Update as per @skizzerz's comments
This commit is contained in:
parent
5ec273c6e0
commit
2877abea55
@ -1 +1,2 @@
|
|||||||
typing
|
typing
|
||||||
|
enum
|
||||||
|
107
src/channels.py
107
src/channels.py
@ -1,48 +1,63 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from src.context import IRCContext, Features
|
from src.context import IRCContext, Features
|
||||||
from src.logger import debuglog
|
from src.logger import debuglog
|
||||||
from src import users
|
from src import users
|
||||||
|
|
||||||
Main = None # main channel
|
Main = None # main channel
|
||||||
|
|
||||||
all_channels = {}
|
_channels = {}
|
||||||
|
|
||||||
_states = ("not yet joined", "pending join", "joined", "pending leave", "left channel", "", "deleted", "cleared")
|
_states = ("not yet joined", "pending join", "joined", "pending leave", "left channel", "", "deleted", "cleared")
|
||||||
|
|
||||||
def _strip(name):
|
class _States(Enum):
|
||||||
return name.lstrip("".join(Features["STATUSMSG"]))
|
NotJoined = "not yet joined"
|
||||||
|
PendingJoin = "pending join"
|
||||||
|
Joined = "joined"
|
||||||
|
PendingLeave = "pending leave"
|
||||||
|
Left = "left channel"
|
||||||
|
|
||||||
|
Deleted = "deleted"
|
||||||
|
Cleared = "cleared"
|
||||||
|
|
||||||
|
# This is used to tell if this is a fake channel or not. If this
|
||||||
|
# function returns a true value, then it's a fake channel. This is
|
||||||
|
# useful for testing, where we might want the users in fake channels.
|
||||||
def predicate(name):
|
def predicate(name):
|
||||||
return not name.startswith(tuple(Features["CHANTYPES"]))
|
return not name.startswith(tuple(Features["CHANTYPES"]))
|
||||||
|
|
||||||
def get(name):
|
get = _channels.__getitem__
|
||||||
"""Return an existing channel, or raise a KeyError if it doesn't exist."""
|
|
||||||
|
|
||||||
return all_channels[_strip(name)]
|
def add(name, cli, key=""):
|
||||||
|
|
||||||
def add(name, cli):
|
|
||||||
"""Add and return a new channel, or an existing one if it exists."""
|
"""Add and return a new channel, or an existing one if it exists."""
|
||||||
|
|
||||||
name = _strip(name)
|
# We use add() in a bunch of places where the channel probably (but
|
||||||
|
# not surely) already exists. If it does, obviously we want to use
|
||||||
|
# that one. However, if the client is *not* the same, that means we
|
||||||
|
# would be trying to send something from one connection over to
|
||||||
|
# another one (or some other weird stuff like that). Instead of
|
||||||
|
# jumping through hoops, we just disallow it here.
|
||||||
|
|
||||||
if name in all_channels:
|
if name in _channels:
|
||||||
if cli is not all_channels[name].client:
|
if cli is not _channels[name].client:
|
||||||
raise RuntimeError("different IRC client for channel {0}".format(name))
|
raise RuntimeError("different IRC client for channel {0}".format(name))
|
||||||
return all_channels[name]
|
return _channels[name]
|
||||||
|
|
||||||
cls = Channel
|
cls = Channel
|
||||||
if predicate(name):
|
if predicate(name):
|
||||||
cls = FakeChannel
|
cls = FakeChannel
|
||||||
|
|
||||||
chan = all_channels[name] = cls(name, cli)
|
chan = _channels[name] = cls(name, cli)
|
||||||
chan.join()
|
chan.join(key)
|
||||||
return chan
|
return chan
|
||||||
|
|
||||||
def exists(name):
|
exists = _channels.__contains__
|
||||||
"""Return True if a channel with the name exists, False otherwise."""
|
|
||||||
|
|
||||||
return _strip(name) in all_channels
|
def channels():
|
||||||
|
"""Iterate over all the current channels."""
|
||||||
|
yield from _channels.values()
|
||||||
|
|
||||||
class Channel(IRCContext):
|
class Channel(IRCContext):
|
||||||
|
|
||||||
@ -53,37 +68,51 @@ class Channel(IRCContext):
|
|||||||
self.users = set()
|
self.users = set()
|
||||||
self.modes = {}
|
self.modes = {}
|
||||||
self.timestamp = None
|
self.timestamp = None
|
||||||
self.state = 0
|
self.state = _States.NotJoined
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
self.users.clear()
|
self.users.clear()
|
||||||
self.modes.clear()
|
self.modes.clear()
|
||||||
self.state = -2
|
self.state = _States.Deleted
|
||||||
self.client = None
|
self.client = None
|
||||||
self.timestamp = None
|
self.timestamp = None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{self.__class__.__name__}: {self.name} ({0})".format(_states[self.state], self=self)
|
return "{self.__class__.__name__}: {self.name} ({0})".format(self.state.value, self=self)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "{self.__class__.__name__}({self.name!r})".format(self=self)
|
return "{self.__class__.__name__}({self.name!r})".format(self=self)
|
||||||
|
|
||||||
def join(self, key=""):
|
def join(self, key=""):
|
||||||
if self.state in (0, 4):
|
if self.state in (_States.NotJoined, _States.Left):
|
||||||
self.state = 1
|
self.state = _States.PendingJoin
|
||||||
self.client.send("JOIN {0} :{1}".format(self.name, key))
|
self.client.send("JOIN {0} :{1}".format(self.name, key))
|
||||||
|
|
||||||
def part(self, message=""):
|
def part(self, message=""):
|
||||||
if self.state == 2:
|
if self.state is _States.Joined:
|
||||||
self.state = 3
|
self.state = _States.PendingLeave
|
||||||
self.client.send("PART {0} :{1}".format(self.name, message))
|
self.client.send("PART {0} :{1}".format(self.name, message))
|
||||||
|
|
||||||
def kick(self, target, message=""):
|
def kick(self, target, message=""):
|
||||||
if self.state == 2:
|
if self.state is _States.Joined:
|
||||||
self.client.send("KICK {0} {1} :{2}".format(self.name, target, message))
|
self.client.send("KICK {0} {1} :{2}".format(self.name, target, message))
|
||||||
|
|
||||||
def mode(self, *changes):
|
def mode(self, *changes):
|
||||||
if not changes:
|
"""Perform a mode change on the channel.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
chan.mode() # Will get back the modes on the channel
|
||||||
|
chan.mode("b") # Will get the banlist back
|
||||||
|
chan.mode("-m")
|
||||||
|
chan.mode(["-v", "woffle"], ["+o", "Vgr"])
|
||||||
|
chan.mode("-m", ("+v", "jacob1"), ("-o", "nyuszika7h"))
|
||||||
|
|
||||||
|
This performs both single and complex mode changes.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not changes: # bare call; get channel modes
|
||||||
self.client.send("MODE", self.name)
|
self.client.send("MODE", self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -93,7 +122,7 @@ class Channel(IRCContext):
|
|||||||
if isinstance(change, str):
|
if isinstance(change, str):
|
||||||
change = (change, None)
|
change = (change, None)
|
||||||
params.append(change)
|
params.append(change)
|
||||||
params.sort(key=lambda x: x[0][0])
|
params.sort(key=lambda x: x[0][0]) # sort by prefix
|
||||||
|
|
||||||
while params:
|
while params:
|
||||||
cur, params = params[:max_modes], params[max_modes:]
|
cur, params = params[:max_modes], params[max_modes:]
|
||||||
@ -109,13 +138,23 @@ class Channel(IRCContext):
|
|||||||
final.append(mode)
|
final.append(mode)
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if target is not None:
|
if target is not None: # target will be None if the mode is parameter-less
|
||||||
final.append(" ")
|
final.append(" ")
|
||||||
final.append(target)
|
final.append(target)
|
||||||
|
|
||||||
self.client.send("MODE", self.name, "".join(final))
|
self.client.send("MODE", self.name, "".join(final))
|
||||||
|
|
||||||
def update_modes(self, rawnick, mode, targets):
|
def update_modes(self, rawnick, mode, targets):
|
||||||
|
"""Update the channel's mode registry with the new modes.
|
||||||
|
|
||||||
|
This is called whenever a MODE event is received. All of the
|
||||||
|
modes are kept up-to-date in the channel, even if we don't need
|
||||||
|
it. For instance, banlists are updated properly when the bot
|
||||||
|
receives them. We don't need all the mode information, but it's
|
||||||
|
better to have everything stored than only some parts.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
set_time = int(time.time()) # for list modes timestamp
|
set_time = int(time.time()) # for list modes timestamp
|
||||||
list_modes, all_set, only_set, no_set = Features["CHANMODES"]
|
list_modes, all_set, only_set, no_set = Features["CHANMODES"]
|
||||||
status_modes = Features["PREFIX"].values()
|
status_modes = Features["PREFIX"].values()
|
||||||
@ -127,7 +166,7 @@ class Channel(IRCContext):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if prefix == "+":
|
if prefix == "+":
|
||||||
if c in status_modes:
|
if c in status_modes: # op/voice status; keep it here and update the user's registry too
|
||||||
if c not in self.modes:
|
if c not in self.modes:
|
||||||
self.modes[c] = set()
|
self.modes[c] = set()
|
||||||
user = users.get(targets[i], allow_bot=True)
|
user = users.get(targets[i], allow_bot=True)
|
||||||
@ -135,14 +174,14 @@ class Channel(IRCContext):
|
|||||||
user.channels[self].add(c)
|
user.channels[self].add(c)
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
elif c in list_modes:
|
elif c in list_modes: # stuff like bans, quiets, and ban and invite exempts
|
||||||
if c not in self.modes:
|
if c not in self.modes:
|
||||||
self.modes[c] = {}
|
self.modes[c] = {}
|
||||||
self.modes[c][targets[i]] = (rawnick, set_time)
|
self.modes[c][targets[i]] = (rawnick, set_time)
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if c in no_set:
|
if c in no_set: # everything else; e.g. +m, +i, +f, etc.
|
||||||
targ = None
|
targ = None
|
||||||
else:
|
else:
|
||||||
targ = targets[i]
|
targ = targets[i]
|
||||||
@ -182,14 +221,14 @@ class Channel(IRCContext):
|
|||||||
del self.modes[mode]
|
del self.modes[mode]
|
||||||
del user.channels[self]
|
del user.channels[self]
|
||||||
|
|
||||||
def clear(self):
|
def _clear(self):
|
||||||
for user in self.users:
|
for user in self.users:
|
||||||
del user.channels[self]
|
del user.channels[self]
|
||||||
self.users.clear()
|
self.users.clear()
|
||||||
self.modes.clear()
|
self.modes.clear()
|
||||||
self.state = -1
|
self.state = _States.Cleared
|
||||||
self.timestamp = None
|
self.timestamp = None
|
||||||
del all_channels[self.name]
|
del _channels[self.name]
|
||||||
|
|
||||||
class FakeChannel(Channel):
|
class FakeChannel(Channel):
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
Features = {"CASEMAPPING": "rfc1459", "CHARSET": "utf-8", "STATUSMSG": {"@", "+"}, "CHANTYPES": {"#"}} # IRC server features (these are defaults)
|
Features = {"CASEMAPPING": "rfc1459", "CHARSET": "utf-8", "STATUSMSG": {"@", "+"}, "CHANTYPES": {"#"}}
|
||||||
|
|
||||||
def lower(nick):
|
def lower(nick):
|
||||||
if nick is None:
|
if nick is None:
|
||||||
@ -41,17 +41,18 @@ class IRCContext:
|
|||||||
return "PRIVMSG"
|
return "PRIVMSG"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def raw_send(data, client, send_type, name):
|
def _send(data, client, send_type, name):
|
||||||
full_address = "{cli.nickname}!{cli.ident}@{cli.hostmask}".format(cli=client)
|
full_address = "{cli.nickname}!{cli.ident}@{cli.hostmask}".format(cli=client)
|
||||||
|
|
||||||
# Maximum length of sent data is 0x200 (512) bytes. However,
|
# Maximum length of sent data is 512 bytes. However, we have to
|
||||||
# we have to reduce the maximum length allowed to account for:
|
# reduce the maximum length allowed to account for:
|
||||||
# 1 (1) - The initial colon at the front of the data
|
# 1 (1) - The initial colon at the front of the data
|
||||||
# 2 (1) - The space between the command and the target
|
# 2 (1) - The space between the sender (us) and the command
|
||||||
# 2 (1) - The space between the target and the data
|
# 3 (1) - The space between the command and the target
|
||||||
# 3 (1) - The colon at the front of the data to send
|
# 4 (1) - The space between the target and the data
|
||||||
# 4 (3) - I don't know why, but we need 3 more/less characters
|
# 5 (1) - The colon at the front of the data to send
|
||||||
length = 0x200 - 7
|
# 6 (2) - The trailing \r\n
|
||||||
|
length = 512 - 7
|
||||||
# Next, we need to reduce the length to account for our address
|
# Next, we need to reduce the length to account for our address
|
||||||
length -= len(full_address)
|
length -= len(full_address)
|
||||||
# Then we also need to account for the target's length
|
# Then we also need to account for the target's length
|
||||||
@ -66,4 +67,4 @@ class IRCContext:
|
|||||||
|
|
||||||
def send(self, data, target=None, *, notice=False, privmsg=False):
|
def send(self, data, target=None, *, notice=False, privmsg=False):
|
||||||
send_type = self.get_send_type(is_notice=notice, is_privmsg=privmsg)
|
send_type = self.get_send_type(is_notice=notice, is_privmsg=privmsg)
|
||||||
self.raw_send(data, self.client, send_type, self.name)
|
self._send(data, self.client, send_type, self.name)
|
||||||
|
60
src/users.py
60
src/users.py
@ -12,17 +12,20 @@ import botconfig
|
|||||||
|
|
||||||
Bot = None # bot instance
|
Bot = None # bot instance
|
||||||
|
|
||||||
all_users = WeakSet()
|
_users = WeakSet()
|
||||||
|
|
||||||
_arg_msg = "(nick={0}, ident={1}, host={2}, realname={3}, account={4}, allow_bot={5})"
|
_arg_msg = "(nick={0}, ident={1}, host={2}, realname={3}, account={4}, allow_bot={5})"
|
||||||
|
|
||||||
|
# This is used to tell if this is a fake nick or not. If this function
|
||||||
|
# returns a true value, then it's a fake nick. This is useful for
|
||||||
|
# testing, where we might want everyone to be fake nicks.
|
||||||
predicate = re.compile(r"^[0-9]+$").search
|
predicate = re.compile(r"^[0-9]+$").search
|
||||||
|
|
||||||
def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_multiple=False, allow_none=False, allow_bot=False, raw_nick=False):
|
def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_multiple=False, allow_none=False, allow_bot=False):
|
||||||
"""Return the matching user(s) from the user list.
|
"""Return the matching user(s) from the user list.
|
||||||
|
|
||||||
This takes up to 5 positional arguments (nick, ident, host, realname,
|
This takes up to 5 positional arguments (nick, ident, host, realname,
|
||||||
account) and may take up to four keyword-only arguments:
|
account) and may take up to three keyword-only arguments:
|
||||||
|
|
||||||
- allow_multiple (defaulting to False) allows multiple matches,
|
- allow_multiple (defaulting to False) allows multiple matches,
|
||||||
and returns a list, even if there's only one match;
|
and returns a list, even if there's only one match;
|
||||||
@ -34,23 +37,17 @@ def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_
|
|||||||
- allow_bot (defaulting to False) allows the bot to be matched and
|
- allow_bot (defaulting to False) allows the bot to be matched and
|
||||||
returned;
|
returned;
|
||||||
|
|
||||||
- raw_nick (defaulting to False) means that the nick has not been
|
|
||||||
yet parsed, and so ident and host will be None, and nick will be
|
|
||||||
a raw nick of the form nick!ident@host.
|
|
||||||
|
|
||||||
If allow_multiple is not set and multiple users match, a ValueError
|
If allow_multiple is not set and multiple users match, a ValueError
|
||||||
will be raised. If allow_none is not set and no users match, a KeyError
|
will be raised. If allow_none is not set and no users match, a KeyError
|
||||||
will be raised.
|
will be raised.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if raw_nick:
|
if ident is None and host is None and nick is not None:
|
||||||
if ident is not None or host is not None:
|
|
||||||
raise ValueError("ident and host need to be None if raw_nick is True")
|
|
||||||
nick, ident, host = parse_rawnick(nick)
|
nick, ident, host = parse_rawnick(nick)
|
||||||
|
|
||||||
potential = []
|
potential = []
|
||||||
users = set(all_users)
|
users = set(_users)
|
||||||
if allow_bot:
|
if allow_bot:
|
||||||
users.add(Bot)
|
users.add(Bot)
|
||||||
|
|
||||||
@ -83,7 +80,7 @@ def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_
|
|||||||
|
|
||||||
return potential[0]
|
return potential[0]
|
||||||
|
|
||||||
def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channels=None, raw_nick=False):
|
def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channels=None):
|
||||||
"""Create a new user, add it to the user list and return it.
|
"""Create a new user, add it to the user list and return it.
|
||||||
|
|
||||||
This function takes up to 6 keyword-only arguments (and no positional
|
This function takes up to 6 keyword-only arguments (and no positional
|
||||||
@ -91,15 +88,9 @@ def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channe
|
|||||||
With the exception of the first one, any parameter can be omitted.
|
With the exception of the first one, any parameter can be omitted.
|
||||||
If a matching user already exists, a ValueError will be raised.
|
If a matching user already exists, a ValueError will be raised.
|
||||||
|
|
||||||
The raw_nick keyword argument may be set if the nick has not yet
|
|
||||||
been parsed. In that case, ident and host must both be None, and
|
|
||||||
nick must be in the form nick!ident@host.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if raw_nick:
|
if ident is None and host is None and nick is not None:
|
||||||
if ident is not None or host is not None:
|
|
||||||
raise ValueError("ident and host need to be None if raw_nick is True")
|
|
||||||
nick, ident, host = parse_rawnick(nick)
|
nick, ident, host = parse_rawnick(nick)
|
||||||
|
|
||||||
if exists(nick, ident, host, realname, account, allow_multiple=True, allow_bot=True):
|
if exists(nick, ident, host, realname, account, allow_multiple=True, allow_bot=True):
|
||||||
@ -115,10 +106,10 @@ def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channe
|
|||||||
cls = FakeUser
|
cls = FakeUser
|
||||||
|
|
||||||
new = cls(cli, nick, ident, host, realname, account, channels)
|
new = cls(cli, nick, ident, host, realname, account, channels)
|
||||||
all_users.add(new)
|
_users.add(new)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def exists(*args, allow_none=False, **kwargs):
|
def exists(nick=None, ident=None, host=None, realname=None, account=None, *, allow_multiple=False, allow_bot=False):
|
||||||
"""Return True if a matching user exists.
|
"""Return True if a matching user exists.
|
||||||
|
|
||||||
Positional and keyword arguments are the same as get(), with the
|
Positional and keyword arguments are the same as get(), with the
|
||||||
@ -127,29 +118,18 @@ def exists(*args, allow_none=False, **kwargs):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if allow_none: # why would you even want to do that?
|
|
||||||
raise RuntimeError("Cannot use allow_none=True with exists()")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
get(*args, **kwargs)
|
get(nick, ident, host, realname, account, allow_multiple=allow_multiple, allow_bot=allow_bot)
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
_raw_nick_pattern = re.compile(
|
def users():
|
||||||
|
"""Iterate over the users in the registry."""
|
||||||
|
yield from _users
|
||||||
|
|
||||||
r"""
|
_raw_nick_pattern = re.compile(r"^(?P<nick>.+?)(?:!(?P<ident>.+?)@(?P<host>.+))?$")
|
||||||
\A
|
|
||||||
(?P<nick> [^!@\s]+ (?=!|$) )? !?
|
|
||||||
(?P<ident> [^!@\s]+ )? @?
|
|
||||||
(?P<host> \S+ )?
|
|
||||||
\Z
|
|
||||||
""",
|
|
||||||
|
|
||||||
re.VERBOSE
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
def parse_rawnick(rawnick, *, default=None):
|
def parse_rawnick(rawnick, *, default=None):
|
||||||
"""Return a tuple of (nick, ident, host) from rawnick."""
|
"""Return a tuple of (nick, ident, host) from rawnick."""
|
||||||
@ -190,7 +170,7 @@ class User(IRCContext):
|
|||||||
|
|
||||||
def is_owner(self):
|
def is_owner(self):
|
||||||
if self.is_fake:
|
if self.is_fake:
|
||||||
return False # fake nicks can't ever be owner
|
return False
|
||||||
|
|
||||||
hosts = set(botconfig.OWNERS)
|
hosts = set(botconfig.OWNERS)
|
||||||
accounts = set(botconfig.OWNERS_ACCOUNTS)
|
accounts = set(botconfig.OWNERS_ACCOUNTS)
|
||||||
@ -208,7 +188,7 @@ class User(IRCContext):
|
|||||||
|
|
||||||
def is_admin(self):
|
def is_admin(self):
|
||||||
if self.is_fake:
|
if self.is_fake:
|
||||||
return False # they can't be admin, either
|
return False
|
||||||
|
|
||||||
flags = var.FLAGS[self.rawnick] + var.FLAGS_ACCS[self.account]
|
flags = var.FLAGS[self.rawnick] + var.FLAGS_ACCS[self.account]
|
||||||
|
|
||||||
@ -377,7 +357,7 @@ class User(IRCContext):
|
|||||||
max_targets = Features["TARGMAX"][send_type]
|
max_targets = Features["TARGMAX"][send_type]
|
||||||
while targets:
|
while targets:
|
||||||
using, targets = targets[:max_targets], targets[max_targets:]
|
using, targets = targets[:max_targets], targets[max_targets:]
|
||||||
cls.raw_send(message, targets[0].client, send_type, ",".join([t.nick for t in using]))
|
cls._send(message, targets[0].client, send_type, ",".join([t.nick for t in using]))
|
||||||
|
|
||||||
cls._messages.clear()
|
cls._messages.clear()
|
||||||
|
|
||||||
|
@ -28,7 +28,8 @@ if sys.version_info < (3, 3):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
try: # need to manually add dependencies here
|
try: # need to manually add dependencies here
|
||||||
import typing
|
import typing # Python >= 3.5
|
||||||
|
import enum # Python >= 3.4
|
||||||
except ImportError:
|
except ImportError:
|
||||||
command = "python3"
|
command = "python3"
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
|
Loading…
x
Reference in New Issue
Block a user