diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 27120bb..4e53f27 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,7 +3,7 @@ name: Deploy documentation on: workflow_dispatch: push: - tags: + tags: - 'v[0-9]\.[0-9]+\.[0-9]+' jobs: @@ -15,26 +15,29 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Install uv and set the python version - uses: astral-sh/setup-uv@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Build documentation - run: | - cd docs - uv run make html - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: 'docs/_build/html/' + - name: Install uv and set the python version + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + - name: Install graphviz + run: sudo apt-get install -y graphviz + + - name: Build documentation + run: | + cd docs + uv run make html + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "docs/_build/html/" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..10e83c6 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +reference/ \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 3b25bec..6041c7b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,26 +6,39 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import re project = "FindMy.py" copyright = "2024, Mike Almeloo" author = "Mike Almeloo" -release = "0.2.1" +version = re.sub("^v", "", os.popen("git describe --tags").read().strip()) # noqa: S605, S607 +release = version # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.duration", "autoapi.extension"] +extensions = [ + "myst_parser", + "sphinx.ext.duration", + "sphinx.ext.autodoc", + "sphinx.ext.inheritance_diagram", + "autoapi.extension", +] templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- AutoAPI Options --------------------------------------------------------- autoapi_dirs = ["../findmy/"] +autoapi_root = "reference/" +autoapi_add_toctree_entry = False +autoapi_keep_files = True autoapi_options = [ "members", "undoc-members", "show-inheritance", + "show-inheritance-diagram", "show-module-summary", "special-members", "imported-members", @@ -34,5 +47,5 @@ autoapi_options = [ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "alabaster" +html_theme = "sphinx_book_theme" html_static_path = ["_static"] diff --git a/docs/getstarted/01-account.md b/docs/getstarted/01-account.md new file mode 100644 index 0000000..8bfec34 --- /dev/null +++ b/docs/getstarted/01-account.md @@ -0,0 +1,7 @@ +# Logging in + +Some useful features of this library require an active login session with Apple in order to work correctly. +The reason for this is that the remote endpoints require authentication to actually retrieve data. +This page will guide you through the steps needed to log into an Apple account using FindMy.py. + + diff --git a/docs/getstarted/index.md b/docs/getstarted/index.md new file mode 100644 index 0000000..60bc24b --- /dev/null +++ b/docs/getstarted/index.md @@ -0,0 +1,10 @@ +# Getting Started + +* * * + +```{toctree} +:maxdepth: 1 +:glob: + +* +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f292edc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,35 @@ +# FindMy.py + +FindMy.py is a Python library aiming to provide everything you need +to interact with **Apple's FindMy Network**. +Its primary aims are feature completeness, reliability and elegant API design. +It abstracts all the heavy lifting away, while keeping the control flow in your hands. + +## Jump To + +[//]: # "This is hidden to prevent it from showing on the home page" + +```{toctree} +:hidden: + +Home +``` + +[//]: # "Documentation can be expanded to maxdepth 2" + +```{toctree} +:maxdepth: 2 + +getstarted/index +technical/index +reveng/index +``` + +[//]: # "Show these with a maxdepth of 1" + +```{toctree} +:maxdepth: 1 + +API Reference +genindex +``` diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 6adf9ee..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,18 +0,0 @@ -.. FindMy.py documentation master file, created by - sphinx-quickstart on Tue Jan 2 21:16:55 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to FindMy.py's documentation! -===================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/reveng/20-Network_Requests.md b/docs/reveng/20-Network_Requests.md new file mode 100644 index 0000000..970e8d0 --- /dev/null +++ b/docs/reveng/20-Network_Requests.md @@ -0,0 +1,84 @@ +# Intercepting Network Requests + +A big part of our understanding of how the FindMy network functions originates from +network captures detailing how official apps query location reports. +This page aims to provide a quickstart on setting up an environment in which you can +freely inspect network requests made the FindMy app on a Mac. + +```{note} +This guide has only been tested on Sonoma, but it will likely work on other +versions of MacOS as well. +``` + +## Disabling SSL pinning + +Applications on MacOS implement SSL pinning by default. This means that Apple can determine which server-side +certificates are allowed to be used when an application makes a network request. This presents a problem +when we want to inspect these requests: typically, to inspect encrypted traffic using a proxy, we need to perform +a Man-In-The-Middle (MITM) attack on ourselves in order to 'swap out' the certificate with one that we have the private key of. +This is not possible while SSL pinning is active, because the application will simply reject our certificate. + +For this reason, we will first need to disable SSL pinning. We will do this by utilizing [Frida](https://frida.re/) +to attach to the processes that we want to inspect, and then using a script to bypass SSL pinning. + +Start off by downloading [this JavaScript file](https://gist.github.com/azenla/37f941de24c5dfe46f3b8e93d94ce909) and saving +it to a location where you can easily find it again. + +Next, let's actually install Frida by running the following command: + +```bash +pip install frida-tools==13.7.1 +``` + +```{hint} +The above command installs an older version of Frida that is compatible with the script we are going to use. +If you need to use a newer version for whatever reason, you need to apply [these fixes](https://gist.github.com/azenla/37f941de24c5dfe46f3b8e93d94ce909?permalink_comment_id=5675248#gistcomment-5675248) +to the script we downloaded before continuing. + +Note that I will not be able to provide support if you use a version other than the one suggested above. +``` + +To inspect network requests for FindMy, we want to attach Frida to the `searchpartyuseragent` daemon. +Open a terminal and enter the following command, substituting the path to the script if necessary: + +```bash +frida -l disable-ssl-pin.js searchpartyuseragent +``` + +```{important} +If the above command does not work, you may need to temporarily disable [System Integrity Protection](https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection). +Make sure to re-enable it once you're done intercepting! +``` + +If all went well, Frida should now be running. Keep the terminal open while capturing network requests. + +## Intercepting requests + +If you're already familiar with MITM proxies, you can probably skip this step; just use your favorite proxy +while Frida is running. If you're not, read on. + +We will be using [mitmproxy](https://www.mitmproxy.org/) in order to intercept network requests. Install it before continuing: + +```bash +brew install --cask mitmproxy +``` + +Mitmproxy supports several methods to intercept local traffic. We will be using `Local Capture` mode, as it's the easiest to set up +and tear down afterwards. Run the following command to start the proxy: + +```bash +mitmweb --mode local +``` + +```{tip} +Mitmproxy / MacOS may bug you about enabling the correct profile in system settings. If it does, simply do what it says +and come back here. +``` + +```{tip} +Applications other than FindMy may lose their network connection while the capture is running. Simply stop mitmproxy +once you're done and it will go back to normal. +``` + +If all went well, your browser should open the mitmweb interface. From here, you will see all network requests being made +by `searchpartyuseragent`, as well as their responses. diff --git a/docs/reveng/index.md b/docs/reveng/index.md new file mode 100644 index 0000000..57a03eb --- /dev/null +++ b/docs/reveng/index.md @@ -0,0 +1,14 @@ +# Reverse Engineering + +Want to help reverse engineer pieces of the FindMy network? That's great! +The pages in this category aim to provide documentation on how various parts +of the FindMy network have been reverse engineered, and how you can replicate this setup. + +--- + +```{toctree} +:maxdepth: 1 +:glob: + +* +``` diff --git a/docs/technical/10-Network.md b/docs/technical/10-Network.md new file mode 100644 index 0000000..bee3c16 --- /dev/null +++ b/docs/technical/10-Network.md @@ -0,0 +1,40 @@ +# The Network + +This page aims to provide an overview of how the Find My-network works on a technical level. +It does this by explaining in detail what data the tags are broadcasting, how this is picked up +by surrounding iDevices, and how generated location reports can be retrieved. + +```{note} +While official AirTags (and compatible 3rd party tags) use the same principles as described +in this document, they also offer a key rotation feature. To learn more about +how this works, please check out the dedicated [AirTags](#11-AirTags) page. +``` + +## Overview + +Simply said, the FindMy-network works by having accessories such as AirTags broadcast a unique signal over bluetooth. +Any nearby iDevice, such as iPhones or iPads, are able to receive this signal. Once the device is aware of the nearby +accessory, it will upload its current location to Apple's servers, from where it can be retrieved by the owner of the accessory. + +Apple has put a lot of effort into making the network as private as possible. By utilizing encryption, +it is possible for finder devices to encrypt their location with a key that the accessory is broadcasting +before sending it to Apple. This public key allows encryption of certain data, but not decryption. In order +to download location reports for the accessory, we therefore need the private key corresponding to said public key. +These keys, together called a key pair, were generated and exchanged when the accessory was first paired and are now +stored on the owner's devices. By downloading the correct encrypted location reports and then locally decrypting +said reports using the private key, users are able to track their devices without Apple ever being able to read the location. + +![](https://github.com/seemoo-lab/openhaystack/raw/main/Resources/FindMyOverview.png) + +_An overview of the FindMy-network. Source: [SEEMOO-LAB](https://www.petsymposium.org/2021/files/papers/issue3/popets-2021-0045.pdf)_. + +Fetching (encrypted) location reports still requires an Apple account. It is worth noting however, that anyone can download +anyone else's location reports for any of their devices; however, due to the encryption scheme discussed above, +doing this would be rather useless as none of the retrieved reports could be decrypted. This scheme allows devices +to operate without being linked to a specific Apple account. The below dependency diagram visually explains how this entire +system hinges on the availability of the private key; without it, location reports could not be retrieved and decrypted. + +![](dependency_diagram.png) + +_A dependency diagram of data in the network. An arrow pointing from A to B means that in order +to retrieve B, we first require A._ diff --git a/docs/technical/11-AirTags.md b/docs/technical/11-AirTags.md new file mode 100644 index 0000000..2b34213 --- /dev/null +++ b/docs/technical/11-AirTags.md @@ -0,0 +1,3 @@ +# AirTags + +TODO diff --git a/docs/technical/dependency_diagram.png b/docs/technical/dependency_diagram.png new file mode 100644 index 0000000..506f5d0 Binary files /dev/null and b/docs/technical/dependency_diagram.png differ diff --git a/docs/technical/index.md b/docs/technical/index.md new file mode 100644 index 0000000..3f48b28 --- /dev/null +++ b/docs/technical/index.md @@ -0,0 +1,17 @@ +# Technical Documentation + +This category serves as a place to provide technical documentation about the Find My network. +More specifically, it serves as a technical reference for how certain features in this +library have been implemented. + +Most of the knowledge in this section has been sourced from other genius minds. +Make sure to check out the references section on the specific pages to read more about the topics. + +* * * + +```{toctree} +:maxdepth: 1 +:glob: + +* +``` diff --git a/findmy/accessory.py b/findmy/accessory.py index 544170a..18b0e58 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -41,7 +41,7 @@ class FindMyAccessoryMapping(TypedDict): class RollingKeyPairSource(ABC): - """A class that generates rolling `KeyPair`s.""" + """A class that generates rolling :meth:`KeyPair`s.""" @property @abstractmethod diff --git a/findmy/errors.py b/findmy/errors.py index fbf88c7..b876ebc 100644 --- a/findmy/errors.py +++ b/findmy/errors.py @@ -21,5 +21,5 @@ class InvalidStateError(RuntimeError): """ Raised when a method is used that is in conflict with the internal account state. - For example: calling `BaseAppleAccount.login` while already logged in. + For example: calling :meth:`BaseAppleAccount.login` while already logged in. """ diff --git a/findmy/keys.py b/findmy/keys.py index 76b849f..b899616 100644 --- a/findmy/keys.py +++ b/findmy/keys.py @@ -91,7 +91,7 @@ class HasPublicKey(HasHashedPublicKey, ABC): @property @override def hashed_adv_key_bytes(self) -> bytes: - """See `HasHashedPublicKey.hashed_adv_key_bytes`.""" + """See :meth:`HasHashedPublicKey.hashed_adv_key_bytes`.""" return hashlib.sha256(self.adv_key_bytes).digest() @property @@ -136,7 +136,7 @@ class KeyPair(HasPublicKey, Serializable[KeyPairMapping]): key_type: KeyType = KeyType.UNKNOWN, name: str | None = None, ) -> None: - """Initialize the `KeyPair` with the private key bytes.""" + """Initialize the :meth:`KeyPair` with the private key bytes.""" priv_int = crypto.bytes_to_int(private_key) self._priv_key = ec.derive_private_key( priv_int, @@ -162,15 +162,15 @@ class KeyPair(HasPublicKey, Serializable[KeyPairMapping]): @classmethod def new(cls) -> KeyPair: - """Generate a new random `KeyPair`.""" + """Generate a new random :meth:`KeyPair`.""" return cls(secrets.token_bytes(28)) @classmethod def from_b64(cls, key_b64: str) -> KeyPair: """ - Import an existing `KeyPair` from its base64-encoded representation. + Import an existing :meth:`KeyPair` from its base64-encoded representation. - Same format as returned by `KeyPair.private_key_b64`. + Same format as returned by :meth:`KeyPair.private_key_b64`. """ return cls(base64.b64decode(key_b64)) @@ -185,7 +185,7 @@ class KeyPair(HasPublicKey, Serializable[KeyPairMapping]): """ Return the private key as a base64-encoded string. - Can be re-imported using `KeyPair.from_b64`. + Can be re-imported using :meth:`KeyPair.from_b64`. """ return base64.b64encode(self.private_key_bytes).decode("ascii") @@ -233,10 +233,10 @@ class KeyPair(HasPublicKey, Serializable[KeyPairMapping]): return f'KeyPair(name="{self.name}", public_key="{self.adv_key_b64}", type={self.key_type})' -K = TypeVar("K") +_K = TypeVar("_K") -class KeyGenerator(ABC, Generic[K]): +class KeyGenerator(ABC, Generic[_K]): """KeyPair generator.""" @abstractmethod @@ -244,17 +244,17 @@ class KeyGenerator(ABC, Generic[K]): return NotImplemented @abstractmethod - def __next__(self) -> K: + def __next__(self) -> _K: return NotImplemented @overload @abstractmethod - def __getitem__(self, val: int) -> K: ... + def __getitem__(self, val: int) -> _K: ... @overload @abstractmethod - def __getitem__(self, val: slice) -> Generator[K, None, None]: ... + def __getitem__(self, val: slice) -> Generator[_K, None, None]: ... @abstractmethod - def __getitem__(self, val: int | slice) -> K | Generator[K, None, None]: + def __getitem__(self, val: int | slice) -> _K | Generator[_K, None, None]: return NotImplemented diff --git a/findmy/plist.py b/findmy/plist.py index 76872a2..046243e 100644 --- a/findmy/plist.py +++ b/findmy/plist.py @@ -31,7 +31,7 @@ def get_key() -> bytes: def decrypt_plist(encrypted: str | Path | bytes | IO[bytes], key: bytes) -> dict: """ - Decrypts the encrypted plist file at `encrypted` using the provided `key`. + Decrypts the encrypted plist file at :meth:`encrypted` using the provided :meth:`key`. :param encrypted: If bytes or IO, the encrypted plist data. If str or Path, the path to the encrypted plist file, which is diff --git a/findmy/reports/account.py b/findmy/reports/account.py index 6aef1d1..53f1ab1 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -200,7 +200,7 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): """ Request a 2FA code to be sent to a specific phone number ID. - Consider using `BaseSecondFactorMethod.request` instead. + Consider using :meth:`BaseSecondFactorMethod.request` instead. """ raise NotImplementedError @@ -209,7 +209,7 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): """ Submit a 2FA code that was sent to a specific phone number ID. - Consider using `BaseSecondFactorMethod.submit` instead. + Consider using :meth:`BaseSecondFactorMethod.submit` instead. """ raise NotImplementedError @@ -218,7 +218,7 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): """ Request a 2FA code to be sent to a trusted device. - Consider using `BaseSecondFactorMethod.request` instead. + Consider using :meth:`BaseSecondFactorMethod.request` instead. """ raise NotImplementedError @@ -227,7 +227,7 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): """ Submit a 2FA code that was sent to a trusted device. - Consider using `BaseSecondFactorMethod.submit` instead. + Consider using :meth:`BaseSecondFactorMethod.submit` instead. """ raise NotImplementedError @@ -270,9 +270,9 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): list[LocationReport] | dict[HasHashedPublicKey | RollingKeyPairSource, list[LocationReport]] ]: """ - Fetch location reports for `HasHashedPublicKey`s between `date_from` and `date_end`. + Fetch location reports for :class:`HasHashedPublicKey`s between `date_from` and `date_end`. - Returns a dictionary mapping `HasHashedPublicKey`s to a list of their location reports. + Returns a dictionary mapping :class:`HasHashedPublicKey`s to their location reports. """ raise NotImplementedError @@ -311,9 +311,9 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): list[LocationReport] | dict[HasHashedPublicKey | RollingKeyPairSource, list[LocationReport]] ]: """ - Fetch location reports for a sequence of `HasHashedPublicKey`s for the last `hours` hours. + Fetch location reports for :class:`HasHashedPublicKey`s for the last `hours` hours. - Utility method as an alternative to using `BaseAppleAccount.fetch_reports` directly. + Utility method as an alternative to using :meth:`BaseAppleAccount.fetch_reports` directly. """ raise NotImplementedError @@ -326,13 +326,13 @@ class BaseAppleAccount(Closable, Serializable[AccountStateMapping], ABC): """ Retrieve a complete dictionary of Anisette headers. - Utility method for `AnisetteProvider.get_headers` using this account's user and device ID. + Utility method for :meth:`AnisetteProvider.get_headers` using this account's user/device ID. """ raise NotImplementedError class AsyncAppleAccount(BaseAppleAccount): - """An async implementation of `BaseAppleAccount`.""" + """An async implementation of :meth:`BaseAppleAccount`.""" # auth endpoints _ENDPOINT_GSA = "https://gsa.apple.com/grandslam/GsService2" @@ -357,7 +357,7 @@ class AsyncAppleAccount(BaseAppleAccount): """ Initialize the apple account. - :param anisette: An instance of `AsyncAnisetteProvider`. + :param anisette: An instance of :meth:`AsyncAnisetteProvider`. """ super().__init__() @@ -401,7 +401,7 @@ class AsyncAppleAccount(BaseAppleAccount): @property @override def login_state(self) -> LoginState: - """See `BaseAppleAccount.login_state`.""" + """See :meth:`BaseAppleAccount.login_state`.""" return self._login_state @property @@ -412,7 +412,7 @@ class AsyncAppleAccount(BaseAppleAccount): ) @override def account_name(self) -> str | None: - """See `BaseAppleAccount.account_name`.""" + """See :meth:`BaseAppleAccount.account_name`.""" return self._account_info["account_name"] if self._account_info else None @property @@ -423,7 +423,7 @@ class AsyncAppleAccount(BaseAppleAccount): ) @override def first_name(self) -> str | None: - """See `BaseAppleAccount.first_name`.""" + """See :meth:`BaseAppleAccount.first_name`.""" return self._account_info["first_name"] if self._account_info else None @property @@ -434,7 +434,7 @@ class AsyncAppleAccount(BaseAppleAccount): ) @override def last_name(self) -> str | None: - """See `BaseAppleAccount.last_name`.""" + """See :meth:`BaseAppleAccount.last_name`.""" return self._account_info["last_name"] if self._account_info else None @override @@ -501,7 +501,7 @@ class AsyncAppleAccount(BaseAppleAccount): @require_login_state(LoginState.LOGGED_OUT) @override async def login(self, username: str, password: str) -> LoginState: - """See `BaseAppleAccount.login`.""" + """See :meth:`BaseAppleAccount.login`.""" # LOGGED_OUT -> (REQUIRE_2FA or AUTHENTICATED) new_state = await self._gsa_authenticate(username, password) if new_state == LoginState.REQUIRE_2FA: # pass control back to handle 2FA @@ -513,7 +513,7 @@ class AsyncAppleAccount(BaseAppleAccount): @require_login_state(LoginState.REQUIRE_2FA) @override async def get_2fa_methods(self) -> Sequence[AsyncSecondFactorMethod]: - """See `BaseAppleAccount.get_2fa_methods`.""" + """See :meth:`BaseAppleAccount.get_2fa_methods`.""" methods: list[AsyncSecondFactorMethod] = [] if self._account_info is None: @@ -542,7 +542,7 @@ class AsyncAppleAccount(BaseAppleAccount): @require_login_state(LoginState.REQUIRE_2FA) @override async def sms_2fa_request(self, phone_number_id: int) -> None: - """See `BaseAppleAccount.sms_2fa_request`.""" + """See :meth:`BaseAppleAccount.sms_2fa_request`.""" data = {"phoneNumber": {"id": phone_number_id}, "mode": "sms"} await self._sms_2fa_request( @@ -554,7 +554,7 @@ class AsyncAppleAccount(BaseAppleAccount): @require_login_state(LoginState.REQUIRE_2FA) @override async def sms_2fa_submit(self, phone_number_id: int, code: str) -> LoginState: - """See `BaseAppleAccount.sms_2fa_submit`.""" + """See :meth:`BaseAppleAccount.sms_2fa_submit`.""" data = { "phoneNumber": {"id": phone_number_id}, "securityCode": {"code": str(code)}, @@ -579,7 +579,7 @@ class AsyncAppleAccount(BaseAppleAccount): @require_login_state(LoginState.REQUIRE_2FA) @override async def td_2fa_request(self) -> None: - """See `BaseAppleAccount.td_2fa_request`.""" + """See :meth:`BaseAppleAccount.td_2fa_request`.""" headers = { "Content-Type": "text/x-xml-plist", "Accept": "text/x-xml-plist", @@ -593,7 +593,7 @@ class AsyncAppleAccount(BaseAppleAccount): @require_login_state(LoginState.REQUIRE_2FA) @override async def td_2fa_submit(self, code: str) -> LoginState: - """See `BaseAppleAccount.td_2fa_submit`.""" + """See :meth:`BaseAppleAccount.td_2fa_submit`.""" headers = { "security-code": code, "Content-Type": "text/x-xml-plist", @@ -717,7 +717,7 @@ class AsyncAppleAccount(BaseAppleAccount): ) -> ( list[LocationReport] | dict[HasHashedPublicKey | RollingKeyPairSource, list[LocationReport]] ): - """See `BaseAppleAccount.fetch_reports`.""" + """See :meth:`BaseAppleAccount.fetch_reports`.""" date_to = date_to or datetime.now().astimezone() return await self._reports.fetch_reports( @@ -758,7 +758,7 @@ class AsyncAppleAccount(BaseAppleAccount): ) -> ( list[LocationReport] | dict[HasHashedPublicKey | RollingKeyPairSource, list[LocationReport]] ): - """See `BaseAppleAccount.fetch_last_reports`.""" + """See :meth:`BaseAppleAccount.fetch_last_reports`.""" end = datetime.now(tz=timezone.utc) start = end - timedelta(hours=hours) @@ -971,15 +971,15 @@ class AsyncAppleAccount(BaseAppleAccount): with_client_info: bool = False, serial: str = "0", ) -> dict[str, str]: - """See `BaseAppleAccount.get_anisette_headers`.""" + """See :meth:`BaseAppleAccount.get_anisette_headers`.""" return await self._anisette.get_headers(self._uid, self._devid, serial, with_client_info) class AppleAccount(BaseAppleAccount): """ - A sync implementation of `BaseappleAccount`. + A sync implementation of :meth:`BaseappleAccount`. - Uses `AsyncappleAccount` internally. + Uses :meth:`AsyncappleAccount` internally. """ def __init__( @@ -988,7 +988,7 @@ class AppleAccount(BaseAppleAccount): *, state_info: AccountStateMapping | None = None, ) -> None: - """See `AsyncAppleAccount.__init__`.""" + """See :meth:`AsyncAppleAccount.__init__`.""" self._asyncacc = AsyncAppleAccount(anisette=anisette, state_info=state_info) try: @@ -1001,31 +1001,31 @@ class AppleAccount(BaseAppleAccount): @override async def close(self) -> None: - """See `AsyncAppleAccount.close`.""" + """See :meth:`AsyncAppleAccount.close`.""" await self._asyncacc.close() @property @override def login_state(self) -> LoginState: - """See `AsyncAppleAccount.login_state`.""" + """See :meth:`AsyncAppleAccount.login_state`.""" return self._asyncacc.login_state @property @override def account_name(self) -> str | None: - """See `AsyncAppleAccount.login_state`.""" + """See :meth:`AsyncAppleAccount.login_state`.""" return self._asyncacc.account_name @property @override def first_name(self) -> str | None: - """See `AsyncAppleAccount.first_name`.""" + """See :meth:`AsyncAppleAccount.first_name`.""" return self._asyncacc.first_name @property @override def last_name(self) -> str | None: - """See `AsyncAppleAccount.last_name`.""" + """See :meth:`AsyncAppleAccount.last_name`.""" return self._asyncacc.last_name @override @@ -1051,13 +1051,13 @@ class AppleAccount(BaseAppleAccount): @override def login(self, username: str, password: str) -> LoginState: - """See `AsyncAppleAccount.login`.""" + """See :meth:`AsyncAppleAccount.login`.""" coro = self._asyncacc.login(username, password) return self._evt_loop.run_until_complete(coro) @override def get_2fa_methods(self) -> Sequence[SyncSecondFactorMethod]: - """See `AsyncAppleAccount.get_2fa_methods`.""" + """See :meth:`AsyncAppleAccount.get_2fa_methods`.""" coro = self._asyncacc.get_2fa_methods() methods = self._evt_loop.run_until_complete(coro) @@ -1078,25 +1078,25 @@ class AppleAccount(BaseAppleAccount): @override def sms_2fa_request(self, phone_number_id: int) -> None: - """See `AsyncAppleAccount.sms_2fa_request`.""" + """See :meth:`AsyncAppleAccount.sms_2fa_request`.""" coro = self._asyncacc.sms_2fa_request(phone_number_id) return self._evt_loop.run_until_complete(coro) @override def sms_2fa_submit(self, phone_number_id: int, code: str) -> LoginState: - """See `AsyncAppleAccount.sms_2fa_submit`.""" + """See :meth:`AsyncAppleAccount.sms_2fa_submit`.""" coro = self._asyncacc.sms_2fa_submit(phone_number_id, code) return self._evt_loop.run_until_complete(coro) @override def td_2fa_request(self) -> None: - """See `AsyncAppleAccount.td_2fa_request`.""" + """See :meth:`AsyncAppleAccount.td_2fa_request`.""" coro = self._asyncacc.td_2fa_request() return self._evt_loop.run_until_complete(coro) @override def td_2fa_submit(self, code: str) -> LoginState: - """See `AsyncAppleAccount.td_2fa_submit`.""" + """See :meth:`AsyncAppleAccount.td_2fa_submit`.""" coro = self._asyncacc.td_2fa_submit(code) return self._evt_loop.run_until_complete(coro) @@ -1135,7 +1135,7 @@ class AppleAccount(BaseAppleAccount): ) -> ( list[LocationReport] | dict[HasHashedPublicKey | RollingKeyPairSource, list[LocationReport]] ): - """See `AsyncAppleAccount.fetch_reports`.""" + """See :meth:`AsyncAppleAccount.fetch_reports`.""" coro = self._asyncacc.fetch_reports(keys, date_from, date_to) return self._evt_loop.run_until_complete(coro) @@ -1170,7 +1170,7 @@ class AppleAccount(BaseAppleAccount): ) -> ( list[LocationReport] | dict[HasHashedPublicKey | RollingKeyPairSource, list[LocationReport]] ): - """See `AsyncAppleAccount.fetch_last_reports`.""" + """See :meth:`AsyncAppleAccount.fetch_last_reports`.""" coro = self._asyncacc.fetch_last_reports(keys, hours) return self._evt_loop.run_until_complete(coro) @@ -1180,6 +1180,6 @@ class AppleAccount(BaseAppleAccount): with_client_info: bool = False, serial: str = "0", ) -> dict[str, str]: - """See `AsyncAppleAccount.get_anisette_headers`.""" + """See :meth:`AsyncAppleAccount.get_anisette_headers`.""" coro = self._asyncacc.get_anisette_headers(with_client_info, serial) return self._evt_loop.run_until_complete(coro) diff --git a/findmy/reports/anisette.py b/findmy/reports/anisette.py index 240533c..a16eae1 100644 --- a/findmy/reports/anisette.py +++ b/findmy/reports/anisette.py @@ -135,7 +135,7 @@ class BaseAnisetteProvider(Closable, Serializable, ABC): """ Generate a complete dictionary of Anisette headers. - Consider using `BaseAppleAccount.get_anisette_headers` instead. + Consider using :meth:`BaseAppleAccount.get_anisette_headers` instead. """ headers = { # Current Time @@ -207,7 +207,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa @override def to_json(self, dst: str | Path | None = None, /) -> RemoteAnisetteMapping: - """See `BaseAnisetteProvider.serialize`.""" + """See :meth:`BaseAnisetteProvider.serialize`.""" return save_and_return_json( { "type": "aniRemote", @@ -219,7 +219,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa @classmethod @override def from_json(cls, val: str | Path | RemoteAnisetteMapping) -> RemoteAnisetteProvider: - """See `BaseAnisetteProvider.deserialize`.""" + """See :meth:`BaseAnisetteProvider.deserialize`.""" val = read_data_json(val) assert val["type"] == "aniRemote" @@ -231,7 +231,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa @property @override def otp(self) -> str: - """See `BaseAnisetteProvider.otp`_.""" + """See :meth:`BaseAnisetteProvider.otp`.""" otp = (self._anisette_data or {}).get("X-Apple-I-MD") if otp is None: logger.warning("X-Apple-I-MD header not found! Returning fallback...") @@ -240,7 +240,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa @property @override def machine(self) -> str: - """See `BaseAnisetteProvider.machine`_.""" + """See :meth:`BaseAnisetteProvider.machine`.""" machine = (self._anisette_data or {}).get("X-Apple-I-MD-M") if machine is None: logger.warning("X-Apple-I-MD-M header not found! Returning fallback...") @@ -254,7 +254,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa serial: str = "0", with_client_info: bool = False, ) -> dict[str, str]: - """See `BaseAnisetteProvider.get_headers`_.""" + """See :meth::meth:`BaseAnisetteProvider.get_headers`.""" if self._closed: msg = "RemoteAnisetteProvider has been closed and cannot be used" raise RuntimeError(msg) @@ -270,7 +270,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa @override async def close(self) -> None: - """See `AnisetteProvider.close`.""" + """See :meth:`AnisetteProvider.close`.""" if self._closed: return # Already closed, make it idempotent @@ -283,7 +283,7 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, Serializable[RemoteAnisetteMa class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapping]): - """Anisette provider. Generates headers without a remote server using the `anisette` library.""" + """Local anisette provider using the `anisette` library.""" def __init__( self, @@ -328,7 +328,7 @@ class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapp @override def to_json(self, dst: str | Path | None = None, /) -> LocalAnisetteMapping: - """See `BaseAnisetteProvider.serialize`.""" + """See :meth:`BaseAnisetteProvider.serialize`.""" with BytesIO() as buf: self._ani.save_provisioning(buf) prov_data = base64.b64encode(buf.getvalue()).decode("utf-8") @@ -349,7 +349,7 @@ class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapp *, libs_path: str | Path | None = None, ) -> LocalAnisetteProvider: - """See `BaseAnisetteProvider.deserialize`.""" + """See :meth:`BaseAnisetteProvider.deserialize`.""" val = read_data_json(val) assert val["type"] == "aniLocal" @@ -366,7 +366,7 @@ class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapp serial: str = "0", with_client_info: bool = False, ) -> dict[str, str]: - """See `BaseAnisetteProvider.get_headers`_.""" + """See :meth:`BaseAnisetteProvider.get_headers`.""" self._ani_data = self._ani.get_data() return await super().get_headers(user_id, device_id, serial, with_client_info) @@ -374,7 +374,7 @@ class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapp @property @override def otp(self) -> str: - """See `BaseAnisetteProvider.otp`_.""" + """See :meth:`BaseAnisetteProvider.otp`.""" machine = (self._ani_data or {}).get("X-Apple-I-MD") if machine is None: logger.warning("X-Apple-I-MD header not found! Returning fallback...") @@ -383,7 +383,7 @@ class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapp @property @override def machine(self) -> str: - """See `BaseAnisetteProvider.machine`_.""" + """See :meth:`BaseAnisetteProvider.machine`.""" machine = (self._ani_data or {}).get("X-Apple-I-MD-M") if machine is None: logger.warning("X-Apple-I-MD-M header not found! Returning fallback...") @@ -391,4 +391,4 @@ class LocalAnisetteProvider(BaseAnisetteProvider, Serializable[LocalAnisetteMapp @override async def close(self) -> None: - """See `BaseAnisetteProvider.close`_.""" + """See :meth:`BaseAnisetteProvider.close`.""" diff --git a/findmy/reports/reports.py b/findmy/reports/reports.py index 68c97f3..a8062b8 100644 --- a/findmy/reports/reports.py +++ b/findmy/reports/reports.py @@ -52,14 +52,18 @@ LocationReportMapping = Union[LocationReportEncryptedMapping, LocationReportDecr class LocationReport(HasHashedPublicKey, Serializable[LocationReportMapping]): - """Location report corresponding to a certain `HasHashedPublicKey`.""" + """Location report corresponding to a certain :meth:`HasHashedPublicKey`.""" def __init__( self, payload: bytes, hashed_adv_key: bytes, ) -> None: - """Initialize a `KeyReport`. You should probably use `KeyReport.from_payload` instead.""" + """ + Initialize a :class:`LocationReport`. + + You should probably use :meth:`LocationReport.from_payload` instead. + """ self._payload: bytes = payload self._hashed_adv_key: bytes = hashed_adv_key @@ -68,7 +72,7 @@ class LocationReport(HasHashedPublicKey, Serializable[LocationReportMapping]): @property @override def hashed_adv_key_bytes(self) -> bytes: - """See `HasHashedPublicKey.hashed_adv_key_bytes`.""" + """See :meth:`HasHashedPublicKey.hashed_adv_key_bytes`.""" return self._hashed_adv_key @property @@ -96,7 +100,7 @@ class LocationReport(HasHashedPublicKey, Serializable[LocationReportMapping]): return key.hashed_adv_key_bytes == self._hashed_adv_key def decrypt(self, key: KeyPair) -> None: - """Decrypt the report using its corresponding `KeyPair`.""" + """Decrypt the report using its corresponding :meth:`KeyPair`.""" if not self.can_decrypt(key): msg = "Cannot decrypt with this key!" raise ValueError(msg) @@ -136,7 +140,7 @@ class LocationReport(HasHashedPublicKey, Serializable[LocationReportMapping]): @property def timestamp(self) -> datetime: - """The `datetime` when this report was recorded by a device.""" + """The :meth:`datetime` when this report was recorded by a device.""" timestamp_int = int.from_bytes(self._payload[0:4], "big") + (60 * 60 * 24 * 11323) return datetime.fromtimestamp(timestamp_int, tz=timezone.utc).astimezone() @@ -302,9 +306,9 @@ class LocationReport(HasHashedPublicKey, Serializable[LocationReportMapping]): def __lt__(self, other: LocationReport) -> bool: """ - Compare against another `KeyReport`. + Compare against another :meth:`KeyReport`. - A `KeyReport` is said to be "less than" another `KeyReport` iff its recorded + A :meth:`KeyReport` is said to be "less than" another :meth:`KeyReport` iff its recorded timestamp is strictly less than the other report. """ if isinstance(other, LocationReport): @@ -369,12 +373,12 @@ class LocationReportsFetcher: """ Fetch location reports for a certain device. - When ``device`` is a single :class:`.HasHashedPublicKey`, this method will return + When `device` is a single :class:`HasHashedPublicKey`, this method will return a list of location reports corresponding to that key. - When ``device`` is a :class:`.RollingKeyPairSource`, it will return a list of + When `device` is a :class:`RollingKeyPairSource`, it will return a list of location reports corresponding to that source. - When ``device`` is a sequence of :class:`.HasHashedPublicKey`s or RollingKeyPairSource's, - it will return a dictionary with the :class:`.HasHashedPublicKey` or `.RollingKeyPairSource` + When `device` is a sequence of :class:`HasHashedPublicKey`s or RollingKeyPairSource's, + it will return a dictionary with the provided object as key, and a list of location reports as value. """ key_devs: dict[HasHashedPublicKey, HasHashedPublicKey | RollingKeyPairSource] = {} diff --git a/findmy/reports/state.py b/findmy/reports/state.py index 165f5ae..852ace0 100644 --- a/findmy/reports/state.py +++ b/findmy/reports/state.py @@ -6,7 +6,7 @@ from typing_extensions import override class LoginState(Enum): - """Enum of possible login states. Used for `AppleAccount`'s internal state machine.""" + """Enum of possible login states. Used for :meth:`AppleAccount`'s internal state machine.""" LOGGED_OUT = 0 REQUIRE_2FA = 1 @@ -15,9 +15,9 @@ class LoginState(Enum): def __lt__(self, other: "LoginState") -> bool: """ - Compare against another `LoginState`. + Compare against another :meth:`LoginState`. - A `LoginState` is said to be "less than" another `LoginState` iff it is in + A :meth:`LoginState` is said to be "less than" another :meth:`LoginState` iff it is in an "earlier" stage of the login process, going from LOGGED_OUT to LOGGED_IN. """ if isinstance(other, LoginState): diff --git a/findmy/reports/twofactor.py b/findmy/reports/twofactor.py index 6a51e5b..c7bbb67 100644 --- a/findmy/reports/twofactor.py +++ b/findmy/reports/twofactor.py @@ -63,13 +63,13 @@ class AsyncSecondFactorMethod(BaseSecondFactorMethod, ABC): @override @abstractmethod async def request(self) -> None: - """See `BaseSecondFactorMethod.request`.""" + """See :meth:`BaseSecondFactorMethod.request`.""" raise NotImplementedError @override @abstractmethod async def submit(self, code: str) -> LoginState: - """See `BaseSecondFactorMethod.submit`.""" + """See :meth:`BaseSecondFactorMethod.submit`.""" raise NotImplementedError @@ -93,13 +93,13 @@ class SyncSecondFactorMethod(BaseSecondFactorMethod, ABC): @override @abstractmethod def request(self) -> None: - """See `BaseSecondFactorMethod.request`.""" + """See :meth:`BaseSecondFactorMethod.request`.""" raise NotImplementedError @override @abstractmethod def submit(self, code: str) -> LoginState: - """See `BaseSecondFactorMethod.submit`.""" + """See :meth:`BaseSecondFactorMethod.submit`.""" raise NotImplementedError @@ -128,7 +128,7 @@ class TrustedDeviceSecondFactorMethod(BaseSecondFactorMethod, ABC): class AsyncSmsSecondFactor(AsyncSecondFactorMethod, SmsSecondFactorMethod): - """An async implementation of `SmsSecondFactorMethod`.""" + """An async implementation of :meth:`SmsSecondFactorMethod`.""" def __init__( self, @@ -139,7 +139,7 @@ class AsyncSmsSecondFactor(AsyncSecondFactorMethod, SmsSecondFactorMethod): """ Initialize the second factor method. - Should not be done manually; use `AsyncAppleAccount.get_2fa_methods` instead. + Should not be done manually; use :meth:`AsyncAppleAccount.get_2fa_methods` instead. """ super().__init__(account) @@ -174,7 +174,7 @@ class AsyncSmsSecondFactor(AsyncSecondFactorMethod, SmsSecondFactorMethod): class SyncSmsSecondFactor(SyncSecondFactorMethod, SmsSecondFactorMethod): - """A sync implementation of `SmsSecondFactorMethod`.""" + """A sync implementation of :meth:`SmsSecondFactorMethod`.""" def __init__( self, @@ -182,7 +182,7 @@ class SyncSmsSecondFactor(SyncSecondFactorMethod, SmsSecondFactorMethod): number_id: int, phone_number: str, ) -> None: - """See `AsyncSmsSecondFactor.__init__`.""" + """See :meth:`AsyncSmsSecondFactor.__init__`.""" super().__init__(account) self._phone_number_id: int = number_id @@ -191,28 +191,28 @@ class SyncSmsSecondFactor(SyncSecondFactorMethod, SmsSecondFactorMethod): @property @override def phone_number_id(self) -> int: - """See `AsyncSmsSecondFactor.phone_number_id`.""" + """See :meth:`AsyncSmsSecondFactor.phone_number_id`.""" return self._phone_number_id @property @override def phone_number(self) -> str: - """See `AsyncSmsSecondFactor.phone_number`.""" + """See :meth:`AsyncSmsSecondFactor.phone_number`.""" return self._phone_number @override def request(self) -> None: - """See `AsyncSmsSecondFactor.request`.""" + """See :meth:`AsyncSmsSecondFactor.request`.""" return self.account.sms_2fa_request(self._phone_number_id) @override def submit(self, code: str) -> LoginState: - """See `AsyncSmsSecondFactor.submit`.""" + """See :meth:`AsyncSmsSecondFactor.submit`.""" return self.account.sms_2fa_submit(self._phone_number_id, code) class AsyncTrustedDeviceSecondFactor(AsyncSecondFactorMethod, TrustedDeviceSecondFactorMethod): - """An async implementation of `TrustedDeviceSecondFactorMethod`.""" + """An async implementation of :meth:`TrustedDeviceSecondFactorMethod`.""" @override async def request(self) -> None: @@ -224,14 +224,14 @@ class AsyncTrustedDeviceSecondFactor(AsyncSecondFactorMethod, TrustedDeviceSecon class SyncTrustedDeviceSecondFactor(SyncSecondFactorMethod, TrustedDeviceSecondFactorMethod): - """A sync implementation of `TrustedDeviceSecondFactorMethod`.""" + """A sync implementation of :meth:`TrustedDeviceSecondFactorMethod`.""" @override def request(self) -> None: - """See `AsyncTrustedDeviceSecondFactor.request`.""" + """See :meth:`AsyncTrustedDeviceSecondFactor.request`.""" return self.account.td_2fa_request() @override def submit(self, code: str) -> LoginState: - """See `AsyncTrustedDeviceSecondFactor.submit`.""" + """See :meth:`AsyncTrustedDeviceSecondFactor.submit`.""" return self.account.td_2fa_submit(code) diff --git a/findmy/scanner/scanner.py b/findmy/scanner/scanner.py index af10f12..ad50589 100644 --- a/findmy/scanner/scanner.py +++ b/findmy/scanner/scanner.py @@ -211,7 +211,7 @@ class SeparatedOfflineFindingDevice(OfflineFindingDevice, HasPublicKey): detected_at: datetime, additional_data: dict[Any, Any] | None = None, ) -> None: - """Initialize a `SeparatedOfflineFindingDevice`.""" + """Initialize a :meth:`SeparatedOfflineFindingDevice`.""" super().__init__(mac_bytes, status, detected_at, additional_data) self._public_key: bytes = public_key @@ -225,7 +225,7 @@ class SeparatedOfflineFindingDevice(OfflineFindingDevice, HasPublicKey): @property @override def adv_key_bytes(self) -> bytes: - """See `HasPublicKey.adv_key_bytes`.""" + """See :meth:`HasPublicKey.adv_key_bytes`.""" return self._public_key @override @@ -300,7 +300,7 @@ _DEVICE_TYPES = { class OfflineFindingScanner: - """BLE scanner that searches for `OfflineFindingDevice`s.""" + """BLE scanner that searches for :meth:`OfflineFindingDevice`s.""" _scan_ctrl_lock = asyncio.Lock() @@ -311,7 +311,7 @@ class OfflineFindingScanner: Initialize an instance of the Scanner using an event loop. You most likely do not want to use this yourself; - check out `OfflineFindingScanner.create` instead. + check out :meth:`OfflineFindingScanner.create` instead. """ self._scanner: BleakScanner = BleakScanner(self._scan_callback, cb={"use_bdaddr": True}) @@ -377,10 +377,10 @@ class OfflineFindingScanner: extend_timeout: bool = False, ) -> AsyncGenerator[OfflineFindingDevice, None]: """ - Scan for `OfflineFindingDevice`s for up to `timeout` seconds. + Scan for :meth:`OfflineFindingDevice`s for up to :meth:`timeout` seconds. - If `extend_timeout` is set, the timer will be extended - by `timeout` seconds every time a new device is discovered. + If :meth:`extend_timeout` is set, the timer will be extended + by :meth:`timeout` seconds every time a new device is discovered. """ await self._start_scan() diff --git a/findmy/util/abc.py b/findmy/util/abc.py index a88da3b..a660575 100644 --- a/findmy/util/abc.py +++ b/findmy/util/abc.py @@ -19,9 +19,9 @@ class Closable(ABC): def __init__(self, loop: asyncio.AbstractEventLoop | None = None) -> None: """ - Initialize the ``Closable``. + Initialize the :class:`Closable`. - If an event loop is given, the ``Closable`` will attempt to close itself + If an event loop is given, the :class:`Closable` will attempt to close itself using the loop when it is garbage collected. """ self._loop: asyncio.AbstractEventLoop | None = loop @@ -57,7 +57,7 @@ class Serializable(Generic[_T], ABC): If an argument is provided, the output will also be written to that file. The output of this method is guaranteed to be JSON-serializable, and passing - the return value of this function as an argument to `Serializable.from_json` + the return value of this function as an argument to :meth:`Serializable.from_json` will always result in an exact copy of the internal state as it was when exported. You are encouraged to save and load object states to and from disk whenever possible, @@ -69,11 +69,11 @@ class Serializable(Generic[_T], ABC): @abstractmethod def from_json(cls, val: str | Path | _T, /) -> Self: """ - Restore state from a previous `Closable.to_json` export. + Restore state from a previous :meth:`Closable.to_json` export. - If given a str or Path, it must point to a json file from `Serializable.to_json`. + If given a str or Path, it must point to a json file from :meth:`Serializable.to_json`. Otherwise, it should be the Mapping itself. - See `Serializable.to_json` for more information. + See :meth:`Serializable.to_json` for more information. """ raise NotImplementedError diff --git a/findmy/util/files.py b/findmy/util/files.py index e58bfd9..1686bbf 100644 --- a/findmy/util/files.py +++ b/findmy/util/files.py @@ -7,10 +7,10 @@ from collections.abc import Mapping from pathlib import Path from typing import TypeVar, cast -T = TypeVar("T", bound=Mapping) +_T = TypeVar("_T", bound=Mapping) -def save_and_return_json(data: T, dst: str | Path | None) -> T: +def save_and_return_json(data: _T, dst: str | Path | None) -> _T: """Save and return a JSON-serializable data structure.""" if dst is None: return data @@ -23,12 +23,12 @@ def save_and_return_json(data: T, dst: str | Path | None) -> T: return data -def read_data_json(val: str | Path | T) -> T: +def read_data_json(val: str | Path | _T) -> _T: """Read JSON data from a file if a path is passed, or return the argument itself.""" if isinstance(val, str): val = Path(val) if isinstance(val, Path): - val = cast("T", json.loads(val.read_text())) + val = cast("_T", json.loads(val.read_text())) return val diff --git a/findmy/util/http.py b/findmy/util/http.py index 69190d5..780a23d 100644 --- a/findmy/util/http.py +++ b/findmy/util/http.py @@ -33,7 +33,7 @@ class _HttpRequestOptions(_RequestOptions, total=False): class HttpResponse: - """Response of a request made by `HttpSession`.""" + """Response of a request made by :meth:`HttpSession`.""" def __init__(self, status_code: int, content: bytes) -> None: """Initialize the response.""" @@ -115,7 +115,7 @@ class HttpSession(Closable): """ Make an HTTP request. - Keyword arguments will directly be passed to `aiohttp.ClientSession.request`. + Keyword arguments will directly be passed to :meth:`aiohttp.ClientSession.request`. """ session = await self._get_session() diff --git a/findmy/util/types.py b/findmy/util/types.py index 9326eda..370ad61 100644 --- a/findmy/util/types.py +++ b/findmy/util/types.py @@ -3,8 +3,8 @@ from collections.abc import Coroutine from typing import TypeVar, Union -T = TypeVar("T") +_T = TypeVar("_T") # Cannot use `|` operator (PEP 604) in python 3.9, # even with __future__ import since it is evaluated directly -MaybeCoro = Union[T, Coroutine[None, None, T]] +MaybeCoro = Union[_T, Coroutine[None, None, _T]] diff --git a/pyproject.toml b/pyproject.toml index 6bc8200..7d9dc78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,13 @@ dev = [ "packaging>=25.0,<26.0", ] test = ["pytest>=8.3.2,<9.0.0"] -docs = ["sphinx>=8.2.3,<8.3.0", "sphinx-autoapi==3.6.0"] +docs = [ + "furo>=2025.7.19", + "myst-parser>=4.0.1", + "sphinx>=8.2.3,<8.3.0", + "sphinx-autoapi==3.6.0", + "sphinx-book-theme>=1.1.4", +] [tool.pyright] venvPath = "." diff --git a/shell.nix b/shell.nix index 46f84b7..828be14 100644 --- a/shell.nix +++ b/shell.nix @@ -1,18 +1,21 @@ -{ pkgs ? import {} }: +{ + pkgs ? import { }, +}: let - unstable = import (fetchTarball https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz) { }; + unstable = import (fetchTarball "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz") { }; in pkgs.mkShell { packages = with pkgs; [ python312 unstable.uv gh + graphviz ]; shellHook = '' - if [[ -d .venv/ ]]; then - source .venv/bin/activate - fi + if [[ -d .venv/ ]]; then + source .venv/bin/activate + fi ''; -} \ No newline at end of file +} diff --git a/uv.lock b/uv.lock index f5509e4..e48a8a6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.9, <3.14" resolution-markers = [ "python_full_version >= '3.12'", @@ -7,6 +7,18 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -577,8 +589,11 @@ dev = [ { name = "tomli" }, ] docs = [ + { name = "furo", marker = "python_full_version >= '3.11'" }, + { name = "myst-parser", marker = "python_full_version >= '3.11'" }, { name = "sphinx", marker = "python_full_version >= '3.11'" }, { name = "sphinx-autoapi", marker = "python_full_version >= '3.11'" }, + { name = "sphinx-book-theme", marker = "python_full_version >= '3.11'" }, ] test = [ { name = "pytest" }, @@ -604,8 +619,11 @@ dev = [ { name = "tomli", specifier = ">=2.0.1,<3.0.0" }, ] docs = [ + { name = "furo", marker = "python_full_version >= '3.11'", specifier = ">=2025.7.19" }, + { name = "myst-parser", marker = "python_full_version >= '3.11'", specifier = ">=4.0.1" }, { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = ">=8.2.3,<8.3.0" }, { name = "sphinx-autoapi", marker = "python_full_version >= '3.11'", specifier = "==3.6.0" }, + { name = "sphinx-book-theme", marker = "python_full_version >= '3.11'", specifier = ">=1.1.4" }, ] test = [{ name = "pytest", specifier = ">=8.3.2,<9.0.0" }] @@ -734,6 +752,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" }, ] +[[package]] +name = "furo" +version = "2025.7.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments", marker = "python_full_version >= '3.11'" }, + { name = "beautifulsoup4", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", marker = "python_full_version >= '3.11'" }, + { name = "sphinx-basic-ng", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/69/312cd100fa45ddaea5a588334d2defa331ff427bcb61f5fe2ae61bdc3762/furo-2025.7.19.tar.gz", hash = "sha256:4164b2cafcf4023a59bb3c594e935e2516f6b9d35e9a5ea83d8f6b43808fe91f", size = 1662054, upload-time = "2025-07-19T10:52:09.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/34/2b07b72bee02a63241d654f5d8af87a2de977c59638eec41ca356ab915cd/furo-2025.7.19-py3-none-any.whl", hash = "sha256:bdea869822dfd2b494ea84c0973937e35d1575af088b6721a29c7f7878adc9e3", size = 342175, upload-time = "2025-07-19T10:52:02.399Z" }, +] + [[package]] name = "identify" version = "2.6.12" @@ -782,6 +816,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -850,6 +896,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[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 = "multidict" version = "6.6.3" @@ -970,6 +1037,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" }, ] +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1150,6 +1234,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydata-sphinx-theme" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "beautifulsoup4", marker = "python_full_version >= '3.11'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/ea/3ab478cccacc2e8ef69892c42c44ae547bae089f356c4b47caf61730958d/pydata_sphinx_theme-0.15.4.tar.gz", hash = "sha256:7762ec0ac59df3acecf49fd2f889e1b4565dbce8b88b2e29ee06fdd90645a06d", size = 2400673, upload-time = "2024-06-25T19:28:45.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d3/c622950d87a2ffd1654208733b5bd1c5645930014abed8f4c0d74863988b/pydata_sphinx_theme-0.15.4-py3-none-any.whl", hash = "sha256:2136ad0e9500d0949f96167e63f3e298620040aea8f9c74621959eda5d4cf8e6", size = 4640157, upload-time = "2024-06-25T19:28:42.383Z" }, +] + [[package]] name = "pyelftools" version = "0.32" @@ -1434,6 +1537,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/17/0eda9dc80fcaf257222b506844207e71b5d59567c41bbdcca2a72da119b9/sphinx_autoapi-3.6.0-py3-none-any.whl", hash = "sha256:f3b66714493cab140b0e896d33ce7137654a16ac1edb6563edcbd47bf975f711", size = 35281, upload-time = "2025-02-18T01:50:52.789Z" }, ] +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-book-theme" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydata-sphinx-theme", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/19/d002ed96bdc7738c15847c730e1e88282d738263deac705d5713b4d8fa94/sphinx_book_theme-1.1.4.tar.gz", hash = "sha256:73efe28af871d0a89bd05856d300e61edce0d5b2fbb7984e84454be0fedfe9ed", size = 439188, upload-time = "2025-02-20T16:32:32.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/9e/c41d68be04eef5b6202b468e0f90faf0c469f3a03353f2a218fd78279710/sphinx_book_theme-1.1.4-py3-none-any.whl", hash = "sha256:843b3f5c8684640f4a2d01abd298beb66452d1b2394cd9ef5be5ebd5640ea0e1", size = 433952, upload-time = "2025-02-20T16:32:31.009Z" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0"