From e269a5c72d2c8b306e7f68d4ebcf67cc41f7a764 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Fri, 3 Feb 2023 23:27:25 +0800 Subject: [PATCH 1/7] refactor: get ip and acid with unified endpoint --- bitsrun/user.py | 55 ++++++++++++++++++++++++++++++++---------------- bitsrun/utils.py | 48 ------------------------------------------ 2 files changed, 37 insertions(+), 66 deletions(-) diff --git a/bitsrun/user.py b/bitsrun/user.py index 2dfa128..efa4345 100644 --- a/bitsrun/user.py +++ b/bitsrun/user.py @@ -6,7 +6,7 @@ import httpx -from bitsrun.utils import fkbase64, parse_homepage, xencode +from bitsrun.utils import fkbase64, xencode _API_BASE = "http://10.0.0.55" _TYPE_CONST = 1 @@ -29,14 +29,48 @@ class UserResponseType(TypedDict): username: Optional[str] +class LoginStatusRespType(TypedDict): + # Field `error` is `not_online_error` when device is not logged in + error: str + client_ip: Optional[str] + # Field `online_ip` is always present regardless of login status + online_ip: str + # Below are fields only present when device is logged in + sum_bytes: Optional[int] + sum_seconds: Optional[int] + user_balance: Optional[int] + user_name: Optional[str] + wallet_balance: Optional[int] + + class User: def __init__(self, username: str, password: str): self.username = username self.password = password - self.ip, self.acid = parse_homepage(api_base=_API_BASE) + # Initialize reused httpx client 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") + + # Check current login status and get device `online_ip` + login_status = self.get_login_status() + self.ip = login_status.get("online_ip") + + def get_login_status(self) -> LoginStatusRespType: + """Get current login status. + + Returns: + The login status of the current device. If the device is logged in, the + `user_name` field will be present. Otherwise, it will be `None`. As such, + the presence of `user_name` is used to check if the device is logged in. + """ + + resp = self.client.get("/cgi-bin/rad_user_info", params={"callback": "jsonp"}) + return json.loads(resp.text[6:-1]) + def login(self) -> UserResponseType: logged_in_user = self._user_validate() @@ -60,21 +94,6 @@ def _do_action(self, action: Action) -> UserResponseType: response = self.client.get("/cgi-bin/srun_portal", params=params) return json.loads(response.text[6:-1]) - def _get_user_info(self) -> Optional[str]: - """Get current logged in user info if exists. - - Returns: - The username of the current logged in user if exists. - """ - - resp = self.client.get("/cgi-bin/rad_user_info") - data = resp.text - - if data == "not_online_error": - return None - - return data.split(",")[0] - def _user_validate(self) -> Optional[str]: """Check if current logged in user matches the username provided. @@ -85,7 +104,7 @@ def _user_validate(self) -> Optional[str]: The username of the current logged in user if exists. """ - logged_in_user = self._get_user_info() + logged_in_user = self.get_login_status().get("user_name") # Raise exception only if username exists on this IP and # command line arguments provided another username diff --git a/bitsrun/utils.py b/bitsrun/utils.py index a6fa5d1..0de1178 100644 --- a/bitsrun/utils.py +++ b/bitsrun/utils.py @@ -1,53 +1,5 @@ import math from base64 import b64encode -from html.parser import HTMLParser -from typing import Tuple - -import httpx - - -def parse_homepage(api_base: str) -> Tuple[str, str]: - """Parse homepage of 10.0.0.55 and get the acid + ip of current session. - - Raises: - Exception: Throw exception if acid not present in the redirected URL. - Exception: Throw exception if response text does not contain IP. - - Returns: - A tuple of (ip, acid) of the current session. - """ - - res = httpx.get(api_base, follow_redirects=True) - - # ac_id appears in the url query parameter of the redirected URL - ac_id = res.url.params.get("ac_id") - - if not ac_id: - raise Exception("failed to get acid") - - # ip appears in the response HTML - class IPParser(HTMLParser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.ip = None - - def handle_starttag(self, tag, attrs): - if tag == "input": - attr_dict = dict(attrs) - if attr_dict.get("name") == "user_ip": - self.ip = attr_dict["value"] - - def feed(self, *args, **kwargs): - super().feed(*args, **kwargs) - return self.ip - - parser = IPParser() - ip = parser.feed(res.text) - - if not ip: - raise Exception("failed to get ip") - - return ip, ac_id[0] def fkbase64(raw_s: str) -> str: From 7a85f19a11ce7dc7371708b6f635a9c294ce755b Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Fri, 3 Feb 2023 23:43:53 +0800 Subject: [PATCH 2/7] feat: get login status of device --- bitsrun/cli.py | 42 ++++++++++++++++----------- bitsrun/user.py | 75 ++++++++++++++++++++++--------------------------- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/bitsrun/cli.py b/bitsrun/cli.py index 50362ff..25da1bd 100644 --- a/bitsrun/cli.py +++ b/bitsrun/cli.py @@ -5,7 +5,7 @@ import click from bitsrun.config import get_config_paths, read_config -from bitsrun.user import User +from bitsrun.user import User, get_login_status # A hacky way to specify shared options for multiple click commands: # https://stackoverflow.com/questions/40182157/shared-options-and-flags-between-commands @@ -39,6 +39,14 @@ def config_paths(): click.echo("\n".join(map(str, get_config_paths()))) +@cli.command() +def status(): + """Check current network login status.""" + status = get_login_status() + # TODO: Pretty print the status + pprint(status) + + @cli.command() @add_options(_options) def login(username, password, verbose): @@ -58,23 +66,23 @@ def do_action(action, username, password, verbose): if username and not password: password = getpass(prompt="Please enter your password: ") - # Try to read username and password from args provided. If none, look for config - # files in possible paths. If none, fail and prompt user to provide one. - if username and password: - user = User(username, password) - elif conf := read_config(): - user = User(**conf[0]) - if verbose: - click.echo( - click.style("bitsrun: ", fg="blue") - + "Reading config from " - + click.style(conf[1], fg="yellow", underline=True) - ) - else: - ctx = click.get_current_context() - ctx.fail("No username or password provided") - try: + # Try to read username and password from args provided. If none, look for config + # files in possible paths. If none, fail and prompt user to provide one. + if username and password: + user = User(username, password) + elif conf := read_config(): + if verbose: + click.echo( + 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") + if action == "login": resp = user.login() message = f"{user.username} ({resp['online_ip']}) logged in" diff --git a/bitsrun/user.py b/bitsrun/user.py index efa4345..76de652 100644 --- a/bitsrun/user.py +++ b/bitsrun/user.py @@ -43,6 +43,29 @@ class LoginStatusRespType(TypedDict): wallet_balance: Optional[int] +def get_login_status(client: Optional[httpx.Client] = None) -> LoginStatusRespType: + """Get current login status of the device. + + Note: + This function is also used without initializing a `User` instance. As such, + the `client` argument is optional and will be initialized if not provided. + + Args: + client: An optional reused httpx client if provided. Defaults to None. + + Returns: + The login status of the current device. If the device is logged in, the + `user_name` field will be present. Otherwise, it will be `None`. As such, + the presence of `user_name` is used to check if the device is logged in. + """ + + if not client: + client = httpx.Client(base_url=_API_BASE) + + resp = client.get("/cgi-bin/rad_user_info", params={"callback": "jsonp"}) + return json.loads(resp.text[6:-1]) + + class User: def __init__(self, username: str, password: str): self.username = username @@ -56,35 +79,27 @@ def __init__(self, username: str, password: str): self.acid = resp.url.params.get("ac_id") # Check current login status and get device `online_ip` - login_status = self.get_login_status() + login_status = get_login_status(client=self.client) self.ip = login_status.get("online_ip") + self.logged_in_user = login_status.get("user_name") - def get_login_status(self) -> LoginStatusRespType: - """Get current login status. - - Returns: - The login status of the current device. If the device is logged in, the - `user_name` field will be present. Otherwise, it will be `None`. As such, - the presence of `user_name` is used to check if the device is logged in. - """ - - resp = self.client.get("/cgi-bin/rad_user_info", params={"callback": "jsonp"}) - return json.loads(resp.text[6:-1]) + # Validate if current logged in user matches the provided username + if self.logged_in_user and self.logged_in_user != self.username: + raise Exception( + f"Current logged in user ({self.logged_in_user}) and " + f"yours ({self.username}) does not match" + ) def login(self) -> UserResponseType: - logged_in_user = self._user_validate() - # Raise exception if device is already logged in - if logged_in_user == self.username: - raise Exception(f"{logged_in_user}, you are already online") + if self.logged_in_user == self.username: + raise Exception(f"{self.logged_in_user}, you are already online") return self._do_action(Action.LOGIN) def logout(self) -> UserResponseType: - logged_in_user = self._user_validate() - # Raise exception if device is not logged in - if logged_in_user is None: + if self.logged_in_user is None: raise Exception("you have already logged out") return self._do_action(Action.LOGOUT) @@ -94,28 +109,6 @@ def _do_action(self, action: Action) -> UserResponseType: response = self.client.get("/cgi-bin/srun_portal", params=params) return json.loads(response.text[6:-1]) - def _user_validate(self) -> Optional[str]: - """Check if current logged in user matches the username provided. - - Raises: - Exception: If current logged in user and username provided does not match. - - Returns: - The username of the current logged in user if exists. - """ - - logged_in_user = self.get_login_status().get("user_name") - - # Raise exception only if username exists on this IP and - # command line arguments provided another username - if logged_in_user and logged_in_user != self.username: - raise Exception( - f"Current logged in user ({logged_in_user}) and " - f"yours ({self.username}) does not match" - ) - - return logged_in_user - def _get_token(self) -> str: params = {"callback": "jsonp", "username": self.username, "ip": self.ip} response = self.client.get("/cgi-bin/get_challenge", params=params) From 219f112c78ceaa9343924f3e2cc14f6f33875aad Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Sat, 4 Feb 2023 16:04:41 +0800 Subject: [PATCH 3/7] chore(deps): add rich and humanize for pretty printing --- pdm.lock | 60 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 ++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/pdm.lock b/pdm.lock index 858e8b3..1fb7c2a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -94,6 +94,12 @@ dependencies = [ "sniffio", ] +[[package]] +name = "humanize" +version = "4.5.0" +requires_python = ">=3.7" +summary = "Python humanize utilities" + [[package]] name = "identify" version = "2.5.17" @@ -106,6 +112,21 @@ version = "3.4" requires_python = ">=3.5" summary = "Internationalized Domain Names in Applications (IDNA)" +[[package]] +name = "markdown-it-py" +version = "2.1.0" +requires_python = ">=3.7" +summary = "Python port of markdown-it. Markdown parsing, done right!" +dependencies = [ + "mdurl~=0.1", +] + +[[package]] +name = "mdurl" +version = "0.1.2" +requires_python = ">=3.7" +summary = "Markdown URL utilities" + [[package]] name = "mypy" version = "0.991" @@ -162,6 +183,12 @@ dependencies = [ "virtualenv>=20.10.0", ] +[[package]] +name = "pygments" +version = "2.14.0" +requires_python = ">=3.6" +summary = "Pygments is a syntax highlighting package written in Python." + [[package]] name = "pyyaml" version = "6.0" @@ -183,6 +210,17 @@ dependencies = [ "rfc3986==1.5.0", ] +[[package]] +name = "rich" +version = "13.3.1" +requires_python = ">=3.7.0" +summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +dependencies = [ + "markdown-it-py<3.0.0,>=2.1.0", + "pygments<3.0.0,>=2.14.0", + "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", +] + [[package]] name = "ruff" version = "0.0.240" @@ -226,7 +264,7 @@ dependencies = [ [metadata] lock_version = "4.1" -content_hash = "sha256:cc0626bc3792800e8a7c0cc552ca6fa6f6ab5020d8abf7dc15e09b463a3029d2" +content_hash = "sha256:1f056ba916522a6be1602e4f53e74501ddde37e364cb613de186ce49b1f06099" [metadata.files] "anyio 3.6.2" = [ @@ -296,6 +334,10 @@ content_hash = "sha256:cc0626bc3792800e8a7c0cc552ca6fa6f6ab5020d8abf7dc15e09b463 {url = "https://files.pythonhosted.org/packages/ac/a2/0260c0f5d73bdf06e8d3fc1013a82b9f0633dc21750c9e3f3cb1dba7bb8c/httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, {url = "https://files.pythonhosted.org/packages/f5/50/04d5e8ee398a10c767a341a25f59ff8711ae3adf0143c7f8b45fc560d72d/httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, ] +"humanize 4.5.0" = [ + {url = "https://files.pythonhosted.org/packages/ab/bf/4e526ef224ca00f0a2f14513895c8a728aa94682ebbe756447de41230baa/humanize-4.5.0-py3-none-any.whl", hash = "sha256:127e333677183070b82e90e0faef9440f9a16dab92143e52f4523afb08ca9290"}, + {url = "https://files.pythonhosted.org/packages/de/ec/c9fa9a0e2b917bd74c18f9752912fd389b7d8e796cfb864e3c485a9bda5d/humanize-4.5.0.tar.gz", hash = "sha256:d6ed00ed4dc59a66df71012e3d69cf655d7d21b02112d435871998969e8aedc8"}, +] "identify 2.5.17" = [ {url = "https://files.pythonhosted.org/packages/6b/c1/dcb61490b9324dd6c4b071835ce89840536a636512100e300e67e27ab447/identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"}, {url = "https://files.pythonhosted.org/packages/74/6f/752581a147e65f86c00ae0debb21f2217638ff3ca15e7a623f1ff53198a3/identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"}, @@ -304,6 +346,14 @@ content_hash = "sha256:cc0626bc3792800e8a7c0cc552ca6fa6f6ab5020d8abf7dc15e09b463 {url = "https://files.pythonhosted.org/packages/8b/e1/43beb3d38dba6cb420cefa297822eac205a277ab43e5ba5d5c46faf96438/idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, {url = "https://files.pythonhosted.org/packages/fc/34/3030de6f1370931b9dbb4dad48f6ab1015ab1d32447850b9fc94e60097be/idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, ] +"markdown-it-py 2.1.0" = [ + {url = "https://files.pythonhosted.org/packages/33/e9/ac8a93e9eda3891ecdfecf5e01c060bbd2c44d4e3e77efc83b9c7ce9db32/markdown-it-py-2.1.0.tar.gz", hash = "sha256:cf7e59fed14b5ae17c0006eff14a2d9a00ed5f3a846148153899a0224e2c07da"}, + {url = "https://files.pythonhosted.org/packages/f9/3f/ecd1b708973b9a3e4574b43cffc1ce8eb98696da34f1a1c44a68c3c0d737/markdown_it_py-2.1.0-py3-none-any.whl", hash = "sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27"}, +] +"mdurl 0.1.2" = [ + {url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] "mypy 0.991" = [ {url = "https://files.pythonhosted.org/packages/0e/5c/fbe112ca73d4c6a9e65336f48099c60800514d8949b4129c093a84a28dc8/mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, {url = "https://files.pythonhosted.org/packages/14/05/5a4206e269268f4aecb1096bf2375a231c959987ccf3e31313221b8bc153/mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, @@ -360,6 +410,10 @@ content_hash = "sha256:cc0626bc3792800e8a7c0cc552ca6fa6f6ab5020d8abf7dc15e09b463 {url = "https://files.pythonhosted.org/packages/1a/fc/dec2f3a2850d2e0d9b499db12a574e53a502d8bd89f3928be2b42d112200/pre_commit-3.0.3-py2.py3-none-any.whl", hash = "sha256:83e2e8cc5cbb3691cff9474494816918d865120768aa36c9eda6185126667d21"}, {url = "https://files.pythonhosted.org/packages/97/e9/26c7a792c8793e844ccfdac1e832136909f9a49dde32550b961edfacb51e/pre_commit-3.0.3.tar.gz", hash = "sha256:4187e74fda38f0f700256fb2f757774385503b04292047d0899fc913207f314b"}, ] +"pygments 2.14.0" = [ + {url = "https://files.pythonhosted.org/packages/0b/42/d9d95cc461f098f204cd20c85642ae40fbff81f74c300341b8d0e0df14e0/Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, + {url = "https://files.pythonhosted.org/packages/da/6a/c427c06913204e24de28de5300d3f0e809933f376e0b7df95194b2bb3f71/Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, +] "pyyaml 6.0" = [ {url = "https://files.pythonhosted.org/packages/02/25/6ba9f6bb50a3d4fbe22c1a02554dc670682a07c8701d1716d19ddea2c940/PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, {url = "https://files.pythonhosted.org/packages/08/f4/ffa743f860f34a5e8c60abaaa686f82c9ac7a2b50e5a1c3b1eb564d59159/PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, @@ -406,6 +460,10 @@ content_hash = "sha256:cc0626bc3792800e8a7c0cc552ca6fa6f6ab5020d8abf7dc15e09b463 {url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, {url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, ] +"rich 13.3.1" = [ + {url = "https://files.pythonhosted.org/packages/68/31/b8934896818c885001aeb7df388ba0523ea3ec88ad31805983d9b0480a50/rich-13.3.1.tar.gz", hash = "sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f"}, + {url = "https://files.pythonhosted.org/packages/a8/c6/14b77fe7a5fab66ffbeffd6706f598d00a52702846bce0e2339bcf9dd20c/rich-13.3.1-py3-none-any.whl", hash = "sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9"}, +] "ruff 0.0.240" = [ {url = "https://files.pythonhosted.org/packages/0b/56/8c47f6331621afa66b20063024a3de1911e72fbab2c48772b143cc677d48/ruff-0.0.240-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f58f1122001150d70909885ccf43d869237be814d4cfc74bb60b3883635e440a"}, {url = "https://files.pythonhosted.org/packages/16/95/935100db015ed572e4d66c615e71066550303f900f640fc9b934cd41e56c/ruff-0.0.240.tar.gz", hash = "sha256:0f1a0b04ce6f3d59894c64f3c3a5a0a35ff4803b8dc51e962d7de42fdb0f5eb1"}, diff --git a/pyproject.toml b/pyproject.toml index b2269fa..cb10ab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ dependencies = [ "click<9.0.0,>=8.1.3", "platformdirs<3.0.0,>=2.6.2", "httpx>=0.23.3", + "rich>=13.3.1", + "humanize>=4.5.0", ] requires-python = ">=3.8,<4.0" readme = "README.md" From f7c9223420ec186f5fc074806e265a0f044886d9 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Sat, 4 Feb 2023 16:06:00 +0800 Subject: [PATCH 4/7] refactor: separate type models from User --- bitsrun/models.py | 32 ++++++++++++++++++++++++++++++++ bitsrun/user.py | 34 ++-------------------------------- 2 files changed, 34 insertions(+), 32 deletions(-) create mode 100644 bitsrun/models.py diff --git a/bitsrun/models.py b/bitsrun/models.py new file mode 100644 index 0000000..1ad476c --- /dev/null +++ b/bitsrun/models.py @@ -0,0 +1,32 @@ +from enum import Enum +from typing import Literal, Optional, TypedDict, Union + + +class Action(Enum): + LOGIN = "login" + LOGOUT = "logout" + + +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_msg: str + res: Union[Literal["login_error"], Literal["ok"]] + # Field `username` is not present on login fails and all logout scenarios + username: Optional[str] + + +class LoginStatusRespType(TypedDict): + # Field `error` is `not_online_error` when device is not logged in + error: str + client_ip: Optional[str] + # Field `online_ip` is always present regardless of login status + online_ip: str + # Below are fields only present when device is logged in + sum_bytes: Optional[int] + sum_seconds: Optional[int] + user_balance: Optional[int] + user_name: Optional[str] + wallet_balance: Optional[int] diff --git a/bitsrun/user.py b/bitsrun/user.py index 76de652..15ac0a7 100644 --- a/bitsrun/user.py +++ b/bitsrun/user.py @@ -1,11 +1,11 @@ import hmac import json -from enum import Enum from hashlib import sha1 -from typing import Dict, Literal, Optional, TypedDict, Union +from typing import Dict, Optional, Union import httpx +from bitsrun.models import Action, LoginStatusRespType, UserResponseType from bitsrun.utils import fkbase64, xencode _API_BASE = "http://10.0.0.55" @@ -13,36 +13,6 @@ _N_CONST = 200 -class Action(Enum): - LOGIN = "login" - LOGOUT = "logout" - - -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_msg: str - res: Union[Literal["login_error"], Literal["ok"]] - # Field `username` is not present on login fails and all logout scenarios - username: Optional[str] - - -class LoginStatusRespType(TypedDict): - # Field `error` is `not_online_error` when device is not logged in - error: str - client_ip: Optional[str] - # Field `online_ip` is always present regardless of login status - online_ip: str - # Below are fields only present when device is logged in - sum_bytes: Optional[int] - sum_seconds: Optional[int] - user_balance: Optional[int] - user_name: Optional[str] - wallet_balance: Optional[int] - - def get_login_status(client: Optional[httpx.Client] = None) -> LoginStatusRespType: """Get current login status of the device. From 9d0a3832f62ba416c6ecfc40f602e72109f95e5b Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Sat, 4 Feb 2023 16:06:43 +0800 Subject: [PATCH 5/7] feat: add status print to bitsrun --- bitsrun/cli.py | 23 ++++++++++++++++++----- bitsrun/utils.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/bitsrun/cli.py b/bitsrun/cli.py index 25da1bd..a548914 100644 --- a/bitsrun/cli.py +++ b/bitsrun/cli.py @@ -1,11 +1,12 @@ import sys from getpass import getpass -from pprint import pprint import click +from rich import print_json from bitsrun.config import get_config_paths, read_config from bitsrun.user import User, get_login_status +from bitsrun.utils import print_status_table # A hacky way to specify shared options for multiple click commands: # https://stackoverflow.com/questions/40182157/shared-options-and-flags-between-commands @@ -42,9 +43,20 @@ def config_paths(): @cli.command() def status(): """Check current network login status.""" - status = get_login_status() - # TODO: Pretty print the status - pprint(status) + login_status = get_login_status() + + if login_status.get("user_name"): + click.echo( + 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") + + f"{login_status['online_ip']} is offline" + ) @cli.command() @@ -82,6 +94,7 @@ def do_action(action, username, password, verbose): else: ctx = click.get_current_context() ctx.fail("No username or password provided") + sys.exit(1) if action == "login": resp = user.login() @@ -96,7 +109,7 @@ def do_action(action, username, password, verbose): # Output direct result of the API response if verbose if verbose: click.echo(f"{click.style('bitsrun:', fg='cyan')} Response from API:") - pprint(resp) + print_json(data=resp) # 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`. diff --git a/bitsrun/utils.py b/bitsrun/utils.py index 0de1178..b843119 100644 --- a/bitsrun/utils.py +++ b/bitsrun/utils.py @@ -1,6 +1,46 @@ import math from base64 import b64encode +from humanize import naturaldelta, naturalsize +from rich import box +from rich.console import Console +from rich.table import Table + +from bitsrun.models import LoginStatusRespType + + +def print_status_table(login_status: LoginStatusRespType) -> None: + """Print the login status table to the console if logged in. + + You should get something like this: + + ┌──────────────┬──────────────┬──────────────┬──────────────┐ + │ Traffic Used │ Online Time │ User Balance │ Wallet │ + ├──────────────┼──────────────┼──────────────┼──────────────┤ + │ 879.3 MiB │ 3 hours │ 10.00 │ 0.00 │ + └──────────────┴──────────────┴──────────────┴──────────────┘ + """ + + 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_row( + 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}", + ) + + console = Console() + console.print(table) + def fkbase64(raw_s: str) -> str: """Encode string with a magic base64 mask""" From af678827c87f6c86afd23a9d9e2d559e531249f3 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Sat, 4 Feb 2023 16:17:14 +0800 Subject: [PATCH 6/7] chore: bump version and update readme --- README.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd51faa..6e32530 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ pipx install bitsrun ### CLI +Check login status of your device. + +```text +Usage: bitsrun status [OPTIONS] + + Check current network login status. + +Options: + --help Show this message and exit. +``` + +> **Note**: this is the output of `bitsrun status --help`. + +Login or logout with your username and password. + ```text Usage: bitsrun login/logout [OPTIONS] diff --git a/pyproject.toml b/pyproject.toml index cb10ab3..43c5d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bitsrun" -version = "3.3.1" +version = "3.4.0" description = "A headless login / logout script for 10.0.0.55" authors = [{ name = "spencerwooo", email = "spencer.woo@outlook.com" }] dependencies = [ From 579189132c94ebdf686a279da7f69d3471b4c5f1 Mon Sep 17 00:00:00 2001 From: "Spencer (Shangbo Wu)" Date: Sat, 4 Feb 2023 16:25:03 +0800 Subject: [PATCH 7/7] docs: update readme screenshots --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6e32530..d9181a3 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ pipx install bitsrun Check login status of your device. +![bitsrun status](https://user-images.githubusercontent.com/32114380/216757172-368d74bc-ad74-4122-9b1f-9568ce0341d3.png) + ```text Usage: bitsrun status [OPTIONS] @@ -44,6 +46,8 @@ Options: Login or logout with your username and password. +![bitsrun login](https://user-images.githubusercontent.com/32114380/216757151-b6e8c620-48b6-4411-ac41-f07b79ef9827.png) + ```text Usage: bitsrun login/logout [OPTIONS]