Merge pull request #7 from malmeloo/feat/better-docs

Better documentation
This commit is contained in:
Mike Almeloo
2025-08-05 15:39:46 +02:00
committed by GitHub
30 changed files with 522 additions and 172 deletions

View File

@@ -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

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
reference/

View File

@@ -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"]

View File

@@ -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.

10
docs/getstarted/index.md Normal file
View File

@@ -0,0 +1,10 @@
# Getting Started
* * *
```{toctree}
:maxdepth: 1
:glob:
*
```

35
docs/index.md Normal file
View File

@@ -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 <self>
```
[//]: # "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 <reference/findmy/index>
genindex
```

View File

@@ -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`

View File

@@ -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.

14
docs/reveng/index.md Normal file
View File

@@ -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:
*
```

View File

@@ -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._

View File

@@ -0,0 +1,3 @@
# AirTags
TODO

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

17
docs/technical/index.md Normal file
View File

@@ -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:
*
```

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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`."""

View File

@@ -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] = {}

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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]]

View File

@@ -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 = "."

View File

@@ -1,18 +1,21 @@
{ pkgs ? import <nixpkgs> {} }:
{
pkgs ? import <nixpkgs> { },
}:
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
'';
}
}

130
uv.lock generated
View File

@@ -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"