diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a678308..60caaf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,20 +10,23 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.11" cache: "pip" # caching pip dependencies - - run: pip install -r requirements.txt - - name: Ruff linting + - run: pip install -e . + + - name: Ruff format uses: chartboost/ruff-action@v1 + with: + args: format --check - - name: Black formatting - uses: psf/black@stable + - name: Ruff lint + uses: chartboost/ruff-action@v1 - name: Mypy run: | diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 0acf48c..d01feb3 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -9,7 +9,7 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 @@ -20,7 +20,7 @@ jobs: - name: Set up build tools run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install -e . python -m pip install setuptools build - name: Build package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebe8bf6..2519c51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: asset_name: bitsrun-universal-macos.bin steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 @@ -34,7 +34,7 @@ jobs: - name: Set up build tools run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install -e . python -m pip install pyinstaller - name: Compile single executable for Windows/Linux diff --git a/pyproject.toml b/pyproject.toml index bdc7bd7..3c51a06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bitsrun" -version = "3.6.0" +version = "3.6.1" description = "A headless login / logout script for 10.0.0.55" authors = [{ name = "spencerwooo", email = "spencer.woo@outlook.com" }] dependencies = [ @@ -45,6 +45,10 @@ build-backend = "setuptools.build_meta" line-length = 88 select = ["E", "F", "I", "N", "B", "SIM"] +[tool.ruff.format] +# Use single quotes rather than double quotes. +quote-style = "single" + [tool.mypy] disallow_any_unimported = true no_implicit_optional = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3f87d62..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -httpx>=0.24.0 -rich>=13.3.5 -humanize>=4.5.0 -click>=8.1.3 -platformdirs>=2.6.2 diff --git a/src/bitsrun/cli.py b/src/bitsrun/cli.py index c6f181f..93645b7 100644 --- a/src/bitsrun/cli.py +++ b/src/bitsrun/cli.py @@ -13,9 +13,9 @@ # A hacky way to specify shared options for multiple click commands: # https://stackoverflow.com/questions/40182157/shared-options-and-flags-between-commands _options = [ - click.option("-u", "--username", help="Your username.", required=False), - click.option("-p", "--password", help="Your password.", required=False), - click.option("-v", "--verbose", is_flag=True, help="Verbosely echo API response."), + click.option('-u', '--username', help='Your username.', required=False), + click.option('-p', '--password', help='Your password.', required=False), + click.option('-v', '--verbose', is_flag=True, help='Verbosely echo API response.'), ] # Replace the default implementation @@ -46,11 +46,11 @@ def cli(): @cli.command() def config_paths(): """List possible paths of the configuration file.""" - click.echo("\n".join(map(str, get_config_paths()))) + click.echo('\n'.join(map(str, get_config_paths()))) @cli.command() -@click.option("--json/--no-json", default=False, help="Output in JSON format.") +@click.option('--json/--no-json', default=False, help='Output in JSON format.') def status(json: bool): """Check current network login status.""" login_status = get_login_status() @@ -61,15 +61,15 @@ def status(json: bool): return # Output in human-readable format - if login_status.get("user_name"): + if login_status.get('user_name'): click.echo( - click.style("bitsrun: ", fg="green") + click.style('bitsrun: ', fg='green') + f"{login_status['user_name']} ({login_status['online_ip']}) is online" ) print_status_table(login_status) else: click.echo( - click.style("bitsrun: ", fg="cyan") + click.style('bitsrun: ', fg='cyan') + f"{login_status['online_ip']} is offline" ) @@ -78,20 +78,20 @@ def status(json: bool): @add_options(_options) def login(username, password, verbose): """Log into the BIT network.""" - do_action("login", username, password, verbose) + do_action('login', username, password, verbose) @cli.command() @add_options(_options) def logout(username, password, verbose): """Log out of the BIT network.""" - do_action("logout", username, password, verbose) + do_action('logout', username, password, verbose) def do_action(action, username, password, verbose): # Support reading password from stdin when not passed via `--password` if username and not password: - password = getpass(prompt="Please enter your password: ") + password = getpass(prompt='Please enter your password: ') try: # Try to read username and password from args provided. If none, look for config @@ -101,25 +101,25 @@ def do_action(action, username, password, verbose): elif conf := read_config(): if verbose: click.echo( - click.style("bitsrun: ", fg="blue") - + "Reading config from " - + click.style(conf[1], fg="yellow", underline=True) + click.style('bitsrun: ', fg='blue') + + 'Reading config from ' + + click.style(conf[1], fg='yellow', underline=True) ) user = User(**conf[0]) else: ctx = click.get_current_context() - ctx.fail("No username or password provided") + ctx.fail('No username or password provided') sys.exit(1) - if action == "login": + if action == 'login': resp = user.login() message = f"{user.username} ({resp['online_ip']}) logged in" - elif action == "logout": + elif action == 'logout': resp = user.logout() message = f"{resp['online_ip']} logged out" else: # Should not reach here, but just in case - raise ValueError(f"Unknown action `{action}`") + raise ValueError(f'Unknown action `{action}`') # Output direct result of the API response if verbose if verbose: @@ -128,11 +128,11 @@ def do_action(action, username, password, verbose): # Handle error from API response. When field `error` is not `ok`, then the # login/logout action has likely failed. Hints are provided in the `error_msg`. - if resp["error"] != "ok": + if resp['error'] != 'ok': raise Exception( - resp["error_msg"] - if resp["error_msg"] - else "Action failed, use --verbose for more info" + resp['error_msg'] + if resp['error_msg'] + else 'Action failed, use --verbose for more info' ) # Print success message @@ -145,5 +145,5 @@ def do_action(action, username, password, verbose): sys.exit(1) -if __name__ == "__main__": +if __name__ == '__main__': cli() diff --git a/src/bitsrun/config.py b/src/bitsrun/config.py index 950cb32..53423c8 100644 --- a/src/bitsrun/config.py +++ b/src/bitsrun/config.py @@ -6,7 +6,7 @@ from platformdirs import site_config_path, user_config_path -_APP_NAME = "bitsrun" +_APP_NAME = 'bitsrun' def get_config_paths() -> map: @@ -42,20 +42,20 @@ def get_config_paths() -> map: ] # For backward compatibility - if not platform.startswith("win32"): - paths.insert(0, Path("/etc/")) + if not platform.startswith('win32'): + paths.insert(0, Path('/etc/')) - if platform.startswith("darwin"): - xdg_config_home = getenv("XDG_CONFIG_HOME", "") + if platform.startswith('darwin'): + xdg_config_home = getenv('XDG_CONFIG_HOME', '') if xdg_config_home.strip(): paths.append(Path(xdg_config_home)) else: - paths.append(Path.home() / ".config") + paths.append(Path.home() / '.config') paths.append(paths[-1] / _APP_NAME) else: paths.append(user_config_path()) - return map(lambda path: path / "bit-user.json", paths) + return map(lambda path: path / 'bit-user.json', paths) class ConfigType(TypedDict): diff --git a/src/bitsrun/models.py b/src/bitsrun/models.py index 15e94a9..ef9184e 100644 --- a/src/bitsrun/models.py +++ b/src/bitsrun/models.py @@ -5,9 +5,9 @@ class UserResponseType(TypedDict): client_ip: str online_ip: str # Field `error` is also `login_error` when logout action fails - error: Union[Literal["login_error"], Literal["ok"]] + error: Union[Literal['login_error'], Literal['ok']] error_msg: str - res: Union[Literal["login_error"], Literal["ok"]] + res: Union[Literal['login_error'], Literal['ok']] # Field `username` is not present on login fails and all logout scenarios username: Optional[str] diff --git a/src/bitsrun/user.py b/src/bitsrun/user.py index 23586b5..35710d1 100644 --- a/src/bitsrun/user.py +++ b/src/bitsrun/user.py @@ -9,7 +9,7 @@ from bitsrun.models import LoginStatusRespType, UserResponseType from bitsrun.utils import fkbase64, xencode -_API_BASE = "http://10.0.0.55" +_API_BASE = 'http://10.0.0.55' _TYPE_CONST = 1 _N_CONST = 200 @@ -33,7 +33,7 @@ def get_login_status(client: Optional[httpx.Client] = None) -> LoginStatusRespTy if not client: client = httpx.Client(base_url=_API_BASE) - resp = client.get("/cgi-bin/rad_user_info", params={"callback": "jsonp"}) + resp = client.get('/cgi-bin/rad_user_info', params={'callback': 'jsonp'}) return json.loads(resp.text[6:-1]) @@ -46,57 +46,57 @@ def __init__(self, username: str, password: str): self.client = httpx.Client(base_url=_API_BASE) # Get `ac_id` from the redirected login page - resp = self.client.get("/", follow_redirects=True) - self.acid = resp.url.params.get("ac_id") + resp = self.client.get('/', follow_redirects=True) + self.acid = resp.url.params.get('ac_id') # Check current login status and get device `online_ip` login_status = get_login_status(client=self.client) - self.ip = login_status.get("online_ip") - self.logged_in_user = login_status.get("user_name") + self.ip = login_status.get('online_ip') + self.logged_in_user = login_status.get('user_name') # Validate if current logged in user matches the provided username if self.logged_in_user and self.logged_in_user != self.username: warn( - f"Current logged in user ({self.logged_in_user}) and " - f"yours ({self.username}) does not match. " - "Most likely bitsrun will work fine, " - "but that may differ from what you really want.", + f'Current logged in user ({self.logged_in_user}) and ' + f'yours ({self.username}) does not match. ' + 'Most likely bitsrun will work fine, ' + 'but that may differ from what you really want.', stacklevel=1, ) def login(self) -> UserResponseType: # Raise exception if device is already logged in if self.logged_in_user == self.username: - raise Exception(f"{self.logged_in_user}, you are already online") + raise Exception(f'{self.logged_in_user}, you are already online') # Get challenge token for login authentication token = self._get_token() # Prepare params for login request params = { - "callback": "jsonp", - "username": self.username, - "action": "login", - "ac_id": self.acid, - "ip": self.ip, - "type": _TYPE_CONST, - "n": _N_CONST, + 'callback': 'jsonp', + 'username': self.username, + 'action': 'login', + 'ac_id': self.acid, + 'ip': self.ip, + 'type': _TYPE_CONST, + 'n': _N_CONST, } # Encode login data and generate checksum data = { - "username": self.username, - "password": self.password, - "acid": self.acid, - "ip": self.ip, - "enc_ver": "srun_bx1", + 'username': self.username, + 'password': self.password, + 'acid': self.acid, + 'ip': self.ip, + 'enc_ver': 'srun_bx1', } - hmd5 = hmac.new(token.encode(), b"", "MD5").hexdigest() - json_data = json.dumps(data, separators=(",", ":")) - info = "{SRBX1}" + fkbase64(xencode(json_data, token)) + hmd5 = hmac.new(token.encode(), b'', 'MD5').hexdigest() + json_data = json.dumps(data, separators=(',', ':')) + info = '{SRBX1}' + fkbase64(xencode(json_data, token)) chksum = sha1( - "{0}{1}{0}{2}{0}{3}{0}{4}{0}{5}{0}{6}{0}{7}".format( + '{0}{1}{0}{2}{0}{3}{0}{4}{0}{5}{0}{6}{0}{7}'.format( token, self.username, hmd5, @@ -109,32 +109,32 @@ def login(self) -> UserResponseType: ).hexdigest() # Update params with login data, checksum, and encrypted password - params.update({"password": "{MD5}" + hmd5, "chksum": chksum, "info": info}) + params.update({'password': '{MD5}' + hmd5, 'chksum': chksum, 'info': info}) - response = self.client.get("/cgi-bin/srun_portal", params=params) + response = self.client.get('/cgi-bin/srun_portal', params=params) return json.loads(response.text[6:-1]) def logout(self) -> UserResponseType: # Raise exception if device is not logged in if self.logged_in_user is None: - raise Exception("you have already logged out") + raise Exception('you have already logged out') # Logout params contain only the following fields params = { - "callback": "jsonp", - "action": "logout", - "ac_id": self.acid, - "ip": self.ip, - "username": self.logged_in_user, + 'callback': 'jsonp', + 'action': 'logout', + 'ac_id': self.acid, + 'ip': self.ip, + 'username': self.logged_in_user, # `logged_in_user` may differ from `username`. # Anyway, we can logout without password. } - response = self.client.get("/cgi-bin/srun_portal", params=params) + response = self.client.get('/cgi-bin/srun_portal', params=params) return json.loads(response.text[6:-1]) def _get_token(self) -> str: """Get challenge token for login authentication.""" - params = {"callback": "jsonp", "username": self.username, "ip": self.ip} - response = self.client.get("/cgi-bin/get_challenge", params=params) + params = {'callback': 'jsonp', 'username': self.username, 'ip': self.ip} + response = self.client.get('/cgi-bin/get_challenge', params=params) result = json.loads(response.text[6:-1]) - return result["challenge"] + return result['challenge'] diff --git a/src/bitsrun/utils.py b/src/bitsrun/utils.py index b843119..86dd29a 100644 --- a/src/bitsrun/utils.py +++ b/src/bitsrun/utils.py @@ -21,19 +21,19 @@ def print_status_table(login_status: LoginStatusRespType) -> None: └──────────────┴──────────────┴──────────────┴──────────────┘ """ - if not login_status.get("user_name"): + if not login_status.get('user_name'): return table = Table(box=box.SQUARE) - table.add_column("Traffic Used", style="magenta", width=12) - table.add_column("Online Time", style="yellow", width=12) - table.add_column("User Balance", style="green", width=12) - table.add_column("Wallet", style="blue", width=12) + table.add_column('Traffic Used', style='magenta', width=12) + table.add_column('Online Time', style='yellow', width=12) + table.add_column('User Balance', style='green', width=12) + table.add_column('Wallet', style='blue', width=12) table.add_row( - naturalsize(login_status.get("sum_bytes", 0), binary=True), # type: ignore - naturaldelta(login_status.get("sum_seconds", 0)), # type: ignore + naturalsize(login_status.get('sum_bytes', 0), binary=True), # type: ignore + naturaldelta(login_status.get('sum_seconds', 0)), # type: ignore f"{login_status.get('user_balance', 0):0.2f}", f"{login_status.get('wallet_balance', 0):0.2f}", ) @@ -45,8 +45,8 @@ def print_status_table(login_status: LoginStatusRespType) -> None: def fkbase64(raw_s: str) -> str: """Encode string with a magic base64 mask""" trans = str.maketrans( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", - "LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA", + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', + 'LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA', ) ret = b64encode(bytes(ord(i) & 0xFF for i in raw_s)) return ret.decode().translate(trans) @@ -78,7 +78,7 @@ def lencode(msg, key) -> str: if key: m = msg[msg_len - 1] if m < ll - 3 or m > ll: - return "" + return '' ll = m for i in range(0, msg_len): msg[i] = ( @@ -88,11 +88,11 @@ def lencode(msg, key) -> str: + chr(msg[i] >> 24 & 0xFF) ) if key: - return "".join(msg)[0:ll] - return "".join(msg) + return ''.join(msg)[0:ll] + return ''.join(msg) - if msg == "": - return "" + if msg == '': + return '' pwd = sencode(msg, True) pwdk = sencode(key, False) if len(pwdk) < 4: