diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..86c1931 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,20 @@ +name: verify + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + - run: make verify diff --git a/AGENTS.md b/AGENTS.md index 0a1cf3b..46e09b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ uv run --env-file .env scripts/measure_local.py --cloud # M4 to M6 in the clou - [docs/guarantees.md](docs/guarantees.md): what a consumer can rely on, and the proof. - [docs/internal/brief.md](docs/internal/brief.md): the design and its reasons. - [CONTRIBUTING.md](CONTRIBUTING.md): the checks inside `make verify` and the test rules. +- [CHANGELOG.md](CHANGELOG.md): each change to a public surface. +- `src/hotmemory/`: the library. `tests/test_conformance.py` is the conformance suite. ## Rules @@ -37,3 +39,4 @@ uv run --env-file .env scripts/measure_local.py --cloud # M4 to M6 in the clou workspace id, or a database id in a file. - If a change alters a behavior, update `docs/contracts.md` and `docs/guarantees.md` in the same commit. +- If a change alters a frozen surface, add an entry to `CHANGELOG.md` in the same commit. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ed2f459 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +Each change to a public surface of hotmemory has an entry here. The frozen surfaces are the +names in `hotmemory.__all__`, the method set of `Store`, the fields and field types of +`Record` for each schema version, and the filter keys of `list` and `search`. A test guards +each one, and its failure message points to this file. + +## Unreleased + +### Added + +- `Record`, the frozen dataclass for schema version 1, and `normalize`, the form that + deduplication compares. +- `Store`, the protocol of the storage contract, with `put`, `get`, `history`, `list`, + `search`, `delete`, `list_namespaces`, and `writer`. `Writer` is the protocol of the + buffer that `writer` returns. +- `Filter` and `TimeRange`, the exact filters of `list` and `search`, and `Hit`, one search + result with its distance. +- `MemoryStore` and `MemoryWriter`, the in-process driver. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97d66c0..2e91309 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,16 +17,16 @@ make verify `make verify` is the full check. CI runs the same target. If it passes on your machine, it passes in CI. It has no tiers, because the full check takes less than five seconds. -Today, `make verify` runs these checks, in this order: +`make verify` runs these checks, in this order: 1. `ruff check` over the Python files. 2. `ruff format --check` over the Python files and the Python code blocks in Markdown. -3. The link check. It reads each relative link in the Markdown files at the root and +3. Strict `mypy` over `src/` and `tests/`. +4. The offline test suite, with `pytest --disable-socket`. A test that opens a network + socket fails. +5. The link check. It reads each relative link in the Markdown files at the root and under `docs/`. A link to a file that does not exist makes it fail. -When the library exists, strict `mypy` and the offline test suite join `make verify` -before the link check. - ## The other targets | Target | What it does | @@ -38,29 +38,34 @@ before the link check. ## Rules of the harness -The library is deterministic. The offline suite needs no clock, no network, and no model. -These rules keep it that way. - -- One conformance suite runs against every driver. Each answered guarantee in - [docs/guarantees.md](docs/guarantees.md) is one test. A driver that fails a conformance - test is not a driver. -- The in-memory driver is the reference for the Hotdata driver. A test builds the same - records in both drivers and compares the results of `search`. +The library is deterministic. The offline suite needs no real clock, no network, and no +model. The tests pass a fixed clock and a fake embedder to the store, from +`tests/conftest.py`. These rules keep the suite deterministic. + +- One conformance suite, `tests/test_conformance.py`, runs against every driver through + the `store` fixture. Each answered guarantee of the storage contract in + [docs/guarantees.md](docs/guarantees.md) has its tests there. A driver that fails a + conformance test is not a driver. To add a driver, add it to `DRIVERS` in + `tests/conftest.py`. +- The in-memory driver is the reference for the Hotdata driver. From phase 2, a test + builds the same records in both drivers and compares the results of `search`. - Four surfaces are frozen: the names in `__all__`, the method set of the `Store` protocol, the fields and field types of the record for each schema version, and the filter keys of `list` and `search`. A test compares each surface against a literal set. - A change to a frozen surface is a public contract change, and it needs a changelog - entry. + These tests are in `tests/test_frozen.py`. A change to a frozen surface is a public + contract change, and it needs an entry in [CHANGELOG.md](CHANGELOG.md). - A frozen surface is compared against a literal set, never against the thing that it protects. A test that iterates over the protected thing turns a deletion into one test fewer and not into a failure. -- Each row in the guarantees ledger names the test that proves it. A test reads the ledger. - A named test that does not exist makes it fail. -- Tests marked `hotdata` run the Hotdata driver against a real database. They need - `HOTMEMORY_TEST_DB` to name a throwaway database. Without it, they skip. They +- Each row in the guarantees ledger names the conformance tests that prove it, or the phase + that will prove it. `tests/test_ledger.py` reads the ledger. A named test that the + conformance suite does not define makes it fail. A row with no test and no phase also + makes it fail. +- From phase 2, tests marked `hotdata` run the Hotdata driver against a real database. + They need `HOTMEMORY_TEST_DB` to name a throwaway database. Without it, they skip. They run once for each pull request. They are the only tests that use the network. -- No test calls a model. `capture` takes a callable, and the tests pass a fake extractor - that returns fixed facts. +- No test calls a model. The tests pass a fake embedder. From phase 3, `capture` takes a + callable, and the tests pass a fake extractor that returns fixed facts. - There is no coverage gate, no mutation-testing gate, and no report generator. The output of `make verify` is the report. @@ -68,7 +73,8 @@ These rules keep it that way. Documents are checked like code. If you change a behavior, update [docs/contracts.md](docs/contracts.md) and [docs/guarantees.md](docs/guarantees.md) in the -same pull request. +same pull request. If you change a public surface, add an entry to +[CHANGELOG.md](CHANGELOG.md). No file in this repository names a private repository, a customer, or a deployment detail. This rule includes `docs/internal/`. diff --git a/Makefile b/Makefile index 2694cf8..9abbb2e 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,8 @@ STORAGE_URL := http://127.0.0.1:9000 verify: uv run --group dev ruff check . uv run --group dev ruff format --check . + uv run --group dev mypy + uv run --group dev pytest --disable-socket -q uv run --no-project python scripts/check_links.py local-up: diff --git a/README.md b/README.md index 385adb5..6d8f75c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ Hotdata table, so an agent can join its memory to its own data in one SQL query. the memory systems that we surveyed stores memory as typed columns in the same engine as the data of the consumer. -Status: design. No code exists yet. The contracts and the guarantees are written, and -phase 0 measured each guarantee that was open. +Status: version 0.0.0, not published. The storage contract exists in Python with one +driver, `MemoryStore`, which runs in process memory. The Hotdata driver and the memory +contract do not exist yet. [docs/internal/roadmap.md](docs/internal/roadmap.md) lists the +phases. The library has two layers: @@ -24,6 +26,31 @@ of the consumer. There is no hotmemory server. The first consumer is an incident investigator built on Hotdata. The library is not specific to it. A plain Python agent, a LangGraph agent, or any process with a Hotdata API key can use it. +## Try the storage contract + +`MemoryStore` needs an embedder for a search with query text. An embedder is a callable +that turns a list of texts into a list of vectors. This example uses a toy embedder that +counts two words: + +```python +from hotmemory import Filter, MemoryStore + + +def embed(texts): + return [[text.count("disk") + 0.1, text.count("cpu") + 0.1] for text in texts] + + +store = MemoryStore(embedder=embed) +store.put(("team", "alerts"), "disk", kind="fact", content="The disk fills at night.") +store.put(("team", "alerts"), "disk", kind="fact", content="The disk fills at noon.") +store.put(("team", "alerts"), "cpu", kind="fact", content="The cpu spikes after a deploy.") + +print(store.get(("team", "alerts"), "disk").id) # team/alerts/disk@2 +print([r.revision for r in store.history(("team", "alerts"), "disk")]) # [1, 2] +hits = store.search("disk", [("team",)], Filter(kind="fact"), k=1) +print(hits[0].record.content) # The disk fills at noon. +``` + ## Documents - [docs/contracts.md](docs/contracts.md): the record, the storage operations, the memory @@ -34,5 +61,6 @@ LangGraph agent, or any process with a Hotdata API key can use it. container. - [CONTRIBUTING.md](CONTRIBUTING.md): the one command that checks a change, and the rules of the test suite. +- [CHANGELOG.md](CHANGELOG.md): each change to a public surface. - [docs/internal/](docs/internal/brief.md): the design brief, the survey, the roadmap, and the plan for the current phase. diff --git a/docs/contracts.md b/docs/contracts.md index e5a8cd5..e69a8f0 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -5,8 +5,9 @@ implements. The memory contract is the surface that an agent calls, and it is bu store. This file states both. The behavior that each contract guarantees, and the proof for each guarantee, are in [guarantees.md](guarantees.md). -Status: design. No code exists yet. This file describes schema version 1 as the library -will ship it. +Status: the storage contract exists in Python, with `MemoryStore` as its only driver. +`HotdataStore` and the memory contract are design, and this file describes them as the +library will ship them. This file describes schema version 1. ## The platform under the store @@ -76,16 +77,16 @@ A record is the unit that the store holds. Schema version 1 fixes these fields. | Field | Type | Meaning | |---|---|---| -| `namespace` | tuple of strings | Where the record lives. The store keeps it as one path string joined with `/`. A match is on whole labels and never on a string prefix. No label contains `.`. | -| `key` | string | The stable identifier of the caller inside the namespace. | +| `namespace` | tuple of strings | Where the record lives. The store keeps it as one path string joined with `/`. A match is on whole labels and never on a string prefix. A namespace has at least one label. A label is not empty and contains no `.` and no `/`. | +| `key` | string | The stable identifier of the caller inside the namespace. A key is not empty and contains no `/` and no `@`. | | `revision` | integer | 1 for the first put under a key. Each later put adds 1. | | `kind` | string | One of `fact`, `profile`, `procedure`, `episode`. | | `subject` | string | What the record is about, for example an alert key, a person, or a service. Empty if unknown. | -| `content` | string | The text that a model reads. | -| `cues` | list of strings | Questions or phrases that this record answers. Optional. The driver embeds them apart from `content`. | +| `content` | string | The text that a model reads. It is not empty after normalization. | +| `cues` | tuple of strings | Questions or phrases that this record answers. Optional. The driver embeds them apart from `content`. | | `payload` | JSON object | Structured data that the consumer defines. The store never reads it. | -| `tags` | list of strings | Free labels. You can filter on them. | -| `sources` | list of strings | References to the origin of the record: a thread id, a document path, a run id, an episode key. The length of the list is the corroboration count. | +| `tags` | tuple of strings | Free labels. You can filter on them. | +| `sources` | tuple of strings | References to the origin of the record: a thread id, a document path, a run id, an episode key. The length of the list is the corroboration count. | | `actor` | string | Who wrote this revision: a user id, an agent name, or an extractor name. | | `created_at` | timestamp | When the store wrote this revision. System clock. | | `observed_at` | timestamp or null | The time of the source. A post-mortem that you load a year later keeps the incident date here. | @@ -97,6 +98,12 @@ A record is the unit that the store holds. Schema version 1 fixes these fields. | `forget_reason` | string | The reason for `forget_after`. Empty if `forget_after` is null. | | `id` | string | `namespace/key@revision`. Derived. The load key. | +In Python, the record is a frozen dataclass. The list fields are tuples, so a record +cannot change after the store writes it. The store copies `payload` when it writes a +record and when it returns one, so a change to a dict that a caller holds never reaches the +store. Every timestamp carries a time zone. The record +refuses a value that the table above does not allow. + The public record has no embedding field. If the caller supplies an embedder, the Hotdata driver adds embedding columns. If the caller uses a provider-backed index, the driver adds none. The driver configuration selects one of the two. @@ -110,18 +117,23 @@ records the moment that the store found out. To ask what memory held on a given | Operation | Arguments | Behavior | |---|---|---| -| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision, and the previous current row gets `superseded_by`. Returns the id. If the normalized content is equal to the content of the current revision, it writes nothing and returns the current id. | +| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision, and the previous current row gets `superseded_by`. Returns the id. If the normalized content is equal to the content of the current revision, it writes nothing and returns the current id. This rule compares content only. It does not apply when the current revision is past its `forget_after`, so a `put` of the same content brings the fact back as a new revision. | | `get` | namespace, key, optional revision | Returns the current revision, or the named revision. Returns None if the record does not exist. | | `history` | namespace, key | Returns every revision, oldest first. | -| `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first. It uses no model and no embedding. | -| `search` | query text or none, namespace prefixes, optional filter, k | Returns up to k current revisions in order of relevance, closest first, each with a distance. It takes the same filter as `list`. With no query text, it is `list`. | +| `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first, with ties in `created_at` ordered by id. `since` keeps the revisions whose `created_at` is at or after it. It uses no model and no embedding. | +| `search` | query text or none, namespace prefixes, optional filter, k | Returns up to k current revisions in order of relevance, closest first, each with a distance. It takes the same filter as `list`. With no query text, it is `list`, and each distance is None. | | `delete` | namespace, key | Removes every revision of the key. This is a hard delete. | -| `list_namespaces` | optional prefix | Returns the distinct namespaces under the prefix. | -| `writer` | none | A context manager. It buffers every `put` inside it. The buffer flushes on exit, at a row count, or at an interval, and returns the ids that it flushed. | +| `list_namespaces` | optional prefix | Returns the distinct namespaces under the prefix that hold a record, sorted. | +| `writer` | optional row count, optional interval | A context manager. It buffers every `put` inside it. The buffer flushes when the block exits, when it reaches the row count, and on the first `put` after the interval passes. The writer records the ids that it flushed. If the block raises an error, the writer drops the buffer. | The filter accepts equality on `kind`, `subject`, `tags`, and `actor`. It accepts a range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. Any other filter raises an -error. +error. In Python, the filter is a frozen dataclass with one optional field for each key, so +an unknown key cannot be written, and a value of the wrong type raises an error. + +- A `tags` filter matches a record that holds every tag that the filter names. +- A range includes its start and excludes its end. A side that is not given is open. +- A null timestamp on a record never matches a range. This is the SQL rule for null. `list` and `search` never return a deleted revision, a superseded revision, or a record past `forget_after`. `history` returns superseded revisions. No operation returns a deleted @@ -139,7 +151,11 @@ Version 1 ships two drivers. - `MemoryStore` runs in the process, in memory. It is a real driver and not a mock. It computes relevance with the same cosine distance that the engine uses, and it refuses a filter that it does not model. The offline test suite runs against it, and it is the - reference for the other driver. + reference for the other driver. It takes two optional arguments. The embedder is a + callable that turns a list of texts into a list of vectors. Without it, a `search` with + query text raises an error. The clock is a callable that returns the current time, and + the default reads the system clock. `MemoryStore` ranks by the cosine distance between + the query and `content` only. It does not rank by BM25 or by `cues`. - `HotdataStore` uses one managed database, two tables per schema version, keyed loads, a serialized writer, and the retrieval query below. With the local RuntimeDB stack, it is also the development driver. diff --git a/docs/guarantees.md b/docs/guarantees.md index c8d5341..5f6d78d 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -11,31 +11,37 @@ Each guarantee has one of two states. - Answered: a measurement or the design already supports the answer. - To measure: a measurement must give the answer before the library relies on it. -A guarantee marked [measured] was observed against a real Hotdata workspace. In phase 1, -each answered guarantee gets one conformance test, and the Test column names it. If a named test -does not exist, a test in the suite fails. Until the suite exists, -the Test column is empty. +A guarantee marked [measured] was observed against a real Hotdata workspace. + +The Test column names the conformance tests in `tests/test_conformance.py` that prove the +guarantee against every driver. If a named test does not exist, `tests/test_ledger.py` +fails. A row that no test proves yet names the phase that will prove it. Phase 2 proves +the rows that only the Hotdata driver can show, and phase 3 proves the rows of the memory +contract. ## The ledger | Question | Answer | State | Test | |---|---|---|---| -| Does a second `put` under the same key replace the record? | No. It writes revision n+1 and marks revision n as superseded. `get` returns n+1. | answered | | -| Can a retried `remember` create duplicates? | No. The key comes from the subject and a hash of the normalized content. A put whose normalized content is equal to the current revision writes nothing. | answered | | -| Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so that a caller can decide. | answered | | -| After a synchronous `put` returns, does `list` see the record? | Yes. A read after a write was never stale in 30 trials. | answered [measured] | | -| After a synchronous `put` returns, does `search` see the record? | Yes. Without an index, the retrieval query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 | | -| After a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes. `writer` returns the ids that it flushed. | answered | | -| Two processes write to the same table. What happens? | The engine refuses the second load with 409. The driver retries with backoff and stops after a bound. Inside one process, the writer sends one load at a time. | answered [measured], M3 | | -| Two writers put the same key. What happens? | The last writer wins at the row level. Revisions are new rows, so both revisions exist and the later one is current. | answered | | -| Does `delete` remove retained revisions and their embeddings? | Yes. An embedding is a column of its row. After a keyed delete, neither a provider-backed vector index nor a BM25 index returned the deleted row. | answered [measured], M2 | | -| Which filters work in `list` and `search`? | Equality on `kind`, `subject`, `tags`, and `actor`. A range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. A prefix on namespace labels. Any other filter raises an error. | answered | | -| Does a higher score mean more relevant? | The store returns a distance, and a lower distance is closer. The memory contract returns records in order, with no score. An adapter that needs a score converts the distance. | answered | | -| What does `recall(as_of=T)` return? | The records that are valid at T by the as-of rule in [contracts.md](contracts.md). If `history` is consulted, this includes records superseded after T. If not, it excludes them. | answered | | -| Who enforces scope? | The library filters on the allowed scopes of the caller. The platform enforces the database boundary through the API token. A caller that holds the token can go around the library. | answered | | -| Can a consumer tell sources, extractions, and hypotheses apart? | Yes, through `kind`, `sources`, and `actor`. An extracted fact carries the name of the extractor in `actor`. | answered | | -| Is a record deleted after its `forget_after` time passes? | No. It stops appearing in `list` and `search`. The sweeper deletes it on its next run. | answered [measured] | | -| Does a write inside a turn reach a `recall` in the same turn? | No, by contract. A consumer reads what was there before its own capture. | answered | | +| Does a second `put` under the same key replace the record? | No. It writes revision n+1 and marks revision n as superseded. `get` returns n+1. | answered || `test_second_put_writes_a_new_revision` | +| Can a retried `remember` create duplicates? | No. The key comes from the subject and a hash of the normalized content. A put whose normalized content is equal to the current revision writes nothing. | answered || phase 3 | +| Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so that a caller can decide. | answered || `test_deduplication_is_exact_on_normalized_content` | +| After a synchronous `put` returns, does `list` see the record? | Yes. A read after a write was never stale in 30 trials. | answered [measured] || `test_synchronous_put_is_visible_to_list` | +| After a synchronous `put` returns, does `search` see the record? | Yes. Without an index, the retrieval query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 || `test_synchronous_put_is_visible_to_search` | +| After a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes. The writer records the ids that it flushed. | answered || `test_buffered_put_is_visible_after_flush` | +| Two processes write to the same table. What happens? | The engine refuses the second load with 409. The driver retries with backoff and stops after a bound. Inside one process, the writer sends one load at a time. | answered [measured], M3 || phase 2 | +| Two writers put the same key. What happens? | The last writer wins at the row level. Revisions are new rows, so both revisions exist and the later one is current. | answered || `test_last_writer_wins_on_one_key` | +| Does `delete` remove retained revisions and their embeddings? | Yes. An embedding is a column of its row. After a keyed delete, neither a provider-backed vector index nor a BM25 index returned the deleted row. | answered [measured], M2 || `test_delete_removes_every_revision` | +| Which filters work in `list` and `search`? | Equality on `kind`, `subject`, `tags`, and `actor`. A range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. A prefix on namespace labels. Any other filter raises an error. | answered || `test_filter_matches_by_equality`, `test_filter_matches_a_time_range`, `test_unknown_filter_key_raises`, `test_prefix_matches_whole_labels` | +| Does a higher score mean more relevant? | The store returns a distance, and a lower distance is closer. The memory contract returns records in order, with no score. An adapter that needs a score converts the distance. | answered || `test_search_returns_distance_closest_first` | +| What does `recall(as_of=T)` return? | The records that are valid at T by the as-of rule in [contracts.md](contracts.md). If `history` is consulted, this includes records superseded after T. If not, it excludes them. | answered || phase 3 | +| Who enforces scope? | The library filters on the allowed scopes of the caller. The platform enforces the database boundary through the API token. A caller that holds the token can go around the library. | answered || phase 3 | +| Can a consumer tell sources, extractions, and hypotheses apart? | Yes, through `kind`, `sources`, and `actor`. An extracted fact carries the name of the extractor in `actor`. | answered || phase 3 | +| Is a record deleted after its `forget_after` time passes? | No. It stops appearing in `list` and `search`. The sweeper deletes it on its next run. | answered [measured] || `test_forget_after_hides_without_deleting`, and the sweeper in phase 2 | +| A record is past its `forget_after`. Does a `put` of the same content write it again? | Yes. Deduplication applies only to a current revision that `list` can return. The put writes a new revision, and `list` and `search` return it. | answered | `test_put_after_forget_after_writes_a_new_revision` | +| Can a caller change a stored record? | No. A record is frozen, and the store copies `payload` on the way in and on the way out. A change to a dict that the caller holds does not reach the store. | answered | `test_stored_record_cannot_change` | +| Can a record have empty content? | No. `put` and a buffered `put` refuse content that is empty after normalization, and write nothing. | answered | `test_put_refuses_empty_content` | +| Does a write inside a turn reach a `recall` in the same turn? | No, by contract. A consumer reads what was there before its own capture. | answered || phase 3 | ## Measurements diff --git a/docs/internal/brief.md b/docs/internal/brief.md index ee33686..3952465 100644 --- a/docs/internal/brief.md +++ b/docs/internal/brief.md @@ -1,7 +1,8 @@ # Brief: hotmemory, agent memory as tables on Hotdata Status: draft, revised 2026-10-05, phases moved to `roadmap.md` and `plan.md`, and the phase 0 -measurements applied (results in `docs/guarantees.md`). Nothing in this brief is built. It fixes the two +measurements applied (results in `docs/guarantees.md`). Phase 1 built the storage contract with +`MemoryStore`, and `docs/contracts.md` states the details that it fixed. It fixes the two contracts, the guarantees behind them, and the harness that proves them, before the first line of code. The revision applies section 4 of `survey.md`, in this folder. @@ -136,16 +137,16 @@ A record is the unit the store holds. Its fields are fixed for schema version 1. | Field | Type | Meaning | |---|---|---| -| `namespace` | tuple of strings | Where the record lives. Stored as a path string joined with `/`, and matched on whole labels, never on a string prefix. No label contains `.`. | -| `key` | string | The caller's stable identifier inside the namespace. | +| `namespace` | tuple of strings | Where the record lives. Stored as a path string joined with `/`, and matched on whole labels, never on a string prefix. A label is not empty and contains no `.` and no `/`. | +| `key` | string | The caller's stable identifier inside the namespace. It is not empty and contains no `/` and no `@`. | | `revision` | integer | 1 for the first put under a key, then counting up. | | `kind` | string | One of `fact`, `profile`, `procedure`, `episode`. | | `subject` | string | What the record is about, for example an alert key, a person, a service. Empty when unknown. | -| `content` | string | The text a model reads. This is the column a provider-backed index embeds. | -| `cues` | list of strings | Questions or phrases this record answers. Optional. Embedded separately from `content`. Section 3.4. | +| `content` | string | The text a model reads. Not empty after normalization. This is the column a provider-backed index embeds. | +| `cues` | tuple of strings | Questions or phrases this record answers. Optional. Embedded separately from `content`. Section 3.4. | | `payload` | JSON object | Structured data the consumer defines. The store never reads it. | -| `tags` | list of strings | Free labels. Filterable. | -| `sources` | list of strings | References to where the record came from: a thread id, a document path, a run id, an episode key. The length of this list is the corroboration count. | +| `tags` | tuple of strings | Free labels. Filterable. | +| `sources` | tuple of strings | References to where the record came from: a thread id, a document path, a run id, an episode key. The length of this list is the corroboration count. | | `actor` | string | Who wrote this revision: a user id, an agent name, an extractor name. | | `created_at` | timestamp | When this revision was written. System clock. | | `observed_at` | timestamp or null | The time of the source the record came from. A post-mortem loaded a year later keeps the incident date here. | @@ -169,14 +170,14 @@ store found out. A replay that asks what memory held on a given day reads `creat | Operation | Signature, in words | Behaviour | |---|---|---| -| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision and the previous current row gets `superseded_by` set. Returns the id. If the normalized content equals the current revision's, writes nothing and returns the current id. | +| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision and the previous current row gets `superseded_by` set. Returns the id. If the normalized content equals the current revision's, writes nothing and returns the current id, unless the current revision is past its `forget_after`. | | `get` | namespace, key, optional revision | Returns the current revision, or the named one. Returns None when absent. | | `history` | namespace, key | Returns every revision, oldest first. | | `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first. No model, no embedding. The filter is equality on `kind`, `subject`, `tags`, and `actor`, and a range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. | | `search` | query text or none, namespace prefixes, optional filter, k | Returns up to k current revisions ranked by relevance, closest first, with a distance. The same filter as `list`. With no query text it is `list`. | | `delete` | namespace, key | Removes every revision of the key. Hard delete. | | `list_namespaces` | optional prefix | Returns the distinct namespaces under the prefix. | -| `writer` | none | A context manager. Every `put` inside it is buffered. The buffer flushes on exit, or at a row count, or at an interval, and returns the ids it flushed. | +| `writer` | optional row count, optional interval | A context manager. Every `put` inside it is buffered. The buffer flushes on exit, or at a row count, or at an interval, and the writer records the ids it flushed. | Deleted revisions, superseded revisions, and records past `forget_after` never appear in `list` or `search`. `history` shows superseded revisions. Nothing shows deleted ones. @@ -294,7 +295,7 @@ gets a conformance test in phase 1, and `docs/guarantees.md` names the test. | Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so a caller can decide. | answered | | When a synchronous `put` returns, is the record visible to `list`? | Yes. Read after write was never stale in 30 trials. | answered [measured] | | When a synchronous `put` returns, is the record visible to `search`? | Yes. Without an index the ranking query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 | -| When a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes, and `writer` returns the ids it flushed. | answered | +| When a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes, and the writer records the ids it flushed. | answered | | What happens when two processes write the same table? | The second load is refused with 409. The driver retries with backoff and gives up after a bound. Within one process the writer serializes. | answered [measured], M3: with two concurrent writers no load needed more than 3 of 8 attempts | | What happens when two writers put the same key? | Last writer wins at the row level. Because revisions are new rows, both revisions exist and the later one is current. | answered | | Does `delete` remove retained revisions and derived embeddings? | Yes. The embedding is a column of the row, and after a keyed delete neither a provider-backed vector index nor a BM25 index returned the row. | answered [measured], M2 | diff --git a/docs/internal/plan.md b/docs/internal/plan.md index 3668046..20975cc 100644 --- a/docs/internal/plan.md +++ b/docs/internal/plan.md @@ -98,6 +98,14 @@ Worked in order on one branch. Each task is one commit or a few. fused search, the test has to compare something looser, such as the set of records returned. Phase 2 decides which, and this phase keeps `MemoryStore` search vector-only so that either choice stays open. +- How `supersede` closes the old record. The memory contract sets the old record's + `valid_until` and `expired_at`. `Store.put` sets only `superseded_by` on the old + revision, and no `Store` operation can change another field of a written revision. Phase + 3 decides how `Memory` writes those two fields, and the change to `Store` needs a + changelog entry. +- Deduplication compares content only. A `put` that keeps the content and adds a source, + a tag, or a new `forget_after` writes nothing. The contract counts sources as + corroboration, so phase 3 decides how `remember` adds a source to an existing fact. ## Stop and ask if diff --git a/docs/local.md b/docs/local.md index 2457e91..502e15b 100644 --- a/docs/local.md +++ b/docs/local.md @@ -2,7 +2,8 @@ RuntimeDB is the Hotdata query engine. The library can use a local RuntimeDB in place of a cloud workspace, with no API key and no cloud service. This page tells you how to start it -and how to point the library at it. +and how to point the library at it. The Hotdata driver that reads the variables below +comes in phase 2. Today, the measurement scripts use the stack. ## Start the stack diff --git a/pyproject.toml b/pyproject.toml index b900a75..b3831d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,15 +2,20 @@ name = "hotmemory" version = "0.0.0" description = "Agent memory as tables on Hotdata" +readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [] -[dependency-groups] -dev = ["ruff"] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/hotmemory"] -[tool.uv] -package = false +[dependency-groups] +dev = ["mypy", "pytest", "pytest-socket", "ruff"] [tool.ruff] line-length = 100 @@ -18,3 +23,12 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src", "tests"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--strict-markers"] diff --git a/src/hotmemory/__init__.py b/src/hotmemory/__init__.py new file mode 100644 index 0000000..e30c8f1 --- /dev/null +++ b/src/hotmemory/__init__.py @@ -0,0 +1,23 @@ +"""Agent memory as tables on Hotdata.""" + +from hotmemory.filter import Filter, TimeRange +from hotmemory.memory import MemoryStore, MemoryWriter +from hotmemory.record import SCHEMA_VERSION, JSONValue, Kind, Record, normalize +from hotmemory.store import Clock, Embedder, Hit, Store, Writer + +__all__ = [ + "SCHEMA_VERSION", + "Clock", + "Embedder", + "Filter", + "Hit", + "JSONValue", + "Kind", + "MemoryStore", + "MemoryWriter", + "Record", + "Store", + "TimeRange", + "Writer", + "normalize", +] diff --git a/src/hotmemory/_rules.py b/src/hotmemory/_rules.py new file mode 100644 index 0000000..6dd67ec --- /dev/null +++ b/src/hotmemory/_rules.py @@ -0,0 +1,133 @@ +"""Plain functions that every driver calls, so the drivers agree on the contract.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from datetime import datetime + +from hotmemory.filter import Filter +from hotmemory.record import JSONValue, Kind, Record, check_label, normalize + + +def check_prefix(prefix: Sequence[str]) -> tuple[str, ...]: + """Return `prefix` as a tuple of labels. An empty prefix is allowed and matches all.""" + if isinstance(prefix, str): + raise TypeError("prefix must be a sequence of labels, not a string") + labels = tuple(prefix) + for label in labels: + check_label(label) + return labels + + +def under_prefix(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool: + """Return True if the first labels of `namespace` are the labels of `prefix`.""" + return namespace[: len(prefix)] == prefix + + +def next_revision(current: Record | None) -> int: + """Return the revision number for the next put after `current`.""" + return 1 if current is None else current.revision + 1 + + +def is_duplicate(current: Record, content: str, now: datetime) -> bool: + """Return True if a put of `content` writes nothing because `current` holds it. + + That is the case when `current` is listed at `now` and its normalized content equals + the normalized `content`. A current revision past its `forget_after` is never a + duplicate, so a put brings the fact back as a new revision. + """ + return is_listed(current, now) and normalize(current.content) == normalize(content) + + +def is_listed(record: Record, now: datetime) -> bool: + """Return True if `list` and `search` can return `record` at `now`. + + A superseded revision is not listed. A record is not listed once its `forget_after` + is at or before `now`. + """ + if record.superseded_by is not None: + return False + return record.forget_after is None or record.forget_after > now + + +def matches(record: Record, filter: Filter | None) -> bool: + """Return True if `record` satisfies every field that `filter` sets.""" + if filter is None: + return True + if filter.kind is not None and record.kind != filter.kind: + return False + if filter.subject is not None and record.subject != filter.subject: + return False + if filter.actor is not None and record.actor != filter.actor: + return False + if filter.tags is not None and not set(filter.tags) <= set(record.tags): + return False + ranges = ( + (filter.valid_from, record.valid_from), + (filter.valid_until, record.valid_until), + (filter.created_at, record.created_at), + (filter.expired_at, record.expired_at), + ) + return all(window is None or window.contains(value) for window, value in ranges) + + +def newest_first(record: Record) -> tuple[float, str]: + """Sort key for the order of `list`: newest `created_at` first, then by id.""" + return (-record.created_at.timestamp(), record.id) + + +def build_record( + namespace: Sequence[str], + key: str, + revision: int, + created_at: datetime, + *, + kind: Kind, + content: str, + subject: str, + cues: Sequence[str], + payload: dict[str, JSONValue] | None, + tags: Sequence[str], + sources: Sequence[str], + actor: str, + observed_at: datetime | None, + valid_from: datetime | None, + valid_until: datetime | None, + forget_after: datetime | None, + forget_reason: str, +) -> Record: + """Return the record that a put writes. `valid_from` defaults to `observed_at`.""" + return Record( + namespace=tuple(namespace), + key=key, + revision=revision, + kind=kind, + subject=subject, + content=content, + cues=tuple(cues), + payload={} if payload is None else payload, + tags=tuple(tags), + sources=tuple(sources), + actor=actor, + created_at=created_at, + observed_at=observed_at, + valid_from=observed_at if valid_from is None else valid_from, + valid_until=valid_until, + forget_after=forget_after, + forget_reason=forget_reason, + ) + + +def cosine_distance(a: Sequence[float], b: Sequence[float]) -> float: + """Return 1 minus the cosine similarity of `a` and `b`. + + Raises ValueError if the lengths differ or a vector has zero length. + """ + if len(a) != len(b): + raise ValueError(f"vectors differ in length: {len(a)} and {len(b)}") + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(x * x for x in b)) + if norm_a == 0 or norm_b == 0: + raise ValueError("cannot compute a cosine distance for a zero vector") + return 1.0 - sum(x * y for x, y in zip(a, b, strict=True)) / (norm_a * norm_b) diff --git a/src/hotmemory/filter.py b/src/hotmemory/filter.py new file mode 100644 index 0000000..fe8d04d --- /dev/null +++ b/src/hotmemory/filter.py @@ -0,0 +1,76 @@ +"""The filter that `list` and `search` accept.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from hotmemory.record import KINDS, Kind + + +@dataclass(frozen=True, kw_only=True) +class TimeRange: + """A half-open time range: `start` is inside it, `end` is not. + + A side that is None is open. A null timestamp on a record is never inside a range. + """ + + start: datetime | None = None + end: datetime | None = None + + def __post_init__(self) -> None: + for name in ("start", "end"): + value = getattr(self, name) + if value is None: + continue + if not isinstance(value, datetime): + raise TypeError(f"TimeRange.{name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"TimeRange.{name} must carry a time zone") + if self.start is not None and self.end is not None and self.end < self.start: + raise ValueError("TimeRange.end must not be before TimeRange.start") + + def contains(self, value: datetime | None) -> bool: + """Return True if `value` is not None and lies inside the range.""" + if value is None: + return False + if self.start is not None and value < self.start: + return False + return self.end is None or value < self.end + + +@dataclass(frozen=True, kw_only=True) +class Filter: + """Exact filters for `list` and `search`. A field that is None does not filter. + + `kind`, `subject`, and `actor` match by equality. `tags` matches a record that holds + every tag named. The four time fields match a record whose value lies in the range. + """ + + kind: Kind | None = None + subject: str | None = None + tags: tuple[str, ...] | None = None + actor: str | None = None + valid_from: TimeRange | None = None + valid_until: TimeRange | None = None + created_at: TimeRange | None = None + expired_at: TimeRange | None = None + + def __post_init__(self) -> None: + if self.kind is not None and self.kind not in KINDS: + raise ValueError(f"kind must be one of {', '.join(KINDS)}, got {self.kind!r}") + for name in ("subject", "actor"): + value = getattr(self, name) + if value is not None and not isinstance(value, str): + raise TypeError(f"Filter.{name} must be a string") + if self.tags is not None: + if isinstance(self.tags, str): + raise TypeError("Filter.tags must be a sequence of strings, not a string") + tags = tuple(self.tags) + if not all(isinstance(tag, str) for tag in tags): + raise TypeError("Filter.tags must hold only strings") + object.__setattr__(self, "tags", tags) + for name in ("valid_from", "valid_until", "created_at", "expired_at"): + value = getattr(self, name) + if value is not None and not isinstance(value, TimeRange): + raise TypeError(f"Filter.{name} must be a TimeRange") diff --git a/src/hotmemory/memory.py b/src/hotmemory/memory.py new file mode 100644 index 0000000..50c29e1 --- /dev/null +++ b/src/hotmemory/memory.py @@ -0,0 +1,296 @@ +"""`MemoryStore`: the in-process driver, and the reference for every other driver.""" + +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from types import TracebackType +from typing import Self + +from hotmemory._rules import ( + build_record, + check_prefix, + cosine_distance, + is_duplicate, + is_listed, + matches, + newest_first, + next_revision, + under_prefix, +) +from hotmemory.filter import Filter +from hotmemory.record import JSONValue, Kind, Record, check_key, check_namespace +from hotmemory.store import Clock, Embedder, Hit + + +def utc_now() -> datetime: + """Return the current time in UTC.""" + return datetime.now(UTC) + + +class MemoryStore: + """A `Store` that holds every revision in process memory. + + `embedder` turns texts into vectors for `search` with query text. Without one, such a + search raises RuntimeError. `clock` gives `created_at` and the time against which + `forget_after` is compared. Search ranks by the cosine distance between the query and + `content`. + """ + + def __init__(self, *, embedder: Embedder | None = None, clock: Clock = utc_now) -> None: + self._embedder = embedder + self._clock = clock + self._revisions: dict[tuple[tuple[str, ...], str], builtins.list[Record]] = {} + self._vectors: dict[str, tuple[float, ...]] = {} + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> str: + draft = build_record( + namespace, + key, + 1, + self._clock(), + kind=kind, + content=content, + subject=subject, + cues=cues, + payload=payload, + tags=tags, + sources=sources, + actor=actor, + observed_at=observed_at, + valid_from=valid_from, + valid_until=valid_until, + forget_after=forget_after, + forget_reason=forget_reason, + ) + return self._write(draft) + + def get(self, namespace: Sequence[str], key: str, revision: int | None = None) -> Record | None: + revisions = self._revisions.get(self._slot(namespace, key), []) + if revision is None: + found = revisions[-1] if revisions else None + else: + found = next((record for record in revisions if record.revision == revision), None) + return None if found is None else _copy(found) + + def history(self, namespace: Sequence[str], key: str) -> builtins.list[Record]: + return [_copy(record) for record in self._revisions.get(self._slot(namespace, key), [])] + + def list( + self, + prefix: Sequence[str], + filter: Filter | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> builtins.list[Record]: + _check_count("limit", limit) + records = self._listed([prefix], filter) + if since is not None: + records = [record for record in records if record.created_at >= since] + return [_copy(record) for record in records[:limit]] + + def search( + self, + query: str | None, + prefixes: Sequence[Sequence[str]], + filter: Filter | None = None, + k: int = 10, + ) -> builtins.list[Hit]: + _check_count("k", k) + records = self._listed(prefixes, filter) + if query is None: + return [Hit(_copy(record), None) for record in records[:k]] + query_vector = self._embed_query(query) + self._embed_missing(records) + hits = [ + Hit(record, cosine_distance(query_vector, self._vectors[record.id])) + for record in records + ] + hits.sort(key=lambda hit: (hit.distance, newest_first(hit.record))) + return [Hit(_copy(hit.record), hit.distance) for hit in hits[:k]] + + def delete(self, namespace: Sequence[str], key: str) -> None: + for record in self._revisions.pop(self._slot(namespace, key), []): + self._vectors.pop(record.id, None) + + def list_namespaces(self, prefix: Sequence[str] = ()) -> builtins.list[tuple[str, ...]]: + labels = check_prefix(prefix) + return sorted( + {namespace for namespace, _ in self._revisions if under_prefix(namespace, labels)} + ) + + def writer( + self, max_rows: int = 1000, interval: timedelta = timedelta(seconds=5) + ) -> MemoryWriter: + return MemoryWriter(self, max_rows, interval) + + def _write(self, draft: Record) -> str: + slot = (draft.namespace, draft.key) + revisions = self._revisions.get(slot, []) + current = revisions[-1] if revisions else None + now = self._clock() + if current is not None and is_duplicate(current, draft.content, now): + return current.id + record = replace(draft, revision=next_revision(current), created_at=now) + if current is not None: + revisions = [*revisions[:-1], replace(current, superseded_by=record.id)] + self._revisions[slot] = [*revisions, record] + return record.id + + def _slot(self, namespace: Sequence[str], key: str) -> tuple[tuple[str, ...], str]: + labels = check_namespace(namespace) + check_key(key) + return labels, key + + def _listed( + self, prefixes: Sequence[Sequence[str]], filter: Filter | None + ) -> builtins.list[Record]: + if isinstance(prefixes, str): + raise TypeError("prefixes must be a sequence of prefixes, not a string") + checked = [check_prefix(prefix) for prefix in prefixes] + now = self._clock() + records = [ + revisions[-1] + for (namespace, _), revisions in self._revisions.items() + if any(under_prefix(namespace, prefix) for prefix in checked) + and is_listed(revisions[-1], now) + and matches(revisions[-1], filter) + ] + records.sort(key=newest_first) + return records + + def _embed_query(self, query: str) -> tuple[float, ...]: + if self._embedder is None: + raise RuntimeError("search with query text needs a MemoryStore with an embedder") + return self._embed([query])[0] + + def _embed_missing(self, records: Sequence[Record]) -> None: + missing = [record for record in records if record.id not in self._vectors] + if not missing: + return + vectors = self._embed([record.content for record in missing]) + for record, vector in zip(missing, vectors, strict=True): + self._vectors[record.id] = vector + + def _embed(self, texts: Sequence[str]) -> builtins.list[tuple[float, ...]]: + assert self._embedder is not None + vectors = [tuple(float(x) for x in vector) for vector in self._embedder(texts)] + if len(vectors) != len(texts): + raise ValueError(f"embedder returned {len(vectors)} vectors for {len(texts)} texts") + return vectors + + +class MemoryWriter: + """The `Writer` that `MemoryStore.writer` returns.""" + + def __init__(self, store: MemoryStore, max_rows: int, interval: timedelta) -> None: + _check_count("max_rows", max_rows) + if max_rows < 1: + raise ValueError("max_rows must be 1 or more") + self._store = store + self._max_rows = max_rows + self._interval = interval + self._buffer: builtins.list[Record] = [] + self._flushed: builtins.list[str] = [] + self._last_flush = store._clock() + + @property + def flushed(self) -> builtins.list[str]: + return builtins.list(self._flushed) + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> None: + now = self._store._clock() + self._buffer.append( + build_record( + namespace, + key, + 1, + now, + kind=kind, + content=content, + subject=subject, + cues=cues, + payload=payload, + tags=tags, + sources=sources, + actor=actor, + observed_at=observed_at, + valid_from=valid_from, + valid_until=valid_until, + forget_after=forget_after, + forget_reason=forget_reason, + ) + ) + if len(self._buffer) >= self._max_rows or now - self._last_flush >= self._interval: + self.flush() + + def flush(self) -> builtins.list[str]: + ids = [self._store._write(draft) for draft in self._buffer] + self._buffer.clear() + self._flushed.extend(ids) + self._last_flush = self._store._clock() + return ids + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if exc_type is None: + self.flush() + else: + self._buffer.clear() + + +def _copy(record: Record) -> Record: + """Return a record equal to `record` that shares no mutable value with it.""" + return replace(record) + + +def _check_count(name: str, value: int) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if value < 0: + raise ValueError(f"{name} must not be negative") diff --git a/src/hotmemory/py.typed b/src/hotmemory/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/hotmemory/record.py b/src/hotmemory/record.py new file mode 100644 index 0000000..2b3315f --- /dev/null +++ b/src/hotmemory/record.py @@ -0,0 +1,145 @@ +"""The record of schema version 1, and the normalization that deduplication uses.""" + +from __future__ import annotations + +import copy +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, TypeAlias + +SCHEMA_VERSION = 1 + +Kind: TypeAlias = Literal["fact", "profile", "procedure", "episode"] +KINDS: tuple[Kind, ...] = ("fact", "profile", "procedure", "episode") + +JSONValue: TypeAlias = "str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]" + + +def normalize(content: str) -> str: + """Return `content` in lowercase with each run of whitespace changed to one space.""" + return " ".join(content.lower().split()) + + +def check_namespace(namespace: Sequence[str]) -> tuple[str, ...]: + """Return `namespace` as a tuple, or raise ValueError if a label is not allowed. + + A namespace has at least one label. A label is a non-empty string with no `.` and + no `/`. + """ + if isinstance(namespace, str): + raise TypeError("namespace must be a sequence of labels, not a string") + labels = tuple(namespace) + if not labels: + raise ValueError("namespace must have at least one label") + for label in labels: + check_label(label) + return labels + + +def check_label(label: str) -> None: + """Raise ValueError if `label` is empty or contains `.` or `/`.""" + if not isinstance(label, str): + raise TypeError(f"namespace label must be a string, got {type(label).__name__}") + if not label: + raise ValueError("namespace label must not be empty") + if "." in label or "/" in label: + raise ValueError(f"namespace label must not contain '.' or '/': {label!r}") + + +def check_key(key: str) -> None: + """Raise ValueError if `key` is empty or contains `/` or `@`.""" + if not isinstance(key, str): + raise TypeError(f"key must be a string, got {type(key).__name__}") + if not key: + raise ValueError("key must not be empty") + if "/" in key or "@" in key: + raise ValueError(f"key must not contain '/' or '@': {key!r}") + + +def record_id(namespace: Sequence[str], key: str, revision: int) -> str: + """Return the id `namespace/key@revision`, with the labels joined by `/`.""" + return f"{'/'.join(namespace)}/{key}@{revision}" + + +@dataclass(frozen=True, kw_only=True) +class Record: + """One revision of a memory record, schema version 1. + + The constructor refuses a value that the contract does not allow, and derives `id`. + Every timestamp must carry a time zone. `content` must not be empty after + normalization. The constructor stores a deep copy of `payload`. + """ + + namespace: tuple[str, ...] + key: str + revision: int + kind: Kind + subject: str = "" + content: str + cues: tuple[str, ...] = () + payload: dict[str, JSONValue] = field(default_factory=dict) + tags: tuple[str, ...] = () + sources: tuple[str, ...] = () + actor: str = "" + created_at: datetime + observed_at: datetime | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + expired_at: datetime | None = None + superseded_by: str | None = None + forget_after: datetime | None = None + forget_reason: str = "" + id: str = field(init=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "namespace", check_namespace(self.namespace)) + check_key(self.key) + if isinstance(self.revision, bool) or not isinstance(self.revision, int): + raise TypeError("revision must be an integer") + if self.revision < 1: + raise ValueError(f"revision must be 1 or more, got {self.revision}") + if self.kind not in KINDS: + raise ValueError(f"kind must be one of {', '.join(KINDS)}, got {self.kind!r}") + for name in ("subject", "content", "actor", "forget_reason"): + if not isinstance(getattr(self, name), str): + raise TypeError(f"{name} must be a string") + for name in ("cues", "tags", "sources"): + object.__setattr__(self, name, _strings(name, getattr(self, name))) + if not normalize(self.content): + raise ValueError("content must not be empty") + if not isinstance(self.payload, dict): + raise TypeError("payload must be a dict") + object.__setattr__(self, "payload", copy.deepcopy(self.payload)) + if self.created_at is None: + raise TypeError("created_at must be a datetime") + for name in ( + "created_at", + "observed_at", + "valid_from", + "valid_until", + "expired_at", + "forget_after", + ): + _check_time(name, getattr(self, name)) + if self.forget_reason and self.forget_after is None: + raise ValueError("forget_reason must be empty when forget_after is None") + object.__setattr__(self, "id", record_id(self.namespace, self.key, self.revision)) + + +def _strings(name: str, values: Sequence[str]) -> tuple[str, ...]: + if isinstance(values, str): + raise TypeError(f"{name} must be a sequence of strings, not a string") + result = tuple(values) + if not all(isinstance(value, str) for value in result): + raise TypeError(f"{name} must hold only strings") + return result + + +def _check_time(name: str, value: datetime | None) -> None: + if value is None: + return + if not isinstance(value, datetime): + raise TypeError(f"{name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{name} must carry a time zone") diff --git a/src/hotmemory/store.py b/src/hotmemory/store.py new file mode 100644 index 0000000..a17b3b9 --- /dev/null +++ b/src/hotmemory/store.py @@ -0,0 +1,160 @@ +"""The storage contract: the `Store` protocol that every driver implements.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import datetime, timedelta +from types import TracebackType +from typing import Protocol, Self, TypeAlias + +from hotmemory.filter import Filter +from hotmemory.record import JSONValue, Kind, Record + +Embedder: TypeAlias = Callable[[Sequence[str]], Sequence[Sequence[float]]] +"""Takes a list of texts and returns one vector for each text, in the same order.""" + +Clock: TypeAlias = Callable[[], datetime] +"""Returns the current time, with a time zone.""" + + +@dataclass(frozen=True) +class Hit: + """One result of `search`: a record and its cosine distance to the query. + + A lower distance is closer. The distance is None when the search has no query text. + """ + + record: Record + distance: float | None + + +class Writer(Protocol): + """A buffer of puts that a store writes in one batch. + + A buffered put is not visible until the writer flushes. The writer flushes when the + `with` block exits without an exception, when the buffer reaches its row count, and + on the first put after its interval passes. If the block raises, the writer drops the + buffer. + """ + + @property + def flushed(self) -> builtins.list[str]: + """The ids of every put that this writer flushed, in order.""" + ... + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> None: + """Buffer one put. The arguments are those of `Store.put`.""" + ... + + def flush(self) -> builtins.list[str]: + """Write the buffer and return the ids that it wrote, in order.""" + ... + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + +class Store(Protocol): + """The storage contract. Each method is described in docs/contracts.md.""" + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> str: + """Write a new revision and return its id. + + If the key has a current revision, the new revision gets the next number and the + current one gets `superseded_by`. If the normalized content equals that of the + current revision, nothing is written and the current id is returned. If + `valid_from` is None, it takes the value of `observed_at`. + """ + ... + + def get(self, namespace: Sequence[str], key: str, revision: int | None = None) -> Record | None: + """Return the current revision, or the named one, or None if it does not exist.""" + ... + + def history(self, namespace: Sequence[str], key: str) -> builtins.list[Record]: + """Return every revision of the key, oldest first.""" + ... + + def list( + self, + prefix: Sequence[str], + filter: Filter | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> builtins.list[Record]: + """Return up to `limit` current revisions under `prefix`, newest first. + + `since` keeps the revisions whose `created_at` is at or after it. Ties in + `created_at` are ordered by id. + """ + ... + + def search( + self, + query: str | None, + prefixes: Sequence[Sequence[str]], + filter: Filter | None = None, + k: int = 10, + ) -> builtins.list[Hit]: + """Return up to `k` current revisions under any of `prefixes`, closest first. + + With no query text, the order and the records are those of `list`, and each + distance is None. + """ + ... + + def delete(self, namespace: Sequence[str], key: str) -> None: + """Remove every revision of the key.""" + ... + + def list_namespaces(self, prefix: Sequence[str] = ()) -> builtins.list[tuple[str, ...]]: + """Return the distinct namespaces under `prefix` that hold a record, sorted.""" + ... + + def writer(self, max_rows: int = 1000, interval: timedelta = timedelta(seconds=5)) -> Writer: + """Return a writer that buffers puts. See `Writer`.""" + ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..02cf9d3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +import re +import zlib +from collections.abc import Callable, Sequence +from datetime import UTC, datetime, timedelta + +import pytest + +from hotmemory import Clock, Embedder, MemoryStore, Store + +START = datetime(2026, 10, 5, 12, 0, tzinfo=UTC) +DIMENSIONS = 64 + + +class FakeClock: + """A clock that stands still until a test moves it.""" + + def __init__(self, now: datetime = START) -> None: + self.now = now + + def __call__(self) -> datetime: + return self.now + + def advance(self, delta: timedelta = timedelta(seconds=1)) -> None: + self.now += delta + + +def fake_embedder(texts: Sequence[str]) -> list[list[float]]: + """Embed each text as counts of its words, hashed into a fixed number of dimensions.""" + vectors = [] + for text in texts: + vector = [0.0] * DIMENSIONS + for word in re.findall(r"[a-z0-9]+", text.lower()): + vector[zlib.crc32(word.encode()) % DIMENSIONS] += 1.0 + vectors.append(vector) + return vectors + + +StoreFactory = Callable[[Embedder | None, Clock], Store] + +DRIVERS: dict[str, StoreFactory] = { + "memory": lambda embedder, clock: MemoryStore(embedder=embedder, clock=clock), +} + + +@pytest.fixture +def clock() -> FakeClock: + return FakeClock() + + +@pytest.fixture(params=sorted(DRIVERS)) +def store(request: pytest.FixtureRequest, clock: FakeClock) -> Store: + factory: StoreFactory = DRIVERS[request.param] + return factory(fake_embedder, clock) diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..fbac767 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,238 @@ +"""The conformance suite. Each test proves one row of docs/guarantees.md for every driver.""" + +from datetime import timedelta +from typing import Any + +import pytest + +from hotmemory import Filter, JSONValue, Store, TimeRange + +from .conftest import FakeClock + +NS = ("team", "alerts") + + +def test_second_put_writes_a_new_revision(store: Store, clock: FakeClock) -> None: + first = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + clock.advance() + second = store.put(NS, "disk", kind="fact", content="The disk fills at noon.") + + assert (first, second) == ("team/alerts/disk@1", "team/alerts/disk@2") + current = store.get(NS, "disk") + assert current is not None + assert current.revision == 2 + previous = store.get(NS, "disk", revision=1) + assert previous is not None + assert previous.superseded_by == second + assert [record.id for record in store.history(NS, "disk")] == [first, second] + assert [record.id for record in store.list(NS)] == [second] + assert [hit.record.id for hit in store.search("disk fills", [NS])] == [second] + + +def test_deduplication_is_exact_on_normalized_content(store: Store, clock: FakeClock) -> None: + first = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + clock.advance() + again = store.put(NS, "disk", kind="fact", content=" the DISK fills\n at night. ") + clock.advance() + paraphrase = store.put(NS, "disk", kind="fact", content="At night the disk fills up.") + + assert again == first + assert paraphrase == "team/alerts/disk@2" + assert len(store.history(NS, "disk")) == 2 + + +def test_synchronous_put_is_visible_to_list(store: Store) -> None: + record_id = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + assert [record.id for record in store.list(NS)] == [record_id] + + +def test_synchronous_put_is_visible_to_search(store: Store) -> None: + record_id = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + assert [hit.record.id for hit in store.search("disk fills at night", [NS])] == [record_id] + + +def test_buffered_put_is_visible_after_flush(store: Store) -> None: + with store.writer() as writer: + writer.put(NS, "disk", kind="fact", content="The disk fills at night.") + writer.put(NS, "cpu", kind="fact", content="The CPU spikes at noon.") + assert store.get(NS, "disk") is None + assert store.list(NS) == [] + assert store.search("disk", [NS]) == [] + + assert writer.flushed == ["team/alerts/disk@1", "team/alerts/cpu@1"] + assert {record.id for record in store.list(NS)} == set(writer.flushed) + + +def test_last_writer_wins_on_one_key(store: Store, clock: FakeClock) -> None: + first = store.writer() + second = store.writer() + first.put(NS, "disk", kind="fact", content="The disk fills at night.", actor="one") + second.put(NS, "disk", kind="fact", content="The disk fills at noon.", actor="two") + first.flush() + clock.advance() + second.flush() + + assert [record.actor for record in store.history(NS, "disk")] == ["one", "two"] + current = store.get(NS, "disk") + assert current is not None + assert current.actor == "two" + + +def test_delete_removes_every_revision(store: Store, clock: FakeClock) -> None: + store.put(NS, "disk", kind="fact", content="The disk fills at night.") + clock.advance() + store.put(NS, "disk", kind="fact", content="The disk fills at noon.") + store.search("disk fills", [NS]) + store.delete(NS, "disk") + + assert store.get(NS, "disk") is None + assert store.get(NS, "disk", revision=1) is None + assert store.history(NS, "disk") == [] + assert store.list(NS) == [] + assert store.search("disk fills at noon", [NS]) == [] + assert store.list_namespaces() == [] + + +FILTER_CASES: list[tuple[str, dict[str, Any], Filter]] = [ + ("kind", {"kind": "procedure"}, Filter(kind="procedure")), + ("subject", {"subject": "db-1"}, Filter(subject="db-1")), + ("actor", {"actor": "extractor"}, Filter(actor="extractor")), + ("tags", {"tags": ("disk", "night")}, Filter(tags=("night", "disk"))), +] + + +@pytest.mark.parametrize( + ("name", "fields", "where"), FILTER_CASES, ids=[c[0] for c in FILTER_CASES] +) +def test_filter_matches_by_equality( + store: Store, name: str, fields: dict[str, Any], where: Filter +) -> None: + store.put(NS, "skipped", kind="fact", content="This one does not match.", tags=("disk",)) + store.put(NS, "wanted", content="This one matches.", **{"kind": "fact", **fields}) + wanted = store.get(NS, "wanted") + assert wanted is not None + + assert [record.id for record in store.list(NS, where)] == [wanted.id] + assert [hit.record.id for hit in store.search("one", [NS], where)] == [wanted.id] + + +def test_filter_matches_a_time_range(store: Store, clock: FakeClock) -> None: + start = clock.now + store.put(NS, "unknown", kind="fact", content="Validity unknown.") + store.put(NS, "early", kind="fact", content="Valid early.", valid_from=start) + store.put(NS, "late", kind="fact", content="Valid late.", valid_from=start + timedelta(days=2)) + clock.advance(timedelta(days=1)) + store.put(NS, "newer", kind="fact", content="Written later.") + + early = Filter(valid_from=TimeRange(start=start, end=start + timedelta(days=1))) + assert [record.key for record in store.list(NS, early)] == ["early"] + written = Filter(created_at=TimeRange(start=start + timedelta(hours=1))) + assert [record.key for record in store.list(NS, written)] == ["newer"] + assert [record.key for record in store.list(NS, since=clock.now)] == ["newer"] + + +def test_unknown_filter_key_raises() -> None: + fields: dict[str, Any] = {"content": "disk"} + with pytest.raises(TypeError): + Filter(**fields) + + +def test_prefix_matches_whole_labels(store: Store) -> None: + store.put(("team", "alerts"), "a", kind="fact", content="Inside the prefix.") + store.put(("team", "alerts", "disk"), "b", kind="fact", content="Below the prefix.") + store.put(("team", "alertsx"), "c", kind="fact", content="Shares a string prefix.") + + assert {record.key for record in store.list(("team", "alerts"))} == {"a", "b"} + found = store.search("prefix", [("team", "alerts")]) + assert {hit.record.key for hit in found} == {"a", "b"} + both = store.search(None, [("team", "alerts", "disk"), ("team", "alertsx")]) + assert {hit.record.key for hit in both} == {"b", "c"} + + +def test_search_returns_distance_closest_first(store: Store, clock: FakeClock) -> None: + store.put(NS, "exact", kind="fact", content="disk fills at night") + clock.advance() + store.put(NS, "near", kind="fact", content="disk fills at noon") + clock.advance() + store.put(NS, "far", kind="fact", content="cpu spikes after deploy") + + hits = store.search("disk fills at night", [NS], k=3) + assert [hit.record.key for hit in hits] == ["exact", "near", "far"] + distances = [hit.distance for hit in hits] + assert distances[0] == pytest.approx(0.0) + assert all(distance is not None for distance in distances) + assert distances == sorted(distances, key=lambda distance: distance or 0.0) + assert len(store.search("disk fills at night", [NS], k=2)) == 2 + + +def test_forget_after_hides_without_deleting(store: Store, clock: FakeClock) -> None: + store.put( + NS, + "disk", + kind="fact", + content="The disk fills at night.", + forget_after=clock.now + timedelta(days=1), + forget_reason="temporary", + ) + assert len(store.list(NS)) == 1 + clock.advance(timedelta(days=1)) + + assert store.list(NS) == [] + assert store.search("disk", [NS]) == [] + assert store.get(NS, "disk") is not None + assert len(store.history(NS, "disk")) == 1 + + +def test_put_after_forget_after_writes_a_new_revision(store: Store, clock: FakeClock) -> None: + store.put( + NS, + "disk", + kind="fact", + content="The disk fills at night.", + forget_after=clock.now + timedelta(days=1), + forget_reason="temporary", + ) + clock.advance(timedelta(days=1)) + record_id = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + + assert record_id == "team/alerts/disk@2" + assert [record.id for record in store.list(NS)] == [record_id] + assert [hit.record.id for hit in store.search("disk fills at night", [NS])] == [record_id] + + +def test_stored_record_cannot_change(store: Store) -> None: + payload: dict[str, JSONValue] = {"hosts": ["db-1"], "limits": {"disk": 90}} + store.put(NS, "disk", kind="fact", content="The disk fills at night.", payload=payload) + expected = {"hosts": ["db-1"], "limits": {"disk": 90}} + + payload["hosts"] = ["changed"] + returned = [ + store.get(NS, "disk"), + store.get(NS, "disk", revision=1), + *store.history(NS, "disk"), + *store.list(NS), + *(hit.record for hit in store.search(None, [NS])), + *(hit.record for hit in store.search("disk", [NS])), + ] + for record in returned: + assert record is not None + assert record.payload == expected + hosts = record.payload["hosts"] + assert isinstance(hosts, list) + hosts.append("db-2") + record.payload["new"] = True + + stored = store.get(NS, "disk") + assert stored is not None + assert stored.payload == expected + + +@pytest.mark.parametrize("content", ["", " \n\t "]) +def test_put_refuses_empty_content(store: Store, content: str) -> None: + with pytest.raises(ValueError, match="content"): + store.put(NS, "disk", kind="fact", content=content) + with pytest.raises(ValueError, match="content"), store.writer() as writer: + writer.put(NS, "disk", kind="fact", content=content) + + assert store.get(NS, "disk") is None + assert store.list_namespaces() == [] diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..29a6953 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,61 @@ +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from hotmemory import Filter, TimeRange + +NOW = datetime(2026, 10, 5, tzinfo=UTC) + + +def test_time_range_is_half_open() -> None: + window = TimeRange(start=NOW, end=NOW + timedelta(days=1)) + assert window.contains(NOW) + assert not window.contains(NOW + timedelta(days=1)) + assert not window.contains(NOW - timedelta(seconds=1)) + + +def test_time_range_never_contains_null() -> None: + assert not TimeRange().contains(None) + + +def test_open_time_range_contains_any_time() -> None: + assert TimeRange().contains(NOW) + + +def test_time_range_refuses_end_before_start() -> None: + with pytest.raises(ValueError, match="before"): + TimeRange(start=NOW, end=NOW - timedelta(days=1)) + + +def test_time_range_refuses_a_naive_time() -> None: + with pytest.raises(ValueError, match="time zone"): + TimeRange(start=datetime(2026, 10, 5)) + + +def test_filter_refuses_an_unknown_key() -> None: + fields: dict[str, Any] = {"payload": {}} + with pytest.raises(TypeError): + Filter(**fields) + + +@pytest.mark.parametrize( + ("fields", "error"), + [ + ({"kind": "note"}, ValueError), + ({"subject": 1}, TypeError), + ({"actor": 1}, TypeError), + ({"tags": "disk"}, TypeError), + ({"tags": [1]}, TypeError), + ({"created_at": NOW}, TypeError), + ], +) +def test_filter_refuses_an_unsupported_value( + fields: dict[str, Any], error: type[Exception] +) -> None: + with pytest.raises(error): + Filter(**fields) + + +def test_filter_tags_become_a_tuple() -> None: + assert Filter(tags=["disk"]).tags == ("disk",) # type: ignore[arg-type] diff --git a/tests/test_frozen.py b/tests/test_frozen.py new file mode 100644 index 0000000..ee5327c --- /dev/null +++ b/tests/test_frozen.py @@ -0,0 +1,90 @@ +"""The frozen surfaces. Each test compares a public surface against a literal set.""" + +from dataclasses import fields +from typing import get_args + +import hotmemory +from hotmemory import Filter, Kind, Record, Store + + +def changed(surface: str) -> str: + return ( + f"Frozen surface changed: {surface}. This is a public contract change. " + "Add an entry to CHANGELOG.md under Unreleased, then update the literal in this test." + ) + + +def test_all_is_frozen() -> None: + assert set(hotmemory.__all__) == { + "SCHEMA_VERSION", + "Clock", + "Embedder", + "Filter", + "Hit", + "JSONValue", + "Kind", + "MemoryStore", + "MemoryWriter", + "Record", + "Store", + "TimeRange", + "Writer", + "normalize", + }, changed("hotmemory.__all__") + + +def test_store_methods_are_frozen() -> None: + methods = {name for name, value in vars(Store).items() if callable(value)} + public = {name for name in methods if not name.startswith("_")} + assert public == { + "put", + "get", + "history", + "list", + "search", + "delete", + "list_namespaces", + "writer", + }, changed("the method set of Store") + + +def test_record_fields_are_frozen() -> None: + assert hotmemory.SCHEMA_VERSION == 1, changed("the schema version") + assert {field.name: field.type for field in fields(Record)} == { + "namespace": "tuple[str, ...]", + "key": "str", + "revision": "int", + "kind": "Kind", + "subject": "str", + "content": "str", + "cues": "tuple[str, ...]", + "payload": "dict[str, JSONValue]", + "tags": "tuple[str, ...]", + "sources": "tuple[str, ...]", + "actor": "str", + "created_at": "datetime", + "observed_at": "datetime | None", + "valid_from": "datetime | None", + "valid_until": "datetime | None", + "expired_at": "datetime | None", + "superseded_by": "str | None", + "forget_after": "datetime | None", + "forget_reason": "str", + "id": "str", + }, changed("the fields and field types of Record, schema version 1") + assert set(get_args(Kind)) == {"fact", "profile", "procedure", "episode"}, changed( + "the values of Kind" + ) + + +def test_filter_keys_are_frozen() -> None: + assert {field.name for field in fields(Filter)} == { + "kind", + "subject", + "tags", + "actor", + "valid_from", + "valid_until", + "created_at", + "expired_at", + }, changed("the filter keys of list and search") diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..d0cf983 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,62 @@ +"""The ledger test. Every row of docs/guarantees.md names a test that exists, or a phase.""" + +import ast +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LEDGER = ROOT / "docs" / "guarantees.md" +SUITE = ROOT / "tests" / "test_conformance.py" +HEADER = "| Question | Answer | State | Test |" +TEST_NAME = re.compile(r"`(test_[A-Za-z0-9_]+)`") +PHASE = re.compile(r"\bphase \d+\b") + + +def ledger_rows() -> list[tuple[str, str]]: + """Return the question and the Test cell of each row of the ledger table.""" + lines = LEDGER.read_text(encoding="utf-8").splitlines() + start = lines.index(HEADER) + 2 + rows = [] + for line in lines[start:]: + if not line.startswith("|"): + break + cells = [cell.strip() for cell in line.strip("|").split("|")] + rows.append((cells[0], cells[-1])) + return rows + + +def defined_tests() -> set[str]: + """Return the name of every test function in the conformance suite.""" + tree = ast.parse(SUITE.read_text(encoding="utf-8")) + return { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + } + + +def test_ledger_has_rows() -> None: + assert len(ledger_rows()) >= 16 + + +def test_every_named_test_exists() -> None: + defined = defined_tests() + missing = [ + f"{question} -> {name}" + for question, cell in ledger_rows() + for name in TEST_NAME.findall(cell) + if name not in defined + ] + assert not missing, ( + "docs/guarantees.md names tests that tests/test_conformance.py does not define:\n" + + "\n".join(missing) + ) + + +def test_every_row_names_a_test_or_a_phase() -> None: + empty = [ + question + for question, cell in ledger_rows() + if not TEST_NAME.search(cell) and not PHASE.search(cell) + ] + assert not empty, "these ledger rows name no test and no phase:\n" + "\n".join(empty) diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py new file mode 100644 index 0000000..8a4c845 --- /dev/null +++ b/tests/test_memory_store.py @@ -0,0 +1,111 @@ +from datetime import timedelta + +import pytest + +from hotmemory import MemoryStore +from hotmemory._rules import cosine_distance + +from .conftest import FakeClock, fake_embedder + +NS = ("team",) + + +def test_search_with_query_text_needs_an_embedder() -> None: + store = MemoryStore() + store.put(NS, "a", kind="fact", content="disk full") + with pytest.raises(RuntimeError, match="embedder"): + store.search("disk", [NS]) + + +def test_search_without_query_text_needs_no_embedder() -> None: + store = MemoryStore() + store.put(NS, "a", kind="fact", content="disk full") + assert [hit.distance for hit in store.search(None, [NS])] == [None] + + +def test_embedder_that_drops_a_vector_raises() -> None: + store = MemoryStore(embedder=lambda texts: []) + store.put(NS, "a", kind="fact", content="disk full") + with pytest.raises(ValueError, match="vectors"): + store.search("disk", [NS]) + + +def test_created_at_comes_from_the_clock(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + store.put(NS, "a", kind="fact", content="disk full") + record = store.get(NS, "a") + assert record is not None + assert record.created_at == clock.now + + +def test_valid_from_defaults_to_observed_at(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + observed = clock.now - timedelta(days=365) + store.put(NS, "a", kind="fact", content="disk full", observed_at=observed) + record = store.get(NS, "a") + assert record is not None + assert record.valid_from == observed + + +def test_writer_flushes_at_its_row_count(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + writer = store.writer(max_rows=2) + writer.put(NS, "a", kind="fact", content="one") + assert store.get(NS, "a") is None + writer.put(NS, "b", kind="fact", content="two") + assert writer.flushed == ["team/a@1", "team/b@1"] + + +def test_writer_flushes_on_the_first_put_after_its_interval(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + writer = store.writer(interval=timedelta(seconds=5)) + writer.put(NS, "a", kind="fact", content="one") + clock.advance(timedelta(seconds=5)) + writer.put(NS, "b", kind="fact", content="two") + assert writer.flushed == ["team/a@1", "team/b@1"] + + +def test_writer_drops_its_buffer_when_the_block_raises(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + with pytest.raises(KeyError), store.writer() as writer: + writer.put(NS, "a", kind="fact", content="one") + raise KeyError("stop") + assert store.get(NS, "a") is None + assert writer.flushed == [] + + +def test_writer_checks_a_put_before_it_buffers() -> None: + writer = MemoryStore().writer() + with pytest.raises(ValueError, match="label"): + writer.put(("a.b",), "k", kind="fact", content="one") + + +def test_list_namespaces_matches_whole_labels() -> None: + store = MemoryStore() + store.put(("team", "alerts"), "a", kind="fact", content="one") + store.put(("team", "alertsx"), "b", kind="fact", content="two") + store.put(("other",), "c", kind="fact", content="three") + assert store.list_namespaces(("team", "alerts")) == [("team", "alerts")] + assert store.list_namespaces() == [("other",), ("team", "alerts"), ("team", "alertsx")] + + +def test_reput_after_delete_does_not_reuse_a_stale_embedding() -> None: + store = MemoryStore(embedder=fake_embedder) + store.put(NS, "a", kind="fact", content="disk full") + store.search("disk full", [NS]) + store.delete(NS, "a") + store.put(NS, "a", kind="fact", content="network down") + [hit] = store.search("network down", [NS]) + assert hit.distance == pytest.approx(0.0) + + +def test_cosine_distance() -> None: + assert cosine_distance([1.0, 0.0], [1.0, 0.0]) == pytest.approx(0.0) + assert cosine_distance([1.0, 0.0], [0.0, 1.0]) == pytest.approx(1.0) + assert cosine_distance([1.0, 0.0], [-1.0, 0.0]) == pytest.approx(2.0) + + +@pytest.mark.parametrize(("a", "b"), [([1.0], [1.0, 0.0]), ([0.0], [1.0])]) +def test_cosine_distance_refuses_bad_vectors(a: list[float], b: list[float]) -> None: + with pytest.raises(ValueError): + cosine_distance(a, b) diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..5c008ca --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,7 @@ +from pathlib import Path + +import hotmemory + + +def test_package_ships_type_information() -> None: + assert (Path(hotmemory.__file__).parent / "py.typed").is_file() diff --git a/tests/test_record.py b/tests/test_record.py new file mode 100644 index 0000000..12474da --- /dev/null +++ b/tests/test_record.py @@ -0,0 +1,112 @@ +from datetime import UTC, datetime +from typing import Any + +import pytest + +from hotmemory import Record, normalize + +NOW = datetime(2026, 10, 5, tzinfo=UTC) + + +def make(**overrides: Any) -> Record: + fields: dict[str, Any] = { + "namespace": ("team", "alerts"), + "key": "disk-full", + "revision": 1, + "kind": "fact", + "content": "The disk fills at night.", + "created_at": NOW, + } + fields.update(overrides) + return Record(**fields) + + +def test_id_is_namespace_key_and_revision() -> None: + assert make(revision=3).id == "team/alerts/disk-full@3" + + +def test_defaults_are_empty() -> None: + record = make() + assert record.subject == "" + assert record.cues == () + assert record.payload == {} + assert record.superseded_by is None + + +def test_sequences_become_tuples() -> None: + record = make(namespace=["team"], tags=["disk"], cues=["why"], sources=["thread-1"]) + assert record.namespace == ("team",) + assert record.tags == ("disk",) + assert record.cues == ("why",) + assert record.sources == ("thread-1",) + + +@pytest.mark.parametrize("label", ["a.b", "a/b", ""]) +def test_refuses_a_bad_namespace_label(label: str) -> None: + with pytest.raises(ValueError, match="label"): + make(namespace=("team", label)) + + +def test_refuses_an_empty_namespace() -> None: + with pytest.raises(ValueError, match="at least one label"): + make(namespace=()) + + +def test_refuses_a_string_namespace() -> None: + with pytest.raises(TypeError, match="not a string"): + make(namespace="team") + + +@pytest.mark.parametrize("key", ["a/b", "a@b", ""]) +def test_refuses_a_bad_key(key: str) -> None: + with pytest.raises(ValueError, match="key"): + make(key=key) + + +@pytest.mark.parametrize("revision", [0, -1]) +def test_refuses_a_revision_below_one(revision: int) -> None: + with pytest.raises(ValueError, match="revision"): + make(revision=revision) + + +def test_refuses_an_unknown_kind() -> None: + with pytest.raises(ValueError, match="kind"): + make(kind="note") + + +def test_refuses_a_string_for_tags() -> None: + with pytest.raises(TypeError, match="tags"): + make(tags="disk") + + +def test_refuses_a_naive_timestamp() -> None: + with pytest.raises(ValueError, match="time zone"): + make(observed_at=datetime(2026, 10, 5)) + + +def test_refuses_a_forget_reason_without_forget_after() -> None: + with pytest.raises(ValueError, match="forget_reason"): + make(forget_reason="stale") + + +def test_record_is_frozen() -> None: + record = make() + with pytest.raises(AttributeError): + record.content = "changed" # type: ignore[misc] + + +def test_normalize_lowercases_and_collapses_whitespace() -> None: + assert normalize(" The Disk\n\tFILLS at night. ") == "the disk fills at night." + + +@pytest.mark.parametrize("content", ["", " \n\t"]) +def test_refuses_empty_content(content: str) -> None: + with pytest.raises(ValueError, match="content"): + make(content=content) + + +def test_payload_is_copied_on_the_way_in() -> None: + payload: dict[str, Any] = {"hosts": ["db-1"]} + record = make(payload=payload) + payload["hosts"].append("db-2") + assert record.payload == {"hosts": ["db-1"]} diff --git a/uv.lock b/uv.lock index 2f1c99b..aee8bed 100644 --- a/uv.lock +++ b/uv.lock @@ -1,21 +1,364 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] [[package]] name = "hotmemory" version = "0.0.0" -source = { virtual = "." } +source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-socket" }, { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "ruff" }] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-socket" }, + { name = "ruff" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-socket" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ce/4ef7b049852c95a8727b4a7e6496f762df1ac0b47bc0320d10293f5e95ec/pytest_socket-0.8.1.tar.gz", hash = "sha256:2f57787914ad2e1308d09ce141b95c3e55741fbb4fb7b7556593a6b063e0c9c7", size = 17313, upload-time = "2026-08-19T15:16:25.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ef/ab507f117b3d19b54e3c9c632a99c28c3b284562ec6e02e274581d530d92/pytest_socket-0.8.1-py3-none-any.whl", hash = "sha256:f9846bed1dcd96eed459e5e14795bbaf96715cf4e827891fe70773817ecb8ed4", size = 8751, upload-time = "2026-08-19T15:16:24.426Z" }, +] [[package]] name = "ruff" @@ -41,3 +384,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]