diff --git a/.gitignore b/.gitignore index 79ea342..80b7dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ docs/_build pyjd.egg-info/ venv/ .coverage +*.env diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 0000000..224398d --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,14 @@ +[ + { + "label": "Connect MYJD", + "adapter": "Debugpy", + "request": "launch", + "program": "pyjd/__main__.py", + "justMyCode": false, + "purpose": ["debug-test"], + "args": ["connect"], + "env": { + "PYTHONASYNCIODEBUG": "1", + }, + }, +] diff --git a/pyjd/__main__.py b/pyjd/__main__.py new file mode 100644 index 0000000..cba5625 --- /dev/null +++ b/pyjd/__main__.py @@ -0,0 +1,8 @@ +def main() -> None: + from pyjd.cli import app + + app() + + +if __name__ == "__main__": + main() diff --git a/pyjd/cli.py b/pyjd/cli.py new file mode 100644 index 0000000..416b463 --- /dev/null +++ b/pyjd/cli.py @@ -0,0 +1,45 @@ +import dataclasses +import logging +from typing import Annotated + +from cyclopts import App, Parameter +from dotenv import dotenv_values +from rich.logging import RichHandler +from rich.traceback import install + +from pyjd.client import JDDeviceClient +from pyjd.jd_types import LinkCollectingJob +from pyjd.queries import AddLinksQuery + +app = App() + +install() +logger = logging.getLogger("pyjd") +logger.addHandler(RichHandler(level=logging.DEBUG, rich_tracebacks=True)) +logger.setLevel(logging.DEBUG) + + +@app.command() +def connect() -> JDDeviceClient: + creds = dotenv_values() + app.console.log(email := creds["EMAIL"] or "") + device = JDDeviceClient.myjd_connect( + email=email, + password=creds["PASSWORD"] or "", + device_id=creds.get("DEVICE_ID"), + device_name=creds.get("DEVICE_NAME"), + ) + app.console.log(device) + return device + + +@app.command() +def add_links( + link: str, *, query: Annotated[AddLinksQuery | None, Parameter(name="*")] = None +) -> LinkCollectingJob: + device = connect() + query = dataclasses.replace(query, links=link) if query else AddLinksQuery(links=link) + + job = device.linkgrabber.add_links(query) + app.console.log(job) + return job diff --git a/pyjd/client.py b/pyjd/client.py index f6dab89..52c7b36 100644 --- a/pyjd/client.py +++ b/pyjd/client.py @@ -64,10 +64,10 @@ def myjd_connect( cls, email: str, password: str, - device_id: str | None, - device_name: str | None, + device_id: str | None = None, + device_name: str | None = None, ) -> Self: api = MyJDAPI() api.connect(email, password) - device = api.get_device(device_id, device_name) + device = api.get_device(id=device_id, name=device_name) return cls(MyJDConnection(api, device)) diff --git a/pyjd/common.py b/pyjd/common.py index 573f434..89bdf67 100644 --- a/pyjd/common.py +++ b/pyjd/common.py @@ -3,6 +3,8 @@ import dataclasses import json import logging +import time +import urllib.parse from contextvars import ContextVar from typing import TYPE_CHECKING, Any, ClassVar, Self @@ -21,6 +23,18 @@ type Params = list[Any] +def next_request_id() -> int: + return time.time_ns() + + +def update_request_id(url: str | None = None) -> None: + if url and (rid := urllib.parse.parse_qs(url).get("rid")): + new_id = int(rid[0]) + else: + new_id = time.time_ns() + REQUEST_ID.set(new_id) + + class DictDataClass: __dataclass_fields__: ClassVar[dict[str, dataclasses.Field[Any]]] @@ -31,7 +45,7 @@ def __iter__(self) -> Generator[tuple[str, Any]]: # recursive dict conversion def __json__(self) -> dict[str, Any]: - return dataclasses.asdict(self) + return dict(self) @classmethod def filter_dict(cls, data: Mapping[str, Any]) -> dict[str, Any]: @@ -52,18 +66,31 @@ def make_request( headers: dict[str, str] | None = None, data: str | None = None, timeout: int = 60, + method: str = "POST", ) -> requests.Response: logger.debug(f"Request to {url}") headers = headers or {} - headers.setdefault("Content-Type", "application/json; charset=utf-8") - return requests.post(url, headers=headers, timeout=timeout, data=data) + if method == "POST": + headers.setdefault("Content-Type", "application/json; charset=utf-8") + return requests.request(method, url, headers=headers, timeout=timeout, data=data) + + +def _parse_param(params: Params | None): + if not params: + return + for param in params: + if type(param) is dict: + yield {k: v for k, v in param.items() if v is not None} + else: + yield param + +def prepare_api_json(path: str, params: Params | None) -> str: -def prepare_api_json(path: str, params: list[Any] | str | None) -> str: data = { "apiVer": 1, "url": path.partition("?")[0], - "params": params or (), - "rid": 12345, + "params": list(_parse_param(params)), + "rid": REQUEST_ID.get(), } return json.dumps(data) diff --git a/pyjd/direct.py b/pyjd/direct.py index bfa5653..7adb1b3 100644 --- a/pyjd/direct.py +++ b/pyjd/direct.py @@ -7,7 +7,7 @@ import requests -from pyjd.common import Params, make_request, prepare_api_json +from pyjd.common import Params, make_request, prepare_api_json, update_request_id from pyjd.jd_types import JDDevice logger = logging.getLogger(__name__) @@ -26,9 +26,15 @@ class DirectConnection: ), ) + def jd_version(self) -> int: + url = f"{self.base_url}/jd/version" + update_request_id() + resp = make_request(url, headers=self.headers) + return _parse_resp(resp.content) + def is_connected(self) -> bool: try: - make_request(self.base_url + "/jd/version", headers=self.headers) + self.jd_version() except requests.exceptions.RequestException: return False else: @@ -43,6 +49,7 @@ def request_bytes(self, path: str, params: Params | None = None) -> bytes: def request(self, path: str, params: Params | None = None) -> requests.Response: url = f"{self.base_url}{path}" + update_request_id() return make_request(url, data=prepare_api_json(path, params)) diff --git a/pyjd/jd_types.py b/pyjd/jd_types.py index e31b9fb..afa2104 100644 --- a/pyjd/jd_types.py +++ b/pyjd/jd_types.py @@ -21,10 +21,10 @@ import dataclasses -@dataclasses.dataclass(slots=True, frozen=True) +@dataclasses.dataclass(slots=True, frozen=True, order=True) class JDDevice: - name: str id: str + name: str type: str diff --git a/pyjd/myjd/api.py b/pyjd/myjd/api.py index 7229370..0f938c5 100644 --- a/pyjd/myjd/api.py +++ b/pyjd/myjd/api.py @@ -2,11 +2,17 @@ import json import logging -import time import urllib.parse from typing import TYPE_CHECKING, Any -from pyjd.common import Params, make_request, prepare_api_json +from pyjd.common import ( + REQUEST_ID, + Params, + make_request, + next_request_id, + prepare_api_json, + update_request_id, +) from pyjd.crypto import ( create_secret, decrypt_secret, @@ -25,15 +31,10 @@ logger = logging.getLogger(__name__) -def _new_request_id() -> int: - return int(time.time() * 1000) - - class MyJDAPI: """Main class for connecting to the MyJD API.""" def __init__(self, appkey: str | None = None) -> None: - self.__request_id = _new_request_id() self.__api_url = "https://api.jdownloader.org" self.__app_key = appkey or "https://github.com/NTFSvolume/async-jd" self.__api_version = 1 @@ -89,24 +90,20 @@ def __update_encryption_tokens(self) -> None: self.__session.device_secret, self.session_token ) - def update_request_id(self) -> None: - """Update ``__request_id``. - - This has to be done for every new request. - """ - self.__request_id = _new_request_id() - def connect(self, email: str, password: str) -> bool: - self.update_request_id() self.__session = MyJDSession( login_secret=create_secret(email, password, "server"), device_secret=create_secret(email, password, "device"), ) - url = self._sign_url("/my/connect", ("email", email), ("appkey", self.__app_key)) + url = self._sign_url( + "/my/connect", + ("email", email), + ("appkey", self.__app_key), + token=self.__session.login_secret, + ) response = self.request_json(url) self.__session.connected = True - self.update_request_id() self.__session.token = response["sessiontoken"] self.__session.regain_token = response["regaintoken"] self.__update_encryption_tokens() @@ -125,7 +122,6 @@ def reconnect(self) -> bool: ("regaintoken", self.__session.regain_token), ) response = self.request_json(url) - self.update_request_id() self.__session.token = response["sessiontoken"] self.__session.regain_token = response["regaintoken"] self.__update_encryption_tokens() @@ -134,7 +130,6 @@ def reconnect(self) -> bool: def disconnect(self) -> bool: url = self._sign_url("/my/disconnect", ("sessiontoken", self.session_token)) resp = self.request_json(url) - self.update_request_id() self.__session = MyJDSession() assert type(resp) is bool return resp @@ -145,35 +140,40 @@ def export_session(self) -> MyJDSessionBackup: def import_session(self, session: MyJDSessionBackup) -> None: self.__session = session.unfreeze() - def update_devices(self) -> bool: + def update_devices(self) -> None: url = self._sign_url("/my/listdevices", ("sessiontoken", self.session_token)) - response = self.request_json(url) - self.update_request_id() + response = self.request_json(url, [self.session_token], method="GET") self.__session.devices = tuple(JDDevice(**d) for d in response["list"]) - return response - def _sign_url(self, path: str, *params: tuple[str, str | None]) -> str: - query = "&".join([*_quote_query_params(params), f"rid={self.__request_id}"]) + def _sign_url( + self, path: str, *params: tuple[str, str | None], token: bytes | None = None + ) -> str: + query = "&".join([*_quote_query_params(params), f"rid={next_request_id()}"]) url = f"{path}?{query}" - sig = sign_hmac_sha256(self.__server_encryption_token, url) + sig = sign_hmac_sha256(token or self.__server_encryption_token, url) return f"{url}&signature={sig}" - def get_device(self, device_name: str | None = None, device_id: str | None = None) -> JDDevice: + def get_device( + self, + *, + name: str | None = None, + id: str | None = None, # noqa: A002 + ) -> JDDevice: if not self.connected: - raise RuntimeError("No connection established\n") + raise RuntimeError("No connection established") - if not (device_id or device_name): + if not (id or name): raise ValueError("Either device_id or device_name are required") for device in self.__session.devices: - if device_id is not None and device.id != device_id: + if id is not None and device.id != id: continue - if device_name is not None and device.name != device_name: + if name is not None and device.name != name: continue return device - raise LookupError("Device not found\n") + raise LookupError("Device not found") def request( self, @@ -181,23 +181,27 @@ def request( params: Params | None = None, action: str | None = None, api: str | None = None, + method: str = "POST", ) -> requests.Response: """Make a request to the MyJD API.""" - self.update_request_id() api = api or self.__api_url - encrypted_json = None - if not self.connected and path != "/my/connect": - raise RuntimeError("No connection established\n") + is_connect = path.partition("?")[0] == "/my/connect" + if not (self.connected or is_connect): + raise RuntimeError("No connection established") - data = prepare_api_json(path, params).encode("utf-8") - encrypted_json = encrypt_secret(self.__device_encryption_token, data) request_url = api + (action or "") + path - resp = make_request( - request_url, - headers={"Content-Type": "application/aesjson-jd; charset=utf-8"}, - data=encrypted_json, - timeout=3, - ) + update_request_id(request_url) + if is_connect or method == "GET": + resp = make_request(request_url, timeout=30, method="GET") + else: + data = prepare_api_json(path, params).encode("utf-8") + resp = make_request( + request_url, + headers={"Content-Type": "application/aesjson-jd; charset=utf-8"}, + data=encrypt_secret(self.__device_encryption_token, data), + timeout=30, + method="POST", + ) if resp.status_code == 200: return resp @@ -210,18 +214,19 @@ def request_json( params: Params | None = None, action: str | None = None, api: str | None = None, + method: str = "POST", ) -> Any: """Make a request to the MyJD API.""" - resp = self.request(path, params, action, api) + resp = self.request(path, params, action, api, method=method) token = self.__server_encryption_token if action is None else self.__device_encryption_token return self.decode_response(resp, token) def decode_response(self, encrypted_response: requests.Response, token: bytes) -> Any | None: response = decrypt_secret(token, encrypted_response.text) data = json.loads(response.decode("utf-8")) - if data["rid"] == self.__request_id: - raise RuntimeError("Request id does not match") - self.update_request_id() + rid = REQUEST_ID.get() + if data["rid"] != rid: + raise RuntimeError(f"Request id {rid} does not match {data['rid']}") return data diff --git a/pyjd/myjd/connection.py b/pyjd/myjd/connection.py index eedfab0..7a18e0a 100644 --- a/pyjd/myjd/connection.py +++ b/pyjd/myjd/connection.py @@ -83,7 +83,6 @@ def refresh_direct_connections(self) -> None: logger.info("refreshing direct connections") resp = self.api.request_json( "/device/getDirectConnectionInfos", - "POST", action=self.__action_url(), ) resp = DirectConnectionInfos(**resp.get("data", resp)) @@ -103,11 +102,11 @@ def disable_direct_connect(self) -> None: def request_json( self, path: str, - params: Params | None = (), + params: Params | None = None, http_action: Literal["GET", "POST"] = "POST", ) -> Any: response = self.request(path, params, http_action) - data = self.api.decode_response(response, self.__action_url()) + data = self.api.decode_response(response, b"") if data is None: return None return data.get("data", data) @@ -115,7 +114,7 @@ def request_json( def request( self, path: str, - params: Params | None = (), + params: Params | None = None, http_action: Literal["GET", "POST"] = "POST", ) -> requests.Response: if not self._ready: @@ -136,10 +135,10 @@ def request( try: response = self.api.request( path, - http_action, params, action_url, api=f"http://{address.ip}:{address.port}", + method=http_action, ) except requests.exceptions.RequestException: self._direct_conns[address] = now + 60 @@ -157,7 +156,7 @@ def request( def request_bytes( self, path: str, - params: Params | None = (), + params: Params | None = None, http_action: Literal["GET", "POST"] = "POST", ) -> bytes: return self.request(path, params, http_action).content @@ -168,7 +167,7 @@ def _myjd_request( params: Any | None = (), http_action: Literal["GET", "POST"] = "POST", ) -> requests.Response: - response = self.api.request(path, http_action, params, self.__action_url()) + response = self.api.request(path, params, self.__action_url(), method=http_action) self.refresh_direct_connections() return response diff --git a/pyproject.toml b/pyproject.toml index 93cf6f5..850d0f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.14" ] dependencies = [ + "cyclopts>=4.22.5", "pycryptodome", "pydantic>=2.13.4", "requests" @@ -26,6 +27,9 @@ license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.12" +[project.scripts] +pyjd = "pyjd.__main__:main" + [build-system] build-backend = "uv_build" requires = ["uv_build>=0.11.18,<0.12"] @@ -36,6 +40,7 @@ dev = [ "pytest-asyncio>=1.3.0", "pytest-cov>=7.1.0", "pytest>=9.1.1", + "python-dotenv>=1.2.3", "ruff>=0.15.18" ] diff --git a/uv.lock b/uv.lock index 27c7c1c..48decf7 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ name = "async-jd" version = "0.0.1" source = { editable = "." } dependencies = [ + { name = "cyclopts" }, { name = "pycryptodome" }, { name = "pydantic" }, { name = "requests" }, @@ -27,11 +28,13 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "python-dotenv" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "cyclopts", specifier = ">=4.22.5" }, { name = "pycryptodome" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "requests" }, @@ -43,9 +46,19 @@ dev = [ { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "python-dotenv", specifier = ">=1.2.3" }, { name = "ruff", specifier = ">=0.15.18" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -206,6 +219,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/5e/a8ba14ceb014f39bd5e3f7077150718c7de61c01ce326bfe7e8eae9b19b2/coverage-7.14.2-py3-none-any.whl", hash = "sha256:04d92589e481a8b68a005a5a1e0646a91c76f322c397c4635298c57cf63699b5", size = 212325, upload-time = "2026-06-20T14:49:28.991Z" }, ] +[[package]] +name = "cyclopts" +version = "4.22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/05/689617b7e86503417c172f577d791524cb13b9697303d5d44409a971ba10/cyclopts-4.22.5.tar.gz", hash = "sha256:94044506317462cad90fb01a917dadce1f48a0915ba3605dc8d178dea1229e24", size = 195144, upload-time = "2026-08-04T13:53:00.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/58/bcab9c33fb7a25a1f5970f357c5b19729bc81d50615d2f737b20c4255909/cyclopts-4.22.5-py3-none-any.whl", hash = "sha256:cf9ce285836053d156730ea4ea0ad0c75cf63beb3f3d8edf222a795bc57666ab", size = 234557, upload-time = "2026-08-04T13:52:58.509Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -224,6 +261,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -438,6 +496,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -453,6 +520,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + [[package]] name = "ruff" version = "0.15.18"