From 94770ea8515864c2197bedb2dfa74e0c96646568 Mon Sep 17 00:00:00 2001 From: willpugh Date: Thu, 16 Jul 2026 23:45:09 -0700 Subject: [PATCH 1/5] Adds a SqlGlot implementation of the OSI SQL dialect --- core-spec/expression_language.md | 95 ++--- mypy.ini | 23 ++ python/ossie-sql/.pre-commit-config.yaml | 21 ++ python/ossie-sql/Makefile | 13 + python/ossie-sql/README.md | 57 +++ python/ossie-sql/pyproject.toml | 31 ++ python/ossie-sql/src/ossie_sql/__init__.py | 27 ++ python/ossie-sql/src/ossie_sql/dialect.py | 188 ++++++++++ python/ossie-sql/src/ossie_sql/functions.py | 237 ++++++++++++ python/ossie-sql/src/ossie_sql/identifiers.py | 86 +++++ python/ossie-sql/src/ossie_sql/py.typed | 0 python/ossie-sql/src/ossie_sql/validate.py | 74 ++++ python/ossie-sql/src/ossie_sql/windows.py | 47 +++ python/ossie-sql/tests/conftest.py | 28 ++ .../ossie-sql/tests/test_dialect_roundtrip.py | 127 +++++++ python/ossie-sql/tests/test_functions.py | 97 +++++ python/ossie-sql/tests/test_identifiers.py | 85 +++++ python/ossie-sql/tests/test_typed_literals.py | 51 +++ python/ossie-sql/tests/test_validate.py | 67 ++++ python/ossie/Makefile | 10 + python/ossie/pyproject.toml | 51 +++ python/{ => ossie}/src/ossie/__init__.py | 0 python/{ => ossie}/src/ossie/models.py | 64 ++-- python/pyproject.toml | 35 +- python/uv.lock | 343 +++++++++++++++++- ruff.toml | 20 + 26 files changed, 1767 insertions(+), 110 deletions(-) create mode 100644 mypy.ini create mode 100644 python/ossie-sql/.pre-commit-config.yaml create mode 100644 python/ossie-sql/Makefile create mode 100644 python/ossie-sql/README.md create mode 100644 python/ossie-sql/pyproject.toml create mode 100644 python/ossie-sql/src/ossie_sql/__init__.py create mode 100644 python/ossie-sql/src/ossie_sql/dialect.py create mode 100644 python/ossie-sql/src/ossie_sql/functions.py create mode 100644 python/ossie-sql/src/ossie_sql/identifiers.py create mode 100644 python/ossie-sql/src/ossie_sql/py.typed create mode 100644 python/ossie-sql/src/ossie_sql/validate.py create mode 100644 python/ossie-sql/src/ossie_sql/windows.py create mode 100644 python/ossie-sql/tests/conftest.py create mode 100644 python/ossie-sql/tests/test_dialect_roundtrip.py create mode 100644 python/ossie-sql/tests/test_functions.py create mode 100644 python/ossie-sql/tests/test_identifiers.py create mode 100644 python/ossie-sql/tests/test_typed_literals.py create mode 100644 python/ossie-sql/tests/test_validate.py create mode 100644 python/ossie/Makefile create mode 100644 python/ossie/pyproject.toml rename python/{ => ossie}/src/ossie/__init__.py (100%) rename python/{ => ossie}/src/ossie/models.py (73%) create mode 100644 ruff.toml diff --git a/core-spec/expression_language.md b/core-spec/expression_language.md index 42299977..60e205c6 100644 --- a/core-spec/expression_language.md +++ b/core-spec/expression_language.md @@ -19,7 +19,7 @@ # Ossie Proposal: Expression Language -**Current Status:** Proposed Final +**Current Status:** Proposed Final **Working Group** @@ -33,7 +33,7 @@ There are two layers in Ossie that need an expression language: -* **Ontology layer.** This layer maps onto the ontology layer which sits above the logical layer. It maps more closely to modelling languages like OWL, [(Py)Rel](https://docs.relational.ai) from RelationalAI, and [Legend](https://legend.finos.org) from Goldman Sachs +* **Ontology layer.** This layer maps onto the ontology layer which sits above the logical layer. It maps more closely to modelling languages like OWL, [(Py)Rel](https://docs.relational.ai) from RelationalAI, and [Legend](https://legend.finos.org) from Goldman Sachs * **Logical layer.** This layer maps directly to the databases and physical layer. It maps closely to traditional BI semantic models. This proposal is only targeted at the Logical Layer. It would be nice if the Ontological layer could re-use the same expression language, but that will be treated as a separate proposal. @@ -44,22 +44,22 @@ We expect there will be extensions to this language to cover concepts such as su ### Design Principles -1. **Portability**: Core functions work identically across all implementations -2. **Familiarity**: Based on widely-adopted SQL syntax and semantics -3. **Analytical Focus**: Prioritizes functions commonly used in BI and analytics +1. **Portability**: Core functions work identically across all implementations +2. **Familiarity**: Based on widely-adopted SQL syntax and semantics +3. **Analytical Focus**: Prioritizes functions commonly used in BI and analytics 4. **Extensibility**: Vendor dialects can extend beyond the core ### Changes to YAML -1) Create a new dialect in the Ossie spec: Ossie\_SQL\_2026, which refers to this language specification. +1) Create a new dialect in the Ossie spec: Ossie\_SQL\_2026, which refers to this language specification. 2) Make Ossie\_SQL\_2026 the default dialect if one is not chosen. ### Standards Reference The core language is based on **ANSI SQL:2003 Core** (ISO/IEC 9075-2:2003), selected for its: -- Wide adoption across major databases (Snowflake, Databricks, PostgreSQL, BigQuery) -- Well-defined semantics +- Wide adoption across major databases (Snowflake, Databricks, PostgreSQL, BigQuery) +- Well-defined semantics - Support for modern analytical features (window functions, CTEs) ### Namespacing and Identifier Resolution @@ -76,7 +76,7 @@ All identifiers MUST be valid names and follow ANSI SQL naming, with the size li Regular identifiers (unquoted) should be case insensitive. For example, an identifier id is regular, so it would match with Id or iD. Comparing quoted and non-quoted identifiers is DB specific, so for best portability it is best to use simple identifiers. -The quote character for the Ossie dialect will follow ANSI SQL and support the double quote character (“). This means that if an expression is in a field expression or as an identifier in the YAML, this will be the expected quoting. However, there are some databases that use other escape characters. Working with these have the option of either creating expressions using their dialect or having the Ossie document written in the Ossie dialect, but then having the SQL Interface queried in the local dialect. The SQL Interface will be defined in a different document. +The quote character for the Ossie dialect will follow ANSI SQL and support the double quote character (“). This means that if an expression is in a field expression or as an identifier in the YAML, this will be the expected quoting. However, there are some databases that use other escape characters. Working with these have the option of either creating expressions using their dialect or having the Ossie document written in the Ossie dialect, but then having the SQL Interface queried in the local dialect. The SQL Interface will be defined in a different document. #### Comparison Table @@ -89,7 +89,7 @@ The quote character for the Ossie dialect will follow ANSI SQL and support the d Sometimes, we may refer to a **normalized identifier**. This is a form the identifiers can be put in, so they can be matched easily and matches can be made with case-sensitive, exact matching. For **normalized identifiers**: -* Regular identifiers are upper cased +* Regular identifiers are upper cased * Quoted identifiers have their quotes stripped and any escaped characters are unescaped #### Name Spaces @@ -119,7 +119,7 @@ Ossie expressions support the following SQL constructs within any expression: | Scalar functions | See function categories below | | Parentheses | Expression grouping | -### +### ### Not Supported in Expressions @@ -138,13 +138,14 @@ Ossie expressions support the following SQL constructs within any expression: Standard SQL operator precedence applies (highest to lowest): -1. Parentheses `()` -2. Unary operators: `+`, `-`, `NOT` -3. Multiplication/Division: `*`, `/`, `%` -4. Addition/Subtraction: `+`, `-` -5. Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `IN`, `BETWEEN`, `IS NULL` -6`AND` -7`OR` +1. Parentheses `()` +2. Unary operators: `+`, `-` +3. Multiplication/Division: `*`, `/`, `%` +4. Addition/Subtraction/Concatenation: `+`, `-`, `||` +5. Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `IN`, `BETWEEN`, `IS NULL` +6. `NOT` +7. `AND` +8. `OR` --- @@ -199,7 +200,7 @@ APPROX_COUNT_DISTINCT(customer_id) -- Approximate median APPROX_PERCENTILE(amount, 0.5) --- Approximate 95th percentile +-- Approximate 95th percentile APPROX_PERCENTILE(response_time, 0.95) ``` @@ -216,7 +217,7 @@ APPROX_PERCENTILE(response_time, 0.95) ### Conditional Aggregations (REQUIRED) -SUM / COUNT aggregation functions support `DISTINCT.` +SUM / COUNT aggregation functions support `DISTINCT.` All aggregations should support filtered aggregation: ```sql @@ -281,8 +282,8 @@ DATE_PART('day', date_expr) Supported date parts for `EXTRACT` and `DATE_PART`: -- `YEAR`, `QUARTER`, `MONTH`, `WEEK`, `DAY` -- `DAYOFWEEK`, `DAYOFYEAR` +- `YEAR`, `QUARTER`, `MONTH`, `WEEK`, `DAY` +- `DAYOFWEEK`, `DAYOFYEAR` - `HOUR`, `MINUTE`, `SECOND`, `MILLISECOND` ### Date Truncation (REQUIRED) @@ -340,13 +341,13 @@ model and behave identically across engines, making them the portable default. Parsing with an explicit format string relies on a datetime format model whose token vocabulary differs across engines (Oracle/`TO_CHAR`-style, `strftime` `%`-codes, and -Java/LDML patterns are all in use). +Java/LDML patterns are all in use). For portability, we are looking to restrict the `format` argument to the portable core format tokens defined in Date Formatting below, and prefer the -single-argument, ISO-8601 forms above where possible. +single-argument, ISO-8601 forms above where possible. -**Since, this differs so widely across databases, consider this experimental for now.** +**Since, this differs so widely across databases, consider this experimental for now.** | Function | Syntax | Description | | :---- | :---- | :---- | @@ -360,9 +361,9 @@ single-argument, ISO-8601 forms above where possible. | `TO_CHAR` | `TO_CHAR(date_expr, format)` | Format date as string | Ossie defines a portable core of format tokens: the tokens that can be expressed in -every major engine's datetime format model. +every major engine's datetime format model. -This feature is experimental. Implementations choosing to support these should support the following tokens for the +This feature is experimental. Implementations choosing to support these should support the following tokens for the `format` argument of `TO_CHAR`. The `strftime` and Java/LDML columns below are informative, provided to aid translation. @@ -431,7 +432,7 @@ a dialect extension. Pattern wildcards for `LIKE`: -- `%` \- Match any sequence of characters +- `%` \- Match any sequence of characters - `_` \- Match any single character ### Regular Expressions (RECOMMENDED) @@ -555,8 +556,8 @@ function_name(args) OVER ( Frame clause options: -- `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` -- `ROWS BETWEEN n PRECEDING AND n FOLLOWING` +- `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` +- `ROWS BETWEEN n PRECEDING AND n FOLLOWING` - `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` ### Ranking Functions (REQUIRED) @@ -610,13 +611,13 @@ CAST(expression AS target_type) Supported target types: -- `VARCHAR` / `STRING` \- Character string -- `INTEGER` / `INT` / `BIGINT` \- Integer -- `DECIMAL` / `NUMERIC` \- Fixed-point decimal -- `FLOAT` / `DOUBLE` \- Floating-point -- `BOOLEAN` \- Boolean -- `DATE` \- Date -- `TIMESTAMP` \- Timestamp +- `VARCHAR` / `STRING` \- Character string +- `INTEGER` / `INT` / `BIGINT` \- Integer +- `DECIMAL` / `NUMERIC` \- Fixed-point decimal +- `FLOAT` / `DOUBLE` \- Floating-point +- `BOOLEAN` \- Boolean +- `DATE` \- Date +- `TIMESTAMP` \- Timestamp - `TIME` \- Time ### TRY\_CAST (RECOMMENDED) @@ -643,7 +644,7 @@ a IS DISTINCT FROM b -- TRUE if one is NULL and other isn't Ossie implementations MAY support additional functions through dialect-specific extensions. When using dialect extensions, the expression must specify the dialect. -The Ossie dialect should always be supported. Other dialects MAY be ignored. There is no guarantee that all different dialects for an expression will act the same, so implementations should be consistent with their dialect handling. This means that if an Ossie model has an expression written in two dialects, the implementation should deterministically choose which dialect to use. +The Ossie dialect should always be supported. Other dialects MAY be ignored. There is no guarantee that all different dialects for an expression will act the same, so implementations should be consistent with their dialect handling. This means that if an Ossie model has an expression written in two dialects, the implementation should deterministically choose which dialect to use. ### Declaring Dialect-Specific Expressions @@ -669,11 +670,11 @@ expression: | Current timestamp | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP` | | Substring | `SUBSTRING(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTRING(s, start, len)` | `SUBSTRING(s, start, len)` | -### +### ### Dialect-Specific Extensions -Vendors may expose their own feature through extensions, however the default for Ossie should be to pass unknown values through.: +Vendors may expose their own feature through extensions, however the default for Ossie should be to pass unknown values through.: --- ## Cross-Reference: Tool Mappings @@ -770,11 +771,11 @@ Implementations MAY support additional functions through dialect extensions. The ## References -- [SQL:2003 Standard](https://www.iso.org/standard/34132.html) (ISO/IEC 9075-2:2003) -- [Tableau Functions Reference](https://help.tableau.com/current/pro/desktop/en-us/functions.htm) -- [Looker Studio Function List](https://support.google.com/looker-studio/table/6379764) -- [DAX Function Reference](https://learn.microsoft.com/en-us/dax/dax-function-reference) -- [Snowflake SQL Reference](https://docs.snowflake.com/en/sql-reference-functions) -- [BigQuery Standard SQL Reference](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) -- [Databricks SQL Functions](https://docs.databricks.com/sql/language-manual/sql-ref-functions.html) +- [SQL:2003 Standard](https://www.iso.org/standard/34132.html) (ISO/IEC 9075-2:2003) +- [Tableau Functions Reference](https://help.tableau.com/current/pro/desktop/en-us/functions.htm) +- [Looker Studio Function List](https://support.google.com/looker-studio/table/6379764) +- [DAX Function Reference](https://learn.microsoft.com/en-us/dax/dax-function-reference) +- [Snowflake SQL Reference](https://docs.snowflake.com/en/sql-reference-functions) +- [BigQuery Standard SQL Reference](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) +- [Databricks SQL Functions](https://docs.databricks.com/sql/language-manual/sql-ref-functions.html) - [PostgreSQL Functions](https://www.postgresql.org/docs/current/functions.html) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..87c183f9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,23 @@ +# Shared strict mypy rules for this repo's Python packages. +# +# mypy has no `extends`/`include` directive for config files, and a +# package's own pyproject.toml can't declaratively "point at" another +# config file the way ruff's `extend` does -- mypy only ever reads one +# config file per run, chosen by `--config-file` or fixed discovery in the +# current working directory (it doesn't search upward like ruff/eslint). +# +# So each package invokes mypy with this file explicit, e.g. from +# python/ossie-sql/: +# +# uv run mypy --config-file ../../mypy.ini src tests +# +# Target paths are passed on the command line (not listed here as `files =`) +# so they're always resolved relative to the invoking package's own +# directory, regardless of where this shared file lives. +[mypy] +python_version = 3.11 +strict = True + +[mypy-sqlglot.*] +# sqlglot ships inline types but not a py.typed marker on all versions. +ignore_missing_imports = True diff --git a/python/ossie-sql/.pre-commit-config.yaml b/python/ossie-sql/.pre-commit-config.yaml new file mode 100644 index 00000000..b1ca9842 --- /dev/null +++ b/python/ossie-sql/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: local + hooks: + - id: ossie-sql-ruff-check + name: ossie-sql ruff check + entry: bash -c 'cd python/ossie-sql && uv run ruff check --fix src tests' + language: system + files: ^python/ossie-sql/.*\.py$ + pass_filenames: false + - id: ossie-sql-ruff-format + name: ossie-sql ruff format + entry: bash -c 'cd python/ossie-sql && uv run ruff format src tests' + language: system + files: ^python/ossie-sql/.*\.py$ + pass_filenames: false + - id: ossie-sql-mypy + name: ossie-sql mypy (strict) + entry: bash -c 'cd python/ossie-sql && uv run mypy --config-file ../../mypy.ini src tests' + language: system + files: ^python/ossie-sql/.*\.py$ + pass_filenames: false diff --git a/python/ossie-sql/Makefile b/python/ossie-sql/Makefile new file mode 100644 index 00000000..3785b5d4 --- /dev/null +++ b/python/ossie-sql/Makefile @@ -0,0 +1,13 @@ +.PHONY: format lint typecheck test + +format: + uv run ruff format src tests + +lint: + uv run ruff check src tests + +typecheck: + uv run mypy --config-file ../../mypy.ini src tests + +test: + uv run pytest diff --git a/python/ossie-sql/README.md b/python/ossie-sql/README.md new file mode 100644 index 00000000..d5cc39e0 --- /dev/null +++ b/python/ossie-sql/README.md @@ -0,0 +1,57 @@ +# apache-ossie-sql + +A [SQLGlot](https://github.com/tobymao/sqlglot) dialect implementing the +Ossie expression language defined in +[`core-spec/expression_language.md`](../../../core-spec/expression_language.md) +("Ossie_SQL_2026"). + +This package covers only the expression grammar: a custom SQLGlot `Dialect` +(tokenizer/parser/generator) so `sqlglot.parse_one(sql, read="ossie")` parses +and round-trips the spec's SQL subset (aggregate/window/date/string/math/ +conditional functions, typed literals, `CASE`, `CAST`/`TRY_CAST`, etc.), plus +a validator that rejects the constructs the spec explicitly disallows +(`SELECT`/`FROM`/`JOIN`, `GROUP BY`, `WHERE`, subqueries, CTEs, set +operations, DDL/DML). Wiring the dialect into the Ossie YAML model (the +spec's "Changes to YAML" section) is out of scope here. + +## Development + +This package uses [`uv`](https://docs.astral.sh/uv/) for dependency +management. + +```bash +uv sync + +# Run the test suite +uv run pytest + +# Format code (auto-fixes in place) +uv run ruff format src tests + +# Check formatting without modifying files +uv run ruff format --check src tests + +# Lint +uv run ruff check src tests + +# Type-check (strict; rules come from the shared repo-root mypy.ini) +uv run mypy --config-file ../../mypy.ini src tests +``` + +Or via the `Makefile`: + +```bash +make format +make lint +make typecheck +make test +``` + +### Enforcing formatting/lint/type-checking locally + +Install the pre-commit hooks scoped to this package so `ruff format`, +`ruff check`, and `mypy` run automatically before each commit: + +```bash +uv run pre-commit install -c python/ossie-sql/.pre-commit-config.yaml +``` diff --git a/python/ossie-sql/pyproject.toml b/python/ossie-sql/pyproject.toml new file mode 100644 index 00000000..65513299 --- /dev/null +++ b/python/ossie-sql/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "apache-ossie-sql" +version = "0.1.0" +description = "SQLGlot dialect for the Ossie expression language (core-spec/expression_language.md)" +license = { text = "Apache-2.0" } +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "sqlglot>=25.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "mypy>=1.10", + "ruff>=0.11", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie_sql"] + +[tool.ruff] +extend = "../../ruff.toml" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/python/ossie-sql/src/ossie_sql/__init__.py b/python/ossie-sql/src/ossie_sql/__init__.py new file mode 100644 index 00000000..0b8ddec2 --- /dev/null +++ b/python/ossie-sql/src/ossie_sql/__init__.py @@ -0,0 +1,27 @@ +"""SQLGlot dialect and validation helpers for the Ossie expression language. + +See ``core-spec/expression_language.md`` for the language spec this package +implements, and this package's README for scope and usage. +""" + +from ossie_sql.dialect import Ossie +from ossie_sql.functions import ComplianceLevel, compliance_level +from ossie_sql.identifiers import ( + InvalidIdentifierError, + identifiers_equal, + normalize_identifier, + normalize_identifier_text, +) +from ossie_sql.validate import UnsupportedConstructError, validate_expression + +__all__ = [ + "ComplianceLevel", + "InvalidIdentifierError", + "Ossie", + "UnsupportedConstructError", + "compliance_level", + "identifiers_equal", + "normalize_identifier", + "normalize_identifier_text", + "validate_expression", +] diff --git a/python/ossie-sql/src/ossie_sql/dialect.py b/python/ossie-sql/src/ossie_sql/dialect.py new file mode 100644 index 00000000..deb38c59 --- /dev/null +++ b/python/ossie-sql/src/ossie_sql/dialect.py @@ -0,0 +1,188 @@ +"""SQLGlot dialect for the Ossie expression language. + +Implements the grammar defined in ``core-spec/expression_language.md`` +("Ossie_SQL_2026"). Registers with SQLGlot as ``"ossie"``, so +``sqlglot.parse_one(sql, read="ossie")`` parses the spec's SQL subset and +``expression.sql(dialect="ossie")`` renders it back. + +The spec is explicitly an ANSI SQL:2003 subset, so this dialect starts from +SQLGlot's default (ANSI-like) Tokenizer/Parser/Generator and only overrides +the handful of spec constructs that the default dialect either can't parse +in the spec's exact shape (``DATEADD``/``DATEDIFF``/``DATE_PART`` with a +bare, leading date-part argument) or renders back in a spelling the spec +doesn't define (e.g. ``STR_POSITION`` instead of ``POSITION ... IN``, +``APPROX_DISTINCT`` instead of ``APPROX_COUNT_DISTINCT``). Functions the spec +lists that SQLGlot has no dedicated AST node for (``IFF``, ``ZEROIFNULL``, +``NULLIFZERO``, ...) already round-trip correctly as-is via SQLGlot's generic +``exp.Anonymous`` fallback and need no customization here. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import ClassVar + +from sqlglot import TokenType, exp +from sqlglot.dialects.dialect import Dialect, unit_to_var +from sqlglot.generator import Generator +from sqlglot.helper import seq_get +from sqlglot.parser import Parser +from sqlglot.tokens import Tokenizer + +_FuncBuilder = Callable[[Sequence[exp.Expression]], exp.Expression] + + +def _build_date_delta(exp_class: type[exp.DateAdd] | type[exp.DateDiff]) -> _FuncBuilder: + """Build a parser for ``FUNC(part, amount_or_start, date_or_end)``. + + The spec puts the date-part identifier FIRST (``DATEADD(day, 7, d)``, + ``DATEDIFF(day, d1, d2)``). SQLGlot's generic positional ``Func`` + construction instead treats the *last* argument as the unit, so this + needs a dedicated builder rather than relying on the default mapping. + """ + + def _builder(args: Sequence[exp.Expression]) -> exp.Expression: + return exp_class(this=seq_get(args, 2), expression=seq_get(args, 1), unit=seq_get(args, 0)) + + return _builder + + +def _build_date_part(args: Sequence[exp.Expression]) -> exp.Expression: + """``DATE_PART(part, expr)`` is the spec's alternative spelling of + ``EXTRACT(part FROM expr)``; unify both into the same ``exp.Extract`` + node so callers see one canonical AST shape regardless of which + surface syntax was used. + + ``DATE_PART`` takes its part name as a quoted string (``'year'``) while + ``EXTRACT`` takes a bare keyword (``YEAR``); normalize to the latter so + the merged AST always renders as valid ``EXTRACT(... FROM ...)`` syntax. + """ + part = seq_get(args, 0) + part_name = part.name if part is not None else "" + return exp.Extract(this=exp.var(part_name.upper()), expression=seq_get(args, 1)) + + +def _build_dpipe( + *, this: exp.Expression | None = None, expression: exp.Expression | None = None +) -> exp.DPipe: + """Give ``||`` the same precedence tier as binary ``+``/``-``. + + SQLGlot's default parser groups ``||`` with the bitwise operators, one + tier looser than ``+``/``-``. The spec instead follows the common convention, + which both put ``||`` at the *same* tier as ``+``/``-`` + (left-to-right, like the arithmetic operators around it) -- see + ``_OssieParser.TERM`` below, which is what actually makes that happen; + this just reproduces the ``safe=True`` default SQLGlot's own DPipe + construction uses so behavior is otherwise unchanged. + """ + return exp.DPipe(this=this, expression=expression, safe=True) + + +class _OssieTokenizer(Tokenizer): + pass + + +class _OssieParser(Parser): + FUNCTIONS = { + **Parser.FUNCTIONS, + "DATEADD": _build_date_delta(exp.DateAdd), + "DATEDIFF": _build_date_delta(exp.DateDiff), + "DATE_PART": _build_date_part, + "APPROX_PERCENTILE": exp.ApproxQuantile.from_arg_list, + } + # SQLGlot infers Parser.TERM's type from its literal (dict[TokenType, + # type[Binary]]), so mypy sees adding a plain builder function as an + # incompatible override -- it isn't, at runtime _parse_term only ever + # calls `klass(this=..., expression=...)` generically. Silence the two + # resulting checks rather than fight SQLGlot's own inferred type. + TERM: ClassVar[dict[TokenType, Callable[..., exp.Expression]]] = { # type: ignore[assignment] + **Parser.TERM, # type: ignore[dict-item] + TokenType.DPIPE: _build_dpipe, + } + + +class _OssieGenerator(Generator): + TYPE_MAPPING = { + **Generator.TYPE_MAPPING, + exp.DataType.Type.TIMESTAMPNTZ: "TIMESTAMP_NTZ", + } + + def dateadd_sql(self, expression: exp.DateAdd) -> str: + return self.func("DATEADD", unit_to_var(expression), expression.expression, expression.this) + + def datediff_sql(self, expression: exp.DateDiff) -> str: + return self.func( + "DATEDIFF", unit_to_var(expression), expression.expression, expression.this + ) + + def strposition_sql(self, expression: exp.StrPosition) -> str: + this = self.sql(expression, "this") + substr = self.sql(expression, "substr") + return f"POSITION({substr} IN {this})" + + def startswith_sql(self, expression: exp.StartsWith) -> str: + return self.func("STARTSWITH", expression.this, expression.expression) + + def endswith_sql(self, expression: exp.EndsWith) -> str: + return self.func("ENDSWITH", expression.this, expression.expression) + + def approxdistinct_sql(self, expression: exp.ApproxDistinct) -> str: + return self.func("APPROX_COUNT_DISTINCT", expression.this, expression.args.get("accuracy")) + + def variancepop_sql(self, expression: exp.VariancePop) -> str: + # SQLGlot's own canonical spelling ("VARIANCE_POP") isn't a name the + # spec recognizes at all; the spec only defines "VAR_POP". + return self.func("VAR_POP", expression.this) + + def dayofyear_sql(self, expression: exp.DayOfYear) -> str: + # Ditto: the spec defines "DAYOFYEAR", not SQLGlot's "DAY_OF_YEAR". + return self.func("DAYOFYEAR", expression.this) + + def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: + # The spec calls this "APPROX_PERCENTILE"; SQLGlot's canonical name + # ("APPROX_QUANTILE") isn't a spelling the spec defines. + return self.func("APPROX_PERCENTILE", expression.this, expression.args.get("quantile")) + + def not_sql(self, expression: exp.Not) -> str: + # Render the compact `NOT IN`/`IS NOT` forms the spec documents, + # instead of SQLGlot's generic `NOT (x IN (...))` / `NOT (x IS NULL)`. + this = expression.this + if isinstance(this, exp.In): + return self.in_sql(this).replace(" IN ", " NOT IN ", 1) + if isinstance(this, exp.Is): + return self.binary(this, "IS NOT") + return super().not_sql(expression) + + def tochar_sql(self, expression: exp.ToChar) -> str: + return self.func("TO_CHAR", expression.this, expression.args.get("format")) + + def cast_sql(self, expression: exp.Cast, safe_prefix: str | None = None) -> str: + # Prefer the compact typed-literal form (`DATE '...'`) for a string + # literal cast to DATE/TIME/TIMESTAMP/TIMESTAMP_NTZ -- the spec's + # primary documented construction syntax for these types. + to = expression.to + this = expression.this + if ( + not safe_prefix + and isinstance(this, exp.Literal) + and this.is_string + and to.is_type( + exp.DataType.Type.DATE, + exp.DataType.Type.TIME, + exp.DataType.Type.TIMESTAMP, + exp.DataType.Type.TIMESTAMPNTZ, + ) + ): + return f"{self.sql(to)} {self.sql(this)}" + return super().cast_sql(expression, safe_prefix=safe_prefix) + + +class Ossie(Dialect): + """The Ossie_SQL_2026 expression dialect.""" + + Tokenizer = _OssieTokenizer + Parser = _OssieParser + Generator = _OssieGenerator + + +__all__ = ["Ossie"] diff --git a/python/ossie-sql/src/ossie_sql/functions.py b/python/ossie-sql/src/ossie_sql/functions.py new file mode 100644 index 00000000..771112f1 --- /dev/null +++ b/python/ossie-sql/src/ossie_sql/functions.py @@ -0,0 +1,237 @@ +"""Function-name compliance levels from ``core-spec/expression_language.md``. + +This module is purely informational (see the package README / project plan +for the rationale): the spec's own "Dialect Extensions" section says an +unrecognized function name should pass through by default, so +:mod:`ossie_sql.validate` does *not* use this module to reject expressions. +Instead, :func:`compliance_level` lets callers (tests, tooling, documentation +generators, ...) ask "is this function name part of the portable core the +spec defines, and at what tier?" + +This may be used in the future for strict modes and generating warnings. + +* Only function *names* are listed here -- operators (``||``, ``%``, + ``AND``/``OR``/``NOT``, ``LIKE``/``ILIKE``) and typed literals + (``DATE '...'``) are syntax, not callable names, and don't belong in a + function-name registry. +* The spec's date-part vocabulary for ``EXTRACT``/``DATE_PART`` (``WEEK``, + ``DAYOFWEEK``, ``MILLISECOND``, ...) is a separate, closed list of + *argument* values, not standalone function names, and is intentionally + excluded from :data:`DATETIME_FUNCTIONS`. +* Names like ``TO_VARCHAR``/``TO_NUMBER``/``TO_BOOLEAN`` appear in some + draft implementations but are not defined anywhere in the committed + spec, so they are not included here. +""" + +from __future__ import annotations + +from typing import Final, Literal + +ComplianceLevel = Literal["REQUIRED", "RECOMMENDED", "EXPERIMENTAL"] + +# Aggregation -- Core (REQUIRED), Statistical (REQUIRED), Percentile (REQUIRED), +# Approximate (RECOMMENDED). +_AGGREGATE_REQUIRED: Final[frozenset[str]] = frozenset( + { + "SUM", + "COUNT", + "AVG", + "MIN", + "MAX", + "STDDEV", + "STDDEV_POP", + "STDDEV_SAMP", + "VARIANCE", + "VAR_POP", + "VAR_SAMP", + "MEDIAN", + "PERCENTILE_CONT", + "PERCENTILE_DISC", + } +) +_AGGREGATE_RECOMMENDED: Final[frozenset[str]] = frozenset( + { + "APPROX_COUNT_DISTINCT", + "APPROX_PERCENTILE", + } +) + +# Date/Time -- Current, Extraction, Alternative Extraction Syntax, Truncation, +# Arithmetic, and Construction are all REQUIRED. Formatting (TO_CHAR) and +# format-string construction are EXPERIMENTAL per the spec's own section +# headers. +_DATETIME_REQUIRED: Final[frozenset[str]] = frozenset( + { + "CURRENT_DATE", + "CURRENT_TIMESTAMP", + "CURRENT_TIME", + "YEAR", + "QUARTER", + "MONTH", + "DAY", + "DAYOFYEAR", + "HOUR", + "MINUTE", + "SECOND", + "EXTRACT", + "DATE_PART", + "DATE_TRUNC", + "DATEADD", + "DATEDIFF", + "TO_DATE", + "TO_TIMESTAMP", + } +) +_DATETIME_EXPERIMENTAL: Final[frozenset[str]] = frozenset({"TO_CHAR"}) + +# String -- Manipulation and Search are REQUIRED (including REGEXP_LIKE, listed +# under Pattern Matching); the RECOMMENDED regex functions are separate. +_STRING_REQUIRED: Final[frozenset[str]] = frozenset( + { + "CONCAT", + "LENGTH", + "LOWER", + "UPPER", + "TRIM", + "LTRIM", + "RTRIM", + "LEFT", + "RIGHT", + "SUBSTRING", + "REPLACE", + "SPLIT_PART", + "POSITION", + "CHARINDEX", + "CONTAINS", + "STARTSWITH", + "ENDSWITH", + "REGEXP_LIKE", + } +) +_STRING_RECOMMENDED: Final[frozenset[str]] = frozenset( + { + "REGEXP_EXTRACT", + "REGEXP_REPLACE", + "REGEXP_COUNT", + } +) + +# Math -- Basic and Advanced are REQUIRED; Trigonometric is RECOMMENDED. +_MATH_REQUIRED: Final[frozenset[str]] = frozenset( + { + "ABS", + "ROUND", + "FLOOR", + "CEIL", + "CEILING", + "TRUNC", + "TRUNCATE", + "MOD", + "SIGN", + "POWER", + "SQRT", + "EXP", + "LN", + "LOG", + "LOG10", + "GREATEST", + "LEAST", + } +) +_MATH_RECOMMENDED: Final[frozenset[str]] = frozenset( + { + "SIN", + "COS", + "TAN", + "ASIN", + "ACOS", + "ATAN", + "ATAN2", + "RADIANS", + "DEGREES", + "PI", + } +) + +# Conditional (REQUIRED). +_CONDITIONAL_REQUIRED: Final[frozenset[str]] = frozenset( + { + "IF", + "IFF", + "NULLIF", + "COALESCE", + "IFNULL", + "NVL", + "NVL2", + "ZEROIFNULL", + "NULLIFZERO", + } +) + +# Window -- Ranking and Offset (REQUIRED). Window aggregations reuse the +# aggregate functions above. +_WINDOW_REQUIRED: Final[frozenset[str]] = frozenset( + { + "ROW_NUMBER", + "RANK", + "DENSE_RANK", + "NTILE", + "PERCENT_RANK", + "CUME_DIST", + "LAG", + "LEAD", + "FIRST_VALUE", + "LAST_VALUE", + "NTH_VALUE", + } +) + +# Type conversion -- CAST is REQUIRED, TRY_CAST is RECOMMENDED. +_TYPE_CONVERSION_REQUIRED: Final[frozenset[str]] = frozenset({"CAST"}) +_TYPE_CONVERSION_RECOMMENDED: Final[frozenset[str]] = frozenset({"TRY_CAST"}) + +REQUIRED_FUNCTIONS: Final[frozenset[str]] = ( + _AGGREGATE_REQUIRED + | _DATETIME_REQUIRED + | _STRING_REQUIRED + | _MATH_REQUIRED + | _CONDITIONAL_REQUIRED + | _WINDOW_REQUIRED + | _TYPE_CONVERSION_REQUIRED +) +"""Every function name the spec marks REQUIRED (MUST support).""" + +RECOMMENDED_FUNCTIONS: Final[frozenset[str]] = ( + _AGGREGATE_RECOMMENDED | _STRING_RECOMMENDED | _MATH_RECOMMENDED | _TYPE_CONVERSION_RECOMMENDED +) +"""Every function name the spec marks RECOMMENDED (SHOULD support).""" + +EXPERIMENTAL_FUNCTIONS: Final[frozenset[str]] = _DATETIME_EXPERIMENTAL +"""Function names the spec marks EXPERIMENTAL (format-string-driven date/time).""" + + +def compliance_level(name: str) -> ComplianceLevel | None: + """Return the spec compliance tier for a function name, or ``None``. + + ``None`` means ``name`` is not defined anywhere in + ``core-spec/expression_language.md`` -- per the spec's "Dialect + Extensions" section, that makes it a vendor/dialect extension, which + :mod:`ossie_sql.validate` passes through rather than rejects. + """ + upper = name.upper() + if upper in REQUIRED_FUNCTIONS: + return "REQUIRED" + if upper in RECOMMENDED_FUNCTIONS: + return "RECOMMENDED" + if upper in EXPERIMENTAL_FUNCTIONS: + return "EXPERIMENTAL" + return None + + +__all__ = [ + "REQUIRED_FUNCTIONS", + "RECOMMENDED_FUNCTIONS", + "EXPERIMENTAL_FUNCTIONS", + "ComplianceLevel", + "compliance_level", +] diff --git a/python/ossie-sql/src/ossie_sql/identifiers.py b/python/ossie-sql/src/ossie_sql/identifiers.py new file mode 100644 index 00000000..a683447e --- /dev/null +++ b/python/ossie-sql/src/ossie_sql/identifiers.py @@ -0,0 +1,86 @@ +"""Identifier normalization per the spec's "Namespacing and Identifier +Resolution" section. + +Key rule from the spec's comparison table: **regular (unquoted) identifiers +normalize to upper-case**, while quoted identifiers are matched exactly +(quotes stripped, escapes unescaped, case preserved) -- + + id -> ID (regular identifier, case-insensitive, normalizes upper) + Id -> ID (same) + "ID" -> ID (quoted, but happens to already be upper) + "id" -> id (quoted: exact, case preserved -- does NOT match column ID) + +Identifiers must be valid ANSI SQL names up to 128 characters. +""" + +from __future__ import annotations + +import re +from typing import Final + +from sqlglot import exp + +_MAX_IDENTIFIER_LENGTH: Final[int] = 128 + +# ANSI SQL regular (unquoted) identifier shape: a letter, followed by any run +# of letters/digits/underscores. The spec doesn't spell out a formal grammar +# beyond "follow ANSI SQL naming"; this matches the common, portable subset +# every major engine accepts unquoted. +_REGULAR_IDENTIFIER_RE = re.compile(r"\A[A-Za-z][A-Za-z0-9_]*\Z") + + +class InvalidIdentifierError(ValueError): + """Raised when an identifier doesn't meet the spec's shape/length rules.""" + + +def is_valid_identifier(raw: str, *, quoted: bool) -> bool: + """Return whether ``raw`` is a syntactically valid Ossie identifier. + + ``raw`` is the identifier text with quotes already stripped (as + SQLGlot's ``exp.Identifier.this`` provides). Quoted identifiers accept + any non-empty text; regular (unquoted) identifiers must match ANSI + regular-identifier shape. Both are capped at 128 characters. + """ + if not raw or len(raw) > _MAX_IDENTIFIER_LENGTH: + return False + if quoted: + return True + return bool(_REGULAR_IDENTIFIER_RE.match(raw)) + + +def normalize_identifier_text(raw: str, *, quoted: bool) -> str: + """Return the spec-normalized form of identifier text. + + Regular identifiers case-fold to upper-case (matching the spec's + normalization rule); quoted identifiers are returned unchanged. + + Raises + ------ + InvalidIdentifierError + If ``raw`` doesn't meet the shape/length rules. + """ + if not is_valid_identifier(raw, quoted=quoted): + raise InvalidIdentifierError( + f"{raw!r} is not a valid Ossie identifier " + f"(quoted={quoted}, max length {_MAX_IDENTIFIER_LENGTH})" + ) + return raw if quoted else raw.upper() + + +def normalize_identifier(node: exp.Identifier) -> str: + """Return the spec-normalized form of a SQLGlot ``exp.Identifier`` node.""" + return normalize_identifier_text(node.this, quoted=bool(node.args.get("quoted"))) + + +def identifiers_equal(a: exp.Identifier, b: exp.Identifier) -> bool: + """Return whether two identifiers refer to the same normalized name.""" + return normalize_identifier(a) == normalize_identifier(b) + + +__all__ = [ + "InvalidIdentifierError", + "is_valid_identifier", + "normalize_identifier_text", + "normalize_identifier", + "identifiers_equal", +] diff --git a/python/ossie-sql/src/ossie_sql/py.typed b/python/ossie-sql/src/ossie_sql/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/python/ossie-sql/src/ossie_sql/validate.py b/python/ossie-sql/src/ossie_sql/validate.py new file mode 100644 index 00000000..6fb0a79b --- /dev/null +++ b/python/ossie-sql/src/ossie_sql/validate.py @@ -0,0 +1,74 @@ +"""Construct validation per the spec's "Not Supported in Expressions" table. + +Ossie expressions are scalar/aggregate/window fragments, not full queries. +The spec explicitly disallows ``SELECT``/``FROM``/``JOIN``, ``GROUP BY``, +``WHERE``, subqueries, CTEs, set operations (``UNION``/``INTERSECT``/ +``EXCEPT``), and DDL/DML -- each with a documented reason ("use filter +property instead", "use field references instead", etc.). + +Per the spec's "Dialect Extensions" section, unrecognized *function names* +pass through by default (see :mod:`ossie_sql.functions`); this module only +rejects the constructs the spec explicitly disallows. It does not maintain +a function-name whitelist. + +Bind parameters/placeholders (``:n``, ``?``) are not documented anywhere in +the expression language -- Ossie expressions are static field/metric +bodies, not parameterized queries -- so they're rejected here too. +""" + +from __future__ import annotations + +from sqlglot import exp + +from ossie_sql.windows import first_unsupported_frame + +# WHERE/GROUP BY/JOIN clauses can only appear inside a SELECT statement in +# SQL grammar, so rejecting exp.Select transitively covers them; exp.Join is +# listed explicitly too since the spec calls it out by name in its own row. +_DISALLOWED_NODE_TYPES: tuple[type[exp.Expression], ...] = ( + exp.Select, + exp.With, + exp.Union, + exp.Intersect, + exp.Except, + exp.Join, + exp.Create, + exp.Drop, + exp.Alter, + exp.Insert, + exp.Update, + exp.Delete, + exp.Merge, + exp.Placeholder, + exp.Parameter, +) + + +class UnsupportedConstructError(ValueError): + """Raised when an expression contains a construct the spec disallows.""" + + def __init__(self, node: exp.Expression) -> None: + self.node = node + super().__init__( + f"{type(node).__name__!r} is not a supported Ossie expression construct: {node.sql()!r}" + ) + + +def validate_expression(expression: exp.Expr) -> None: + """Raise :class:`UnsupportedConstructError` for any disallowed construct. + + Walks the full AST (not just the top level) so a disallowed construct + nested inside an otherwise-valid expression (e.g. a subquery inside an + ``IN`` list) is still caught. Does not check function names -- see the + module docstring. + """ + for node in expression.walk(): + if isinstance(node, _DISALLOWED_NODE_TYPES): + raise UnsupportedConstructError(node) + + unsupported_frame = first_unsupported_frame(expression) + if unsupported_frame is not None: + raise UnsupportedConstructError(unsupported_frame) + + +__all__ = ["UnsupportedConstructError", "validate_expression"] diff --git a/python/ossie-sql/src/ossie_sql/windows.py b/python/ossie-sql/src/ossie_sql/windows.py new file mode 100644 index 00000000..49c478a4 --- /dev/null +++ b/python/ossie-sql/src/ossie_sql/windows.py @@ -0,0 +1,47 @@ +"""Window-function helpers matching the spec's "Window Functions" section. + +The spec documents exactly two frame modes -- ``ROWS`` and ``RANGE`` -- and +says window functions "should act consistently with window functions in +ANSI SQL", whose core (SQL:2003, the standard this whole language is based +on per the spec's "Standards Reference" section) only defines those two +frame modes; ``GROUPS`` is a later (SQL:2011) addition. So ``GROUPS`` frames +are treated as out of scope here. + +Note: an earlier draft implementation (PR #125) also rejected *nested* +window functions and *parameterized* frame bounds. Neither restriction +appears anywhere in the committed spec text, so this module does not +enforce them -- per this package's policy of treating the committed spec as +authoritative over earlier drafts. +""" + +from __future__ import annotations + +from sqlglot import exp + +_ACCEPTED_FRAME_KINDS = frozenset({"ROWS", "RANGE"}) + + +def contains_window(expression: exp.Expr) -> bool: + """Return whether ``expression``'s AST contains an ``OVER (...)`` window.""" + return any(isinstance(node, exp.Window) for node in expression.walk()) + + +def first_unsupported_frame(expression: exp.Expr) -> exp.Window | None: + """Return the first window whose frame clause uses an unsupported mode. + + Returns ``None`` if every ``OVER (...)`` in ``expression`` either omits a + frame clause or uses ``ROWS``/``RANGE``. + """ + for node in expression.walk(): + if not isinstance(node, exp.Window): + continue + spec = node.args.get("spec") + if spec is None: + continue + kind = (spec.args.get("kind") or "").upper() + if kind and kind not in _ACCEPTED_FRAME_KINDS: + return node + return None + + +__all__ = ["contains_window", "first_unsupported_frame"] diff --git a/python/ossie-sql/tests/conftest.py b/python/ossie-sql/tests/conftest.py new file mode 100644 index 00000000..5ffd42ab --- /dev/null +++ b/python/ossie-sql/tests/conftest.py @@ -0,0 +1,28 @@ +"""Shared test fixtures. + +Importing ``ossie_sql`` registers the ``"ossie"`` dialect with SQLGlot (via +the ``Ossie(Dialect)`` subclass's registration metaclass), so every test +module can call ``sqlglot.parse_one(sql, read="ossie")`` / +``expression.sql(dialect="ossie")`` without an explicit import of +``ossie_sql.dialect``. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +import sqlglot +from sqlglot import exp + +import ossie_sql # noqa: F401 (registers the "ossie" dialect as a side effect) + + +@pytest.fixture +def parse_ossie() -> Callable[[str], exp.Expr]: + """Return a helper that parses SQL text with the Ossie dialect.""" + + def _parse(sql: str) -> exp.Expr: + return sqlglot.parse_one(sql, read="ossie") + + return _parse diff --git a/python/ossie-sql/tests/test_dialect_roundtrip.py b/python/ossie-sql/tests/test_dialect_roundtrip.py new file mode 100644 index 00000000..ebe84db4 --- /dev/null +++ b/python/ossie-sql/tests/test_dialect_roundtrip.py @@ -0,0 +1,127 @@ +"""Parse -> generate round-trip tests across the spec's construct categories. + +Each case is (input, expected_output) using the Ossie dialect for both +parsing and rendering. Most cases round-trip to themselves; a few +canonicalize to a different (still spec-valid) spelling, which is called +out inline. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +import ossie_sql # noqa: F401 + +ROUNDTRIP_CASES = [ + # Arithmetic / comparison / logical operators. + ("a + b - c * d / e % f", "a + b - c * d / e % f"), + ("a = b AND a <> b AND a != b", "a = b AND a <> b AND a <> b"), + ("a < b OR NOT (a > b)", "a < b OR NOT (a > b)"), + ("x BETWEEN a AND b", "x BETWEEN a AND b"), + ("x IN (a, b, c)", "x IN (a, b, c)"), + ("x NOT IN (a, b, c)", "x NOT IN (a, b, c)"), + ("x LIKE 'a%'", "x LIKE 'a%'"), + ("x ILIKE 'a%'", "x ILIKE 'a%'"), + ("x IS NULL", "x IS NULL"), + ("x IS NOT NULL", "x IS NOT NULL"), + ("NOT x IS NULL", "x IS NOT NULL"), + ("a IS DISTINCT FROM b", "a IS DISTINCT FROM b"), + ("a IS NOT DISTINCT FROM b", "a IS NOT DISTINCT FROM b"), + ("a || b", "a || b"), + # || sits at the same precedence tier as binary +/- (Snowflake/Databricks + # convention, not SQLGlot's default looser bitwise-adjacent tier). + ("a + b || c", "a + b || c"), + ("a || b + c", "a || b + c"), + ("a || b * c", "a || b * c"), + # CASE. + ("CASE WHEN a THEN 1 WHEN b THEN 2 ELSE 3 END", "CASE WHEN a THEN 1 WHEN b THEN 2 ELSE 3 END"), + ("CASE x WHEN 1 THEN 'a' ELSE 'b' END", "CASE x WHEN 1 THEN 'a' ELSE 'b' END"), + # Aggregates. + ("SUM(amount)", "SUM(amount)"), + ("COUNT(*)", "COUNT(*)"), + ("COUNT(DISTINCT customer_id)", "COUNT(DISTINCT customer_id)"), + ("SUM(DISTINCT amount)", "SUM(DISTINCT amount)"), + ("MEDIAN(x)", "MEDIAN(x)"), + ( + "PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY x)", + "PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY x)", + ), + ("APPROX_COUNT_DISTINCT(customer_id)", "APPROX_COUNT_DISTINCT(customer_id)"), + ("APPROX_PERCENTILE(amount, 0.5)", "APPROX_PERCENTILE(amount, 0.5)"), + ("VAR_POP(x)", "VAR_POP(x)"), + ("VAR_SAMP(x)", "VARIANCE(x)"), # spec declares VAR_SAMP an alias for VARIANCE + # Date/time. + ("YEAR(order_date)", "YEAR(order_date)"), + ("DAYOFYEAR(order_date)", "DAYOFYEAR(order_date)"), + ("EXTRACT(YEAR FROM order_date)", "EXTRACT(YEAR FROM order_date)"), + ("DATE_PART('year', order_date)", "EXTRACT(YEAR FROM order_date)"), # unified onto EXTRACT + # DATE_TRUNC's unit literal is normalized to upper-case at AST + # construction time (a SQLGlot-wide invariant, not dialect-specific) -- + # harmless since the part name is compared case-insensitively everywhere. + ("DATE_TRUNC('month', order_date)", "DATE_TRUNC('MONTH', order_date)"), + ("DATEADD(day, 7, order_date)", "DATEADD(DAY, 7, order_date)"), + ("DATEDIFF(day, start_date, end_date)", "DATEDIFF(DAY, start_date, end_date)"), + ("TO_CHAR(order_date, 'YYYY-MM-DD')", "TO_CHAR(order_date, 'YYYY-MM-DD')"), + # String. + ("CONCAT(a, b)", "CONCAT(a, b)"), + ("POSITION('a' IN b)", "POSITION('a' IN b)"), + ("CHARINDEX('a', b)", "POSITION('a' IN b)"), # alias for POSITION, per spec + ("STARTSWITH(a, 'x')", "STARTSWITH(a, 'x')"), + ("ENDSWITH(a, 'x')", "ENDSWITH(a, 'x')"), + ("CONTAINS(a, 'x')", "CONTAINS(a, 'x')"), + ("REGEXP_LIKE(a, 'x.*')", "REGEXP_LIKE(a, 'x.*')"), + # Math. + ("ABS(x)", "ABS(x)"), + ("CEIL(x)", "CEIL(x)"), + ("CEILING(x)", "CEIL(x)"), # spec-declared alias + ("MOD(x, y)", "x % y"), # spec also defines "%" as the operator form + ("GREATEST(a, b, c)", "GREATEST(a, b, c)"), + # Conditional. + ("IFF(a > b, 1, 0)", "IFF(a > b, 1, 0)"), + ("COALESCE(a, b, c)", "COALESCE(a, b, c)"), + ("NVL2(a, 1, 0)", "NVL2(a, 1, 0)"), + ("ZEROIFNULL(x)", "ZEROIFNULL(x)"), + ("NULLIFZERO(x)", "NULLIFZERO(x)"), + # Window. + ("ROW_NUMBER() OVER (ORDER BY x)", "ROW_NUMBER() OVER (ORDER BY x)"), + ( + "SUM(amount) OVER (PARTITION BY region ORDER BY order_date " + "ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)", + "SUM(amount) OVER (PARTITION BY region ORDER BY order_date " + "ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)", + ), + ("LAG(x, 1, 0) OVER (ORDER BY d)", "LAG(x, 1, 0) OVER (ORDER BY d)"), + # Type conversion / typed literals. + ("CAST(a AS VARCHAR)", "CAST(a AS VARCHAR)"), + ("TRY_CAST(a AS INTEGER)", "TRY_CAST(a AS INT)"), + ("DATE '2024-01-15'", "DATE '2024-01-15'"), + ("TIME '10:30:00'", "TIME '10:30:00'"), + ("TIMESTAMP_NTZ '2024-01-15 10:30:00'", "TIMESTAMP_NTZ '2024-01-15 10:30:00'"), +] + + +@pytest.mark.parametrize("source,expected", ROUNDTRIP_CASES) +def test_roundtrip(source: str, expected: str) -> None: + parsed = sqlglot.parse_one(source, read="ossie") + assert parsed.sql(dialect="ossie") == expected + + +# Precedence-structure checks: `a + b || c` renders identically either way +# `||` groups relative to `+`/`-`, so the string-equality cases above alone +# wouldn't catch a regression back to SQLGlot's default (looser) precedence +# for `||`. Assert the actual top-level node type instead, matching the +# Snowflake/Databricks convention of `||` sharing +/-'s tier, left to right. +CONCAT_PRECEDENCE_CASES = [ + ("a + b || c", sqlglot.exp.DPipe), # (a + b) || c + ("a || b + c", sqlglot.exp.Add), # (a || b) + c + ("a || b * c", sqlglot.exp.DPipe), # a || (b * c) -- * still binds tighter +] + + +@pytest.mark.parametrize("source,top_level_type", CONCAT_PRECEDENCE_CASES) +def test_concat_precedence_matches_term_level( + source: str, top_level_type: type[sqlglot.exp.Expression] +) -> None: + parsed = sqlglot.parse_one(source, read="ossie") + assert type(parsed) is top_level_type diff --git a/python/ossie-sql/tests/test_functions.py b/python/ossie-sql/tests/test_functions.py new file mode 100644 index 00000000..1f205fcb --- /dev/null +++ b/python/ossie-sql/tests/test_functions.py @@ -0,0 +1,97 @@ +"""Tests for ossie_sql.functions.compliance_level().""" + +from __future__ import annotations + +import pytest + +from ossie_sql.functions import ( + EXPERIMENTAL_FUNCTIONS, + RECOMMENDED_FUNCTIONS, + REQUIRED_FUNCTIONS, + compliance_level, +) + +REQUIRED_EXAMPLES = [ + "SUM", + "COUNT", + "AVG", + "MEDIAN", + "PERCENTILE_CONT", + "YEAR", + "DATEADD", + "DATEDIFF", + "EXTRACT", + "DATE_PART", + "CONCAT", + "POSITION", + "STARTSWITH", + "ABS", + "MOD", + "IF", + "IFF", + "ZEROIFNULL", + "ROW_NUMBER", + "LAG", + "CAST", +] + +RECOMMENDED_EXAMPLES = [ + "APPROX_COUNT_DISTINCT", + "APPROX_PERCENTILE", + "REGEXP_EXTRACT", + "SIN", + "PI", + "TRY_CAST", +] + +EXPERIMENTAL_EXAMPLES = ["TO_CHAR"] + + +@pytest.mark.parametrize("name", REQUIRED_EXAMPLES) +def test_required_functions(name: str) -> None: + assert compliance_level(name) == "REQUIRED" + + +@pytest.mark.parametrize("name", RECOMMENDED_EXAMPLES) +def test_recommended_functions(name: str) -> None: + assert compliance_level(name) == "RECOMMENDED" + + +@pytest.mark.parametrize("name", EXPERIMENTAL_EXAMPLES) +def test_experimental_functions(name: str) -> None: + assert compliance_level(name) == "EXPERIMENTAL" + + +@pytest.mark.parametrize("name", ["sum", "Sum", "sUM"]) +def test_compliance_level_is_case_insensitive(name: str) -> None: + assert compliance_level(name) == "REQUIRED" + + +@pytest.mark.parametrize( + "name", + [ + "SNOWFLAKE_VENDOR_FUNC", + "TO_VARCHAR", + "TO_NUMBER", + "TO_BOOLEAN", + "EXISTS_IN", + "NOT_A_FUNCTION", + ], +) +def test_unknown_functions_return_none(name: str) -> None: + # These are either vendor extensions or names from an earlier draft that + # the committed spec never defines -- compliance_level() reports them as + # unknown (None) rather than raising, matching the spec's pass-through + # philosophy for anything outside its own tables. + assert compliance_level(name) is None + + +def test_tiers_are_disjoint() -> None: + assert not (REQUIRED_FUNCTIONS & RECOMMENDED_FUNCTIONS) + assert not (REQUIRED_FUNCTIONS & EXPERIMENTAL_FUNCTIONS) + assert not (RECOMMENDED_FUNCTIONS & EXPERIMENTAL_FUNCTIONS) + + +def test_all_names_are_upper_case() -> None: + for name in REQUIRED_FUNCTIONS | RECOMMENDED_FUNCTIONS | EXPERIMENTAL_FUNCTIONS: + assert name == name.upper() diff --git a/python/ossie-sql/tests/test_identifiers.py b/python/ossie-sql/tests/test_identifiers.py new file mode 100644 index 00000000..029a4807 --- /dev/null +++ b/python/ossie-sql/tests/test_identifiers.py @@ -0,0 +1,85 @@ +"""Tests for ossie_sql.identifiers, matching the spec's comparison table. + +You type this | Equivalent to | Matches a column created as `id`? +id | ID | Yes (standard behavior) +Id | ID | Yes (standard behavior) +"ID" | ID | Yes (force-matched to normalized case) +"id" | id | No (quotes force exact lower-case match) +""" + +from __future__ import annotations + +import pytest +import sqlglot + +import ossie_sql # noqa: F401 +from ossie_sql.identifiers import ( + InvalidIdentifierError, + identifiers_equal, + is_valid_identifier, + normalize_identifier, + normalize_identifier_text, +) + + +def _identifier(sql: str) -> sqlglot.exp.Identifier: + column = sqlglot.parse_one(sql, read="ossie") + assert isinstance(column, sqlglot.exp.Column) + ident = column.this + assert isinstance(ident, sqlglot.exp.Identifier) + return ident + + +@pytest.mark.parametrize( + "source,expected_normalized", + [ + ("id", "ID"), + ("Id", "ID"), + ('"ID"', "ID"), + ('"id"', "id"), + ], +) +def test_normalize_identifier_matches_spec_table(source: str, expected_normalized: str) -> None: + assert normalize_identifier(_identifier(source)) == expected_normalized + + +@pytest.mark.parametrize( + "a,b,expected_equal", + [ + ("id", "ID", True), + ("Id", "ID", True), + ('"ID"', "ID", True), + ('"id"', "ID", False), # quoted lower-case does NOT match column ID + ], +) +def test_identifiers_equal_matches_spec_table(a: str, b: str, expected_equal: bool) -> None: + assert identifiers_equal(_identifier(a), _identifier(b)) is expected_equal + + +def test_regular_identifier_must_start_with_a_letter() -> None: + assert is_valid_identifier("abc123", quoted=False) + assert not is_valid_identifier("123abc", quoted=False) + + +def test_regular_identifier_rejects_special_characters() -> None: + assert not is_valid_identifier("a-b", quoted=False) + assert not is_valid_identifier("a b", quoted=False) + + +def test_quoted_identifier_allows_arbitrary_text() -> None: + assert is_valid_identifier("a-b c!", quoted=True) + + +def test_identifier_length_limit_is_128() -> None: + ok = "a" * 128 + too_long = "a" * 129 + assert is_valid_identifier(ok, quoted=False) + assert not is_valid_identifier(too_long, quoted=False) + assert not is_valid_identifier(too_long, quoted=True) + + +def test_normalize_invalid_identifier_raises() -> None: + with pytest.raises(InvalidIdentifierError): + normalize_identifier_text("123abc", quoted=False) + with pytest.raises(InvalidIdentifierError): + normalize_identifier_text("", quoted=True) diff --git a/python/ossie-sql/tests/test_typed_literals.py b/python/ossie-sql/tests/test_typed_literals.py new file mode 100644 index 00000000..5e2ad617 --- /dev/null +++ b/python/ossie-sql/tests/test_typed_literals.py @@ -0,0 +1,51 @@ +"""Typed-literal and CAST/TRY_CAST construction tests. + +See the spec's "Date/Time Construction" and "Type Conversion Functions" +sections: typed literals (``DATE '...'``) and the equivalent ``CAST(...)`` +form are declared interchangeable, so this asserts they parse to +structurally equal ASTs, not just that each round-trips individually. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +import ossie_sql # noqa: F401 + + +@pytest.mark.parametrize( + "typed_literal,cast_form", + [ + ("DATE '2024-01-15'", "CAST('2024-01-15' AS DATE)"), + ("TIME '10:30:00'", "CAST('10:30:00' AS TIME)"), + ( + "TIMESTAMP_NTZ '2024-01-15 10:30:00'", + "CAST('2024-01-15 10:30:00' AS TIMESTAMP_NTZ)", + ), + ], +) +def test_typed_literal_equivalent_to_cast(typed_literal: str, cast_form: str) -> None: + a = sqlglot.parse_one(typed_literal, read="ossie") + b = sqlglot.parse_one(cast_form, read="ossie") + assert a == b + # Both spellings round-trip to the compact typed-literal form -- the + # spec's primary documented construction syntax for these types. + assert a.sql(dialect="ossie") == typed_literal + assert b.sql(dialect="ossie") == typed_literal + + +def test_try_cast_returns_null_on_failure_syntax() -> None: + parsed = sqlglot.parse_one("TRY_CAST(a AS INTEGER)", read="ossie") + assert isinstance(parsed, sqlglot.exp.TryCast) + # INTEGER / INT are spec-declared synonyms. + assert parsed.sql(dialect="ossie") == "TRY_CAST(a AS INT)" + + +@pytest.mark.parametrize( + "target_type", + ["VARCHAR", "INTEGER", "DECIMAL", "FLOAT", "BOOLEAN", "DATE", "TIMESTAMP", "TIME"], +) +def test_cast_supports_spec_target_types(target_type: str) -> None: + parsed = sqlglot.parse_one(f"CAST(a AS {target_type})", read="ossie") + assert isinstance(parsed, sqlglot.exp.Cast) diff --git a/python/ossie-sql/tests/test_validate.py b/python/ossie-sql/tests/test_validate.py new file mode 100644 index 00000000..7b7ce4c2 --- /dev/null +++ b/python/ossie-sql/tests/test_validate.py @@ -0,0 +1,67 @@ +"""Tests for ossie_sql.validate.validate_expression(). + +Covers the spec's "Not Supported in Expressions" table (SELECT/FROM/JOIN, +GROUP BY, WHERE, subqueries, CTEs, set operations, DDL/DML) plus the +pass-through-unknown-functions behavior from the "Dialect Extensions" +section. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +import ossie_sql # noqa: F401 +from ossie_sql.validate import UnsupportedConstructError, validate_expression + +VALID_EXPRESSIONS = [ + "SUM(x)", + "CASE WHEN a THEN 1 ELSE 0 END", + "x IN (1, 2, 3)", + "amount / SUM(amount) OVER () * 100", + "x BETWEEN a AND b", + # Vendor/unknown function name: passes through, per the spec's default. + "SOME_VENDOR_SPECIFIC_FUNC(x, y)", + "EXISTS_IN(x)", +] + + +@pytest.mark.parametrize("source", VALID_EXPRESSIONS) +def test_valid_expressions_pass(source: str) -> None: + parsed = sqlglot.parse_one(source, read="ossie") + validate_expression(parsed) # must not raise + + +DISALLOWED_EXPRESSIONS = [ + "SELECT * FROM t", + "x IN (SELECT id FROM t)", + "WITH cte AS (SELECT 1) SELECT * FROM cte", + "SELECT 1 UNION SELECT 2", + "SELECT a FROM t1 JOIN t2 ON t1.id = t2.id", +] + + +@pytest.mark.parametrize("source", DISALLOWED_EXPRESSIONS) +def test_disallowed_constructs_raise(source: str) -> None: + parsed = sqlglot.parse_one(source, read="ossie") + with pytest.raises(UnsupportedConstructError): + validate_expression(parsed) + + +def test_groups_frame_mode_rejected() -> None: + parsed = sqlglot.parse_one( + "SUM(x) OVER (ORDER BY d GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)", + read="ossie", + ) + with pytest.raises(UnsupportedConstructError): + validate_expression(parsed) + + +def test_rows_and_range_frame_modes_accepted() -> None: + for frame in [ + "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW", + "ROWS BETWEEN 6 PRECEDING AND CURRENT ROW", + "RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW", + ]: + parsed = sqlglot.parse_one(f"SUM(x) OVER (ORDER BY d {frame})", read="ossie") + validate_expression(parsed) # must not raise diff --git a/python/ossie/Makefile b/python/ossie/Makefile new file mode 100644 index 00000000..cb8fc5cd --- /dev/null +++ b/python/ossie/Makefile @@ -0,0 +1,10 @@ +.PHONY: format lint typecheck + +format: + uv run ruff format src + +lint: + uv run ruff check src + +typecheck: + uv run mypy --config-file ../../mypy.ini src diff --git a/python/ossie/pyproject.toml b/python/ossie/pyproject.toml new file mode 100644 index 00000000..54510599 --- /dev/null +++ b/python/ossie/pyproject.toml @@ -0,0 +1,51 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "apache-ossie" +version = "0.2.0.dev0" +description = "Python types for the Apache Ossie semantic model specification" +requires-python = ">=3.11" +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", +] +dependencies = [ + "pydantic>=2.0", + "PyYAML>=6.0", +] + +[project.license] +text = "Apache-2.0" + +[tool.hatch.build.targets.wheel] +packages = ["src/ossie"] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0", + "mypy>=1.10", + "ruff>=0.11", + "types-PyYAML", +] + +[tool.ruff] +extend = "../../ruff.toml" diff --git a/python/src/ossie/__init__.py b/python/ossie/src/ossie/__init__.py similarity index 100% rename from python/src/ossie/__init__.py rename to python/ossie/src/ossie/__init__.py diff --git a/python/src/ossie/models.py b/python/ossie/src/ossie/models.py similarity index 73% rename from python/src/ossie/models.py rename to python/ossie/src/ossie/models.py index 9be74a6c..7548efbf 100644 --- a/python/src/ossie/models.py +++ b/python/ossie/src/ossie/models.py @@ -15,14 +15,14 @@ # specific language governing permissions and limitations # under the License. -from enum import Enum -from typing import Any, Optional, Union +from enum import StrEnum +from typing import Any import yaml from pydantic import BaseModel, ConfigDict, Field -class OSIDialect(str, Enum): +class OSIDialect(StrEnum): """Supported SQL and expression language dialects.""" ANSI_SQL = "ANSI_SQL" @@ -34,7 +34,7 @@ class OSIDialect(str, Enum): BIGQUERY = "BIGQUERY" -class OSIVendor(str, Enum): +class OSIVendor(StrEnum): """Well-known vendor names for custom extensions.""" COMMON = "COMMON" @@ -52,12 +52,12 @@ class OSIAIContextObject(BaseModel): model_config = ConfigDict(frozen=True, extra="allow") - instructions: Optional[str] = None - synonyms: Optional[tuple[str, ...]] = None - examples: Optional[tuple[str, ...]] = None + instructions: str | None = None + synonyms: tuple[str, ...] | None = None + examples: tuple[str, ...] | None = None -OSIAIContext = Union[str, OSIAIContextObject] +OSIAIContext = str | OSIAIContextObject class OSICustomExtension(BaseModel): @@ -91,7 +91,7 @@ class OSIDimension(BaseModel): model_config = ConfigDict(frozen=True) - is_time: Optional[bool] = None + is_time: bool | None = None class OSIField(BaseModel): @@ -101,11 +101,11 @@ class OSIField(BaseModel): name: str expression: OSIExpression - dimension: Optional[OSIDimension] = None - label: Optional[str] = None - description: Optional[str] = None - ai_context: Optional[OSIAIContext] = None - custom_extensions: Optional[list[OSICustomExtension]] = None + dimension: OSIDimension | None = None + label: str | None = None + description: str | None = None + ai_context: OSIAIContext | None = None + custom_extensions: list[OSICustomExtension] | None = None class OSIDataset(BaseModel): @@ -115,12 +115,12 @@ class OSIDataset(BaseModel): name: str source: str - primary_key: Optional[list[str]] = None - unique_keys: Optional[list[list[str]]] = None - description: Optional[str] = None - ai_context: Optional[OSIAIContext] = None - fields: Optional[list[OSIField]] = None - custom_extensions: Optional[list[OSICustomExtension]] = None + primary_key: list[str] | None = None + unique_keys: list[list[str]] | None = None + description: str | None = None + ai_context: OSIAIContext | None = None + fields: list[OSIField] | None = None + custom_extensions: list[OSICustomExtension] | None = None class OSIRelationship(BaseModel): @@ -133,8 +133,8 @@ class OSIRelationship(BaseModel): to: str from_columns: list[str] to_columns: list[str] - ai_context: Optional[OSIAIContext] = None - custom_extensions: Optional[list[OSICustomExtension]] = None + ai_context: OSIAIContext | None = None + custom_extensions: list[OSICustomExtension] | None = None class OSIMetric(BaseModel): @@ -144,9 +144,9 @@ class OSIMetric(BaseModel): name: str expression: OSIExpression - description: Optional[str] = None - ai_context: Optional[OSIAIContext] = None - custom_extensions: Optional[list[OSICustomExtension]] = None + description: str | None = None + ai_context: OSIAIContext | None = None + custom_extensions: list[OSICustomExtension] | None = None class OSISemanticModel(BaseModel): @@ -155,12 +155,12 @@ class OSISemanticModel(BaseModel): model_config = ConfigDict(frozen=True) name: str - description: Optional[str] = None - ai_context: Optional[OSIAIContext] = None + description: str | None = None + ai_context: OSIAIContext | None = None datasets: list[OSIDataset] - relationships: Optional[list[OSIRelationship]] = None - metrics: Optional[list[OSIMetric]] = None - custom_extensions: Optional[list[OSICustomExtension]] = None + relationships: list[OSIRelationship] | None = None + metrics: list[OSIMetric] | None = None + custom_extensions: list[OSICustomExtension] | None = None class OSIDocument(BaseModel): @@ -169,8 +169,8 @@ class OSIDocument(BaseModel): model_config = ConfigDict(frozen=True) version: str = "0.2.0.dev0" - dialects: Optional[list[OSIDialect]] = None - vendors: Optional[list[OSIVendor]] = None + dialects: list[OSIDialect] | None = None + vendors: list[OSIVendor] | None = None semantic_model: list[OSISemanticModel] def to_osi_yaml(self, **kwargs: Any) -> str: diff --git a/python/pyproject.toml b/python/pyproject.toml index 7cc1326f..75d94d37 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -15,33 +15,8 @@ # specific language governing permissions and limitations # under the License. -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "apache-ossie" -version = "0.2.0.dev0" -description = "Python types for the Apache Ossie semantic model specification" -authors = [{ name = "Apache Software Foundation", email = "dev@ossie.apache.org" }] -requires-python = ">=3.11" -readme = "README.md" -license = "Apache-2.0" -keywords = [ - "Apache Ossie", - "Ossie", -] -dependencies = [ - "pydantic>=2.0", - "PyYAML>=6.0", -] - -[project.urls] -homepage = "https://ossie.apache.org/" -repository = "https://github.com/apache/ossie/" - -[tool.hatch.build.targets.wheel] -packages = ["src/ossie"] - -[tool.uv] -required-version = ">=0.9.0" +# uv workspace root: shares one dependency resolution/lockfile across the +# member packages below. This file is not itself a Python package -- see +# each member's own pyproject.toml for its package metadata. +[tool.uv.workspace] +members = ["ossie", "ossie-sql"] diff --git a/python/uv.lock b/python/uv.lock index 17d1769e..a1766e10 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,6 +1,16 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[manifest] +members = [ + "apache-ossie", + "apache-ossie-sql", +] [[package]] name = "annotated-types" @@ -14,18 +24,281 @@ wheels = [ [[package]] name = "apache-ossie" version = "0.2.0.dev0" -source = { editable = "." } +source = { editable = "ossie" } dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] + [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.11" }, + { name = "types-pyyaml" }, +] + +[[package]] +name = "apache-ossie-sql" +version = "0.1.0" +source = { editable = "ossie-sql" } +dependencies = [ + { name = "sqlglot" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "sqlglot", specifier = ">=25.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.11" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[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 = "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.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.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/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[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.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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 = "pydantic" version = "2.13.4" @@ -143,6 +416,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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 = "pyyaml" version = "6.0.3" @@ -198,6 +496,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "sqlglot" +version = "30.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ed/a6c45aec29353b6392ea34548c40af3ac6ffd6bc5572cf23b2ce250876fc/sqlglot-30.12.0.tar.gz", hash = "sha256:6b8369704662d4f654bc934cea4dd31c916c2a571b389210cb9e951a275e5fd9", size = 5905110, upload-time = "2026-06-26T14:09:40.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/9e/82a390ecc85f066ff80affa01d195f744e3de60ad4d695b8de31c9a66da3/sqlglot-30.12.0-py3-none-any.whl", hash = "sha256:86cccc610073c645c03e72b55b60ae0518aa3253a7fc3bd56551370d003c6554", size = 707583, upload-time = "2026-06-26T14:09:38.525Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..218a6f1d --- /dev/null +++ b/ruff.toml @@ -0,0 +1,20 @@ +# Shared ruff (lint + format) rules for this repo's Python packages. +# +# Individual packages opt in with: +# +# [tool.ruff] +# extend = "../../ruff.toml" # (relative path to this file) +# +# `extend` is ruff's native config-inheritance mechanism: the package's own +# `[tool.ruff]` settings are merged on top of this base (and can override +# it), rather than the usual "closest config wins outright" behavior. +line-length = 100 +target-version = "py311" + +[lint] +select = ["E", "F", "I", "N", "UP", "B", "A", "SIM"] + +[format] +# Explicit (not just relying on defaults): this is the formatting +# enforcement mechanism shared by every package that extends this file. +quote-style = "double" From fc4ef2f363491856b731a56596c71601006ce8df Mon Sep 17 00:00:00 2001 From: willpugh Date: Thu, 16 Jul 2026 23:51:25 -0700 Subject: [PATCH 2/5] Add headers --- python/ossie-sql/src/ossie_sql/__init__.py | 17 +++++++++++++++++ python/ossie-sql/src/ossie_sql/dialect.py | 17 +++++++++++++++++ python/ossie-sql/src/ossie_sql/functions.py | 17 +++++++++++++++++ python/ossie-sql/src/ossie_sql/identifiers.py | 17 +++++++++++++++++ python/ossie-sql/src/ossie_sql/validate.py | 17 +++++++++++++++++ python/ossie-sql/src/ossie_sql/windows.py | 17 +++++++++++++++++ python/ossie-sql/tests/conftest.py | 17 +++++++++++++++++ .../ossie-sql/tests/test_dialect_roundtrip.py | 17 +++++++++++++++++ python/ossie-sql/tests/test_functions.py | 17 +++++++++++++++++ python/ossie-sql/tests/test_identifiers.py | 17 +++++++++++++++++ python/ossie-sql/tests/test_typed_literals.py | 17 +++++++++++++++++ python/ossie-sql/tests/test_validate.py | 17 +++++++++++++++++ 12 files changed, 204 insertions(+) diff --git a/python/ossie-sql/src/ossie_sql/__init__.py b/python/ossie-sql/src/ossie_sql/__init__.py index 0b8ddec2..bb13f706 100644 --- a/python/ossie-sql/src/ossie_sql/__init__.py +++ b/python/ossie-sql/src/ossie_sql/__init__.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """SQLGlot dialect and validation helpers for the Ossie expression language. See ``core-spec/expression_language.md`` for the language spec this package diff --git a/python/ossie-sql/src/ossie_sql/dialect.py b/python/ossie-sql/src/ossie_sql/dialect.py index deb38c59..b659d3da 100644 --- a/python/ossie-sql/src/ossie_sql/dialect.py +++ b/python/ossie-sql/src/ossie_sql/dialect.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """SQLGlot dialect for the Ossie expression language. Implements the grammar defined in ``core-spec/expression_language.md`` diff --git a/python/ossie-sql/src/ossie_sql/functions.py b/python/ossie-sql/src/ossie_sql/functions.py index 771112f1..931b829d 100644 --- a/python/ossie-sql/src/ossie_sql/functions.py +++ b/python/ossie-sql/src/ossie_sql/functions.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Function-name compliance levels from ``core-spec/expression_language.md``. This module is purely informational (see the package README / project plan diff --git a/python/ossie-sql/src/ossie_sql/identifiers.py b/python/ossie-sql/src/ossie_sql/identifiers.py index a683447e..9768d021 100644 --- a/python/ossie-sql/src/ossie_sql/identifiers.py +++ b/python/ossie-sql/src/ossie_sql/identifiers.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Identifier normalization per the spec's "Namespacing and Identifier Resolution" section. diff --git a/python/ossie-sql/src/ossie_sql/validate.py b/python/ossie-sql/src/ossie_sql/validate.py index 6fb0a79b..e543dc36 100644 --- a/python/ossie-sql/src/ossie_sql/validate.py +++ b/python/ossie-sql/src/ossie_sql/validate.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Construct validation per the spec's "Not Supported in Expressions" table. Ossie expressions are scalar/aggregate/window fragments, not full queries. diff --git a/python/ossie-sql/src/ossie_sql/windows.py b/python/ossie-sql/src/ossie_sql/windows.py index 49c478a4..ccd1e45b 100644 --- a/python/ossie-sql/src/ossie_sql/windows.py +++ b/python/ossie-sql/src/ossie_sql/windows.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Window-function helpers matching the spec's "Window Functions" section. The spec documents exactly two frame modes -- ``ROWS`` and ``RANGE`` -- and diff --git a/python/ossie-sql/tests/conftest.py b/python/ossie-sql/tests/conftest.py index 5ffd42ab..4857ae18 100644 --- a/python/ossie-sql/tests/conftest.py +++ b/python/ossie-sql/tests/conftest.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Shared test fixtures. Importing ``ossie_sql`` registers the ``"ossie"`` dialect with SQLGlot (via diff --git a/python/ossie-sql/tests/test_dialect_roundtrip.py b/python/ossie-sql/tests/test_dialect_roundtrip.py index ebe84db4..f70ea196 100644 --- a/python/ossie-sql/tests/test_dialect_roundtrip.py +++ b/python/ossie-sql/tests/test_dialect_roundtrip.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Parse -> generate round-trip tests across the spec's construct categories. Each case is (input, expected_output) using the Ossie dialect for both diff --git a/python/ossie-sql/tests/test_functions.py b/python/ossie-sql/tests/test_functions.py index 1f205fcb..18a18aee 100644 --- a/python/ossie-sql/tests/test_functions.py +++ b/python/ossie-sql/tests/test_functions.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Tests for ossie_sql.functions.compliance_level().""" from __future__ import annotations diff --git a/python/ossie-sql/tests/test_identifiers.py b/python/ossie-sql/tests/test_identifiers.py index 029a4807..47fc4f51 100644 --- a/python/ossie-sql/tests/test_identifiers.py +++ b/python/ossie-sql/tests/test_identifiers.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Tests for ossie_sql.identifiers, matching the spec's comparison table. You type this | Equivalent to | Matches a column created as `id`? diff --git a/python/ossie-sql/tests/test_typed_literals.py b/python/ossie-sql/tests/test_typed_literals.py index 5e2ad617..8015f33f 100644 --- a/python/ossie-sql/tests/test_typed_literals.py +++ b/python/ossie-sql/tests/test_typed_literals.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Typed-literal and CAST/TRY_CAST construction tests. See the spec's "Date/Time Construction" and "Type Conversion Functions" diff --git a/python/ossie-sql/tests/test_validate.py b/python/ossie-sql/tests/test_validate.py index 7b7ce4c2..2c24a7cb 100644 --- a/python/ossie-sql/tests/test_validate.py +++ b/python/ossie-sql/tests/test_validate.py @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + """Tests for ossie_sql.validate.validate_expression(). Covers the spec's "Not Supported in Expressions" table (SELECT/FROM/JOIN, From c09d3b0d9a7cadfcf753efd7e7bff10ca597e9e2 Mon Sep 17 00:00:00 2001 From: willpugh Date: Thu, 16 Jul 2026 23:56:06 -0700 Subject: [PATCH 3/5] Add apache header to py.typed --- python/ossie-sql/src/ossie_sql/py.typed | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/python/ossie-sql/src/ossie_sql/py.typed b/python/ossie-sql/src/ossie_sql/py.typed index e69de29b..13a83393 100644 --- a/python/ossie-sql/src/ossie_sql/py.typed +++ b/python/ossie-sql/src/ossie_sql/py.typed @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. From dc68f750af2010efc87fdbf9fc78512eadd8d874 Mon Sep 17 00:00:00 2001 From: Will Pugh Date: Thu, 23 Jul 2026 00:38:58 -0700 Subject: [PATCH 4/5] Revert models modernization --- converters/dbt/pyproject.toml | 2 +- converters/dbt/uv.lock | 12 +++++- converters/wisdom/pyproject.toml | 2 +- python/ossie/pyproject.toml | 4 +- python/ossie/src/ossie/models.py | 64 ++++++++++++++++---------------- 5 files changed, 46 insertions(+), 38 deletions(-) diff --git a/converters/dbt/pyproject.toml b/converters/dbt/pyproject.toml index 12626d40..9bc855dc 100644 --- a/converters/dbt/pyproject.toml +++ b/converters/dbt/pyproject.toml @@ -68,4 +68,4 @@ default-groups = [ # apache-ossie is not yet published to PyPI; resolve it from the in-repo # package for now. Remove this block once apache-ossie published to PyPI. [tool.uv.sources] -apache-ossie = { path = "../../python", editable = true} +apache-ossie = { path = "../../python/ossie", editable = true} diff --git a/converters/dbt/uv.lock b/converters/dbt/uv.lock index 37eac1d0..78d8397e 100644 --- a/converters/dbt/uv.lock +++ b/converters/dbt/uv.lock @@ -14,7 +14,7 @@ wheels = [ [[package]] name = "apache-ossie" version = "0.2.0.dev0" -source = { editable = "../../python" } +source = { editable = "../../python/ossie" } dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, @@ -26,6 +26,14 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.11" }, + { name = "types-pyyaml" }, +] + [[package]] name = "apache-ossie-dbt" version = "0.2.0.dev0" @@ -46,7 +54,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "apache-ossie", editable = "../../python" }, + { name = "apache-ossie", editable = "../../python/ossie" }, { name = "jinja2", specifier = ">=3.0" }, { name = "metricflow", specifier = ">=0.209.0" }, { name = "pyyaml", specifier = ">=6.0" }, diff --git a/converters/wisdom/pyproject.toml b/converters/wisdom/pyproject.toml index 7af5c094..1d6c5e0d 100644 --- a/converters/wisdom/pyproject.toml +++ b/converters/wisdom/pyproject.toml @@ -64,4 +64,4 @@ default-groups = [ # apache-ossie is not yet published to PyPI; resolve it from the in-repo # package for now. Remove this block once apache-ossie published to PyPI. [tool.uv.sources] -apache-ossie = { path = "../../python", editable = true} +apache-ossie = { path = "../../python/ossie", editable = true} diff --git a/python/ossie/pyproject.toml b/python/ossie/pyproject.toml index 54510599..c1266f33 100644 --- a/python/ossie/pyproject.toml +++ b/python/ossie/pyproject.toml @@ -39,8 +39,8 @@ text = "Apache-2.0" [tool.hatch.build.targets.wheel] packages = ["src/ossie"] -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "pytest>=8.0", "mypy>=1.10", "ruff>=0.11", diff --git a/python/ossie/src/ossie/models.py b/python/ossie/src/ossie/models.py index 7548efbf..9be74a6c 100644 --- a/python/ossie/src/ossie/models.py +++ b/python/ossie/src/ossie/models.py @@ -15,14 +15,14 @@ # specific language governing permissions and limitations # under the License. -from enum import StrEnum -from typing import Any +from enum import Enum +from typing import Any, Optional, Union import yaml from pydantic import BaseModel, ConfigDict, Field -class OSIDialect(StrEnum): +class OSIDialect(str, Enum): """Supported SQL and expression language dialects.""" ANSI_SQL = "ANSI_SQL" @@ -34,7 +34,7 @@ class OSIDialect(StrEnum): BIGQUERY = "BIGQUERY" -class OSIVendor(StrEnum): +class OSIVendor(str, Enum): """Well-known vendor names for custom extensions.""" COMMON = "COMMON" @@ -52,12 +52,12 @@ class OSIAIContextObject(BaseModel): model_config = ConfigDict(frozen=True, extra="allow") - instructions: str | None = None - synonyms: tuple[str, ...] | None = None - examples: tuple[str, ...] | None = None + instructions: Optional[str] = None + synonyms: Optional[tuple[str, ...]] = None + examples: Optional[tuple[str, ...]] = None -OSIAIContext = str | OSIAIContextObject +OSIAIContext = Union[str, OSIAIContextObject] class OSICustomExtension(BaseModel): @@ -91,7 +91,7 @@ class OSIDimension(BaseModel): model_config = ConfigDict(frozen=True) - is_time: bool | None = None + is_time: Optional[bool] = None class OSIField(BaseModel): @@ -101,11 +101,11 @@ class OSIField(BaseModel): name: str expression: OSIExpression - dimension: OSIDimension | None = None - label: str | None = None - description: str | None = None - ai_context: OSIAIContext | None = None - custom_extensions: list[OSICustomExtension] | None = None + dimension: Optional[OSIDimension] = None + label: Optional[str] = None + description: Optional[str] = None + ai_context: Optional[OSIAIContext] = None + custom_extensions: Optional[list[OSICustomExtension]] = None class OSIDataset(BaseModel): @@ -115,12 +115,12 @@ class OSIDataset(BaseModel): name: str source: str - primary_key: list[str] | None = None - unique_keys: list[list[str]] | None = None - description: str | None = None - ai_context: OSIAIContext | None = None - fields: list[OSIField] | None = None - custom_extensions: list[OSICustomExtension] | None = None + primary_key: Optional[list[str]] = None + unique_keys: Optional[list[list[str]]] = None + description: Optional[str] = None + ai_context: Optional[OSIAIContext] = None + fields: Optional[list[OSIField]] = None + custom_extensions: Optional[list[OSICustomExtension]] = None class OSIRelationship(BaseModel): @@ -133,8 +133,8 @@ class OSIRelationship(BaseModel): to: str from_columns: list[str] to_columns: list[str] - ai_context: OSIAIContext | None = None - custom_extensions: list[OSICustomExtension] | None = None + ai_context: Optional[OSIAIContext] = None + custom_extensions: Optional[list[OSICustomExtension]] = None class OSIMetric(BaseModel): @@ -144,9 +144,9 @@ class OSIMetric(BaseModel): name: str expression: OSIExpression - description: str | None = None - ai_context: OSIAIContext | None = None - custom_extensions: list[OSICustomExtension] | None = None + description: Optional[str] = None + ai_context: Optional[OSIAIContext] = None + custom_extensions: Optional[list[OSICustomExtension]] = None class OSISemanticModel(BaseModel): @@ -155,12 +155,12 @@ class OSISemanticModel(BaseModel): model_config = ConfigDict(frozen=True) name: str - description: str | None = None - ai_context: OSIAIContext | None = None + description: Optional[str] = None + ai_context: Optional[OSIAIContext] = None datasets: list[OSIDataset] - relationships: list[OSIRelationship] | None = None - metrics: list[OSIMetric] | None = None - custom_extensions: list[OSICustomExtension] | None = None + relationships: Optional[list[OSIRelationship]] = None + metrics: Optional[list[OSIMetric]] = None + custom_extensions: Optional[list[OSICustomExtension]] = None class OSIDocument(BaseModel): @@ -169,8 +169,8 @@ class OSIDocument(BaseModel): model_config = ConfigDict(frozen=True) version: str = "0.2.0.dev0" - dialects: list[OSIDialect] | None = None - vendors: list[OSIVendor] | None = None + dialects: Optional[list[OSIDialect]] = None + vendors: Optional[list[OSIVendor]] = None semantic_model: list[OSISemanticModel] def to_osi_yaml(self, **kwargs: Any) -> str: From 311194cadb1c69d377a04177c44a2ab82dd69588 Mon Sep 17 00:00:00 2001 From: Will Pugh Date: Fri, 24 Jul 2026 07:42:29 -0700 Subject: [PATCH 5/5] Add the closed algebra package (ossie.algebra) with spec-review hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the closed algebra — the compiler's correctness core — from the foundation reference implementation (apache/ossie#125) into the ossie package, with the foundation types it needs (common/{identifiers,sql_expr,types}, errors), a hypothesis property/law + closure test suite, and the enforcement kit (mypy --strict, import-linter closure contract, file-size audit, mutmut config). Ships an AGENTS.md alongside the algebra with the directory-scoped closure rules (pointing to ARCHITECTURE.md). Reviewed against the foundational semantics spec (apache/ossie#246) and folded the fixes in: - The algebra's internal fan-out signal is E4001_EXPLOSION_UNSAFE, not the spec-reserved engine-wide opt-out E3011; the planner maps it to the user-facing code (E_UNSAFE_REAGGREGATION / E_FAN_OUT_IN_SCALAR_QUERY). - Holistic re-aggregation is blocked only over a *discharged* aggregate (new Column.is_discharged_aggregate), so a single-step holistic over a plain N:1 enriched dimension stays allowed per D-020. - ARCHITECTURE.md documents the algebra as necessary-not-sufficient for Semantic 4 (the planner owns chasm/stitch decomposition). - Doc references repointed to core-spec/foundational_semantics.md (Appendix A; #246 section numbering). 31 tests green; mypy --strict clean; import-linter contract kept. Co-Authored-By: Claude Opus 4.8 --- python/ossie/.pre-commit-config.yaml | 39 ++ python/ossie/Makefile | 34 +- python/ossie/pyproject.toml | 31 + python/ossie/src/ossie/algebra/AGENTS.md | 60 ++ .../ossie/src/ossie/algebra/ARCHITECTURE.md | 143 +++++ python/ossie/src/ossie/algebra/__init__.py | 133 ++++ python/ossie/src/ossie/algebra/composition.py | 147 +++++ python/ossie/src/ossie/algebra/grain.py | 285 +++++++++ python/ossie/src/ossie/algebra/joins.py | 156 +++++ python/ossie/src/ossie/algebra/operations.py | 589 ++++++++++++++++++ python/ossie/src/ossie/algebra/state.py | 302 +++++++++ python/ossie/src/ossie/common/__init__.py | 51 ++ python/ossie/src/ossie/common/identifiers.py | 98 +++ python/ossie/src/ossie/common/sql_expr.py | 122 ++++ python/ossie/src/ossie/common/types.py | 90 +++ python/ossie/src/ossie/errors.py | 354 +++++++++++ python/ossie/src/ossie/py.typed | 16 + python/ossie/tests/conftest.py | 23 + python/ossie/tests/strategies.py | 308 +++++++++ .../ossie/tests/test_aggregate_idempotent.py | 50 ++ python/ossie/tests/test_algebra_closure.py | 90 +++ .../ossie/tests/test_algebra_determinism.py | 50 ++ python/ossie/tests/test_algebra_purity.py | 91 +++ python/ossie/tests/test_algebra_totality.py | 95 +++ python/ossie/tests/test_enrich_operator.py | 114 ++++ python/ossie/tests/test_error_taxonomy.py | 73 +++ python/ossie/tests/test_filter_commute.py | 54 ++ .../ossie/tests/test_frozensql_canonical.py | 100 +++ python/ossie/tests/test_grain_closure.py | 117 ++++ python/ossie/tests/test_merge_associative.py | 77 +++ python/ossie/tests/test_project_idempotent.py | 71 +++ .../ossie/tests/test_reaggregation_safety.py | 131 ++++ python/uv.lock | 551 +++++++++++++++- 33 files changed, 4641 insertions(+), 4 deletions(-) create mode 100644 python/ossie/.pre-commit-config.yaml create mode 100644 python/ossie/src/ossie/algebra/AGENTS.md create mode 100644 python/ossie/src/ossie/algebra/ARCHITECTURE.md create mode 100644 python/ossie/src/ossie/algebra/__init__.py create mode 100644 python/ossie/src/ossie/algebra/composition.py create mode 100644 python/ossie/src/ossie/algebra/grain.py create mode 100644 python/ossie/src/ossie/algebra/joins.py create mode 100644 python/ossie/src/ossie/algebra/operations.py create mode 100644 python/ossie/src/ossie/algebra/state.py create mode 100644 python/ossie/src/ossie/common/__init__.py create mode 100644 python/ossie/src/ossie/common/identifiers.py create mode 100644 python/ossie/src/ossie/common/sql_expr.py create mode 100644 python/ossie/src/ossie/common/types.py create mode 100644 python/ossie/src/ossie/errors.py create mode 100644 python/ossie/src/ossie/py.typed create mode 100644 python/ossie/tests/conftest.py create mode 100644 python/ossie/tests/strategies.py create mode 100644 python/ossie/tests/test_aggregate_idempotent.py create mode 100644 python/ossie/tests/test_algebra_closure.py create mode 100644 python/ossie/tests/test_algebra_determinism.py create mode 100644 python/ossie/tests/test_algebra_purity.py create mode 100644 python/ossie/tests/test_algebra_totality.py create mode 100644 python/ossie/tests/test_enrich_operator.py create mode 100644 python/ossie/tests/test_error_taxonomy.py create mode 100644 python/ossie/tests/test_filter_commute.py create mode 100644 python/ossie/tests/test_frozensql_canonical.py create mode 100644 python/ossie/tests/test_grain_closure.py create mode 100644 python/ossie/tests/test_merge_associative.py create mode 100644 python/ossie/tests/test_project_idempotent.py create mode 100644 python/ossie/tests/test_reaggregation_safety.py diff --git a/python/ossie/.pre-commit-config.yaml b/python/ossie/.pre-commit-config.yaml new file mode 100644 index 00000000..e9cbe63b --- /dev/null +++ b/python/ossie/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + - repo: local + hooks: + - id: ossie-ruff-check + name: ossie ruff check + entry: bash -c 'cd python/ossie && uv run ruff check --fix src tests' + language: system + files: ^python/ossie/.*\.py$ + pass_filenames: false + - id: ossie-ruff-format + name: ossie ruff format + entry: bash -c 'cd python/ossie && uv run ruff format src tests' + language: system + files: ^python/ossie/.*\.py$ + pass_filenames: false + - id: ossie-mypy + name: ossie mypy (strict, src) + entry: bash -c 'cd python/ossie && uv run mypy --config-file ../../mypy.ini src' + language: system + files: ^python/ossie/.*\.py$ + pass_filenames: false + - id: ossie-lint-imports + name: ossie import-linter (algebra closure) + entry: bash -c 'cd python/ossie && uv run lint-imports' + language: system + files: ^python/ossie/.*\.py$ + pass_filenames: false + - id: ossie-audit-file-size + name: ossie module size audit + entry: bash -c 'cd python/ossie && make audit-file-size' + language: system + files: ^python/ossie/.*\.py$ + pass_filenames: false + - id: ossie-pytest + name: ossie pytest (algebra suite) + entry: bash -c 'cd python/ossie && uv run pytest -q' + language: system + files: ^python/ossie/.*\.py$ + pass_filenames: false diff --git a/python/ossie/Makefile b/python/ossie/Makefile index cb8fc5cd..29f61858 100644 --- a/python/ossie/Makefile +++ b/python/ossie/Makefile @@ -1,10 +1,38 @@ -.PHONY: format lint typecheck +.PHONY: format lint typecheck test lint-imports audit-file-size mutation check format: - uv run ruff format src + uv run ruff format src tests lint: - uv run ruff check src + uv run ruff check src tests +# Strict typing is enforced on the implementation (src). The property-test +# suite is intentionally dynamic (hypothesis), so tests are not strict-typed. typecheck: uv run mypy --config-file ../../mypy.ini src + +test: + uv run pytest + +# Closed-algebra layering guard: the algebra may not import higher layers. +# Contract lives in pyproject.toml ([tool.importlinter]). +lint-imports: + uv run lint-imports + +# Keep modules small enough to review. The closed algebra is deliberately +# compact; a module crossing the cap should be split, not exempted. +audit-file-size: + @bad=$$(find src/ossie -name '*.py' -exec wc -l {} + \ + | awk '$$2 != "total" && $$1 > 700 {print}'); \ + if [ -n "$$bad" ]; then \ + echo "ERROR: modules over 700 lines:"; echo "$$bad"; exit 1; \ + else echo "OK: every module <= 700 lines"; fi + +# Mutation testing of the correctness core. Slow — opt-in, NOT run in +# pre-commit. Target: >= 90% killed over src/ossie/algebra (see +# src/ossie/algebra/ARCHITECTURE.md). +mutation: + uv run mutmut run + +# Everything a PR must pass (mutation excluded — run it separately). +check: lint typecheck lint-imports audit-file-size test diff --git a/python/ossie/pyproject.toml b/python/ossie/pyproject.toml index c1266f33..972d7d08 100644 --- a/python/ossie/pyproject.toml +++ b/python/ossie/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0", "PyYAML>=6.0", + "sqlglot>=25.0", ] [project.license] @@ -45,7 +46,37 @@ dev = [ "mypy>=1.10", "ruff>=0.11", "types-PyYAML", + "hypothesis>=6.0", + "import-linter>=2.0", + "mutmut>=3.0", ] [tool.ruff] extend = "../../ruff.toml" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +# Closed-algebra layering guard (run: `make lint-imports`). The algebra is +# the lowest compiler layer; it may depend only on ossie.common / ossie.errors +# (plus stdlib + sqlglot), never on higher layers. See +# src/ossie/algebra/ARCHITECTURE.md. +[tool.importlinter] +root_package = "ossie" + +[[tool.importlinter.contracts]] +name = "algebra is a closed lower layer" +type = "forbidden" +source_modules = ["ossie.algebra"] +forbidden_modules = [ + "ossie.models", + "ossie.parsing", + "ossie.planning", + "ossie.codegen", +] + +# Mutation testing of the correctness core (run: `make mutation`). Opt-in and +# slow, so it is not part of pre-commit. Target: >= 90% killed. +[tool.mutmut] +paths_to_mutate = ["src/ossie/algebra/"] diff --git a/python/ossie/src/ossie/algebra/AGENTS.md b/python/ossie/src/ossie/algebra/AGENTS.md new file mode 100644 index 00000000..2e259359 --- /dev/null +++ b/python/ossie/src/ossie/algebra/AGENTS.md @@ -0,0 +1,60 @@ + + +# AGENTS.md — `ossie.algebra` + +Rules for editing the **closed algebra** (this directory). The full contract — +the operator table, the state invariants, and how each rule is enforced — is in +[`ARCHITECTURE.md`](ARCHITECTURE.md); read it before changing an operator. The +normative semantics live in `core-spec/foundational_semantics.md` §6. + +## Hard rules + +1. **Compose the nine operators** — `source`, `filter_`, `enrich`, `aggregate`, + `project` (`operations.py`); `add_columns`, `broadcast` (`composition.py`); + `merge`, `filtering_join` (`joins.py`). Add capability by composing them. + Adding a **tenth** operator is a reviewed spec change: a new allow-list entry + in `../../../tests/test_algebra_closure.py`, a full set of property tests, and + an `ARCHITECTURE.md` update. +2. **Never construct `CalculationState` (or `Column`) by hand** outside the + operator modules `operations.py` / `joins.py` / `composition.py`. Callers get + states by composing operators. Enforced by `test_algebra_closure.py`. +3. **Immutable · pure · total · deterministic.** State/columns are + `frozen=True, slots=True`; operators return fresh values with no mutation, + I/O, time, or randomness, and either return a valid state or raise. +4. **Typed errors only.** Raise `AlgebraError(ErrorCode..., ...)` from + `ossie.errors` — never `assert`, a bare `Exception`, or a string. Use the + `E4xxx` algebra-safety family for algebra-only failures; do **not** reuse the + spec-reserved `E3011` (the engine-wide M:N opt-out). +5. **No upward imports.** Import only `ossie.common`, `ossie.errors`, the stdlib, + and `sqlglot`. Never `ossie.models` / `parsing` / `planning` / `codegen`. + Enforced by `import-linter`. + +The algebra is **necessary, not sufficient** for the spec: the planner owns plan +shape — default join direction, chasm/stitch decomposition, and Semantic 4 +beyond the local holistic guard. See `ARCHITECTURE.md` → "Scope". + +## Verify (from `python/ossie`) + +```bash +make test # pytest: property + law + closure tests +make lint-imports # import-linter closure contract +make typecheck # mypy --strict on src +make mutation # mutmut >= 90% over the algebra (slow; opt-in) +``` diff --git a/python/ossie/src/ossie/algebra/ARCHITECTURE.md b/python/ossie/src/ossie/algebra/ARCHITECTURE.md new file mode 100644 index 00000000..54dc7ede --- /dev/null +++ b/python/ossie/src/ossie/algebra/ARCHITECTURE.md @@ -0,0 +1,143 @@ + + +# `ossie.algebra` — the closed algebra + +The algebra is the **correctness boundary** of the Ossie reference compiler. A +query is compiled by *composing operators from this package* into a +`CalculationState`; every higher layer (parsing, planning, codegen) is defined +in terms of these operators and this state, and no other way. + +Normative semantics live in +[`core-spec/foundational_semantics.md`](../../../../../core-spec/foundational_semantics.md) +§6. This document is the **engineering contract** for the package: what "closed" +means here, and how it is mechanically enforced. + +## Why "closed" + +The whole point of an algebra is that a *small, fixed* set of operators over a +*single* immutable value type can express every transformation — and that its +properties (grain only coarsens, no fact row is dropped or double-counted, the +same input yields byte-identical output) can be proven once and then relied on +everywhere. New capability is added by **composing existing operators**, not by +adding operators or by hand-building state. That is what keeps the correctness +guarantees total. + +## The nine operators + +The single carrier value is `CalculationState` (grain + columns + provenance + +unique keys). Every operator takes states/values and returns a **new** state. + +| Operator | Module | Role | +|----------|--------|------| +| `source` | `operations.py` | initialize a state from a dataset (sets PK + grain) | +| `filter_` | `operations.py` | apply a row-level predicate (grain unchanged) | +| `enrich` | `operations.py` | N:1 join; preserves parent grain, adds columns | +| `aggregate` | `operations.py` | coarsen to a target grain; add aggregate columns | +| `project` | `operations.py` | keep only named columns (grain preserved) | +| `add_columns` | `composition.py` | derived scalar columns (composite metrics) | +| `merge` | `joins.py` | full-outer, chasm-safe stitch at matching grain | +| `filtering_join` | `joins.py` | semi-/anti-join (**experimental**, flag-gated) | +| `broadcast` | `composition.py` | attach a scalar column (**reserved**) | + +`grain.py` provides the pure grain *simulation* (`simulate`, `combine_grains`, +`is_coarser`, the `Step` tags) used to reason about a plan's grain without +materializing states. + +## State invariants + +`CalculationState.__post_init__` validates these eagerly and raises +`AlgebraError` (never returns a bad state): + +- **I-1** `grain ⊆ {c.name for c in columns if c.kind is DIMENSION}` +- **I-2** column names are unique +- **I-5** `grain == ∅` ⇔ scalar (exactly one row) +- **I-6** `column.dependencies ⊆ {names of other columns}` +- **I-8** `provenance` grows only through operators serving a requested expression +- **I-9** every `unique_keys` set is non-empty and a subset of the dimension names + +## The rules (and how each is enforced) + +1. **Compose operators — never construct state by hand.** `CalculationState` + is built only inside the operator modules (`operations.py`, `joins.py`, + `composition.py`). Enforced mechanically by + [`tests/test_algebra_closure.py`](../../../tests/test_algebra_closure.py) + (AST scan of the whole `ossie` tree). +2. **Immutability / purity.** States and columns are `@dataclass(frozen=True, + slots=True)`; operators return fresh values and have no side effects (no I/O, + time, or randomness). Enforced by `tests/test_algebra_purity.py`. +3. **Totality.** Operators are total over valid inputs: they either return a + valid state or raise a typed `AlgebraError`. Enforced by + `tests/test_algebra_totality.py`. +4. **Determinism.** The same inputs produce byte-identical results (foundation + decision **D-014**). Enforced by `tests/test_algebra_determinism.py`. +5. **Grain closure.** Grain only *coarsens*, and only through `aggregate`. + Enforced by `tests/test_grain_closure.py`. +6. **Typed errors only.** Failures raise `AlgebraError` with an + `ErrorCode` — never `assert`, never a bare `Exception`. See + [`ossie/errors.py`](../errors.py). +7. **No upward imports.** The algebra imports only `ossie.common`, + `ossie.errors`, the stdlib, and `sqlglot`. Enforced by `import-linter` + (`make lint-imports`; contract in `pyproject.toml`). + +## Scope: necessary, not sufficient + +The algebra enforces the *local* safety of each operator application; it is **not** +the whole implementation of the spec's guarantees. Several §6 semantics are owned +by other layers, and a valid algebra composition does not by itself make a query +spec-correct: + +- **Semantic 1 (no fact row dropped) and the default join direction.** The algebra + records an `enrich` `join_type` but does not choose it; emitting `LEFT` + fact→dim (§6.6) and the NULL-bucket behaviour are the planner's and codegen's + job. +- **Semantic 4 (no unsafe re-aggregation) is only *partially* guarded here.** The + algebra rejects the clearest unsafe case — a holistic aggregate over a + discharged join-RHS value (`E4001`). It does **not** implement the full + contract: whether an *algebraic* aggregate (AVG, STDDEV) survives a §6.7 chasm + pre-aggregation or a §6.8.2 stitch, and the resulting `E_UNSAFE_REAGGREGATION` + verdict, are decided by the **planner** when it chooses the plan shape. The + algebra has no multi-stage (SUM/COUNT-tracking) decomposition machinery. +- **Runtime/codegen semantics** — NULL-safe stitch equality (§6.6 row 3), + empty/NULL aggregate results (§6.11), window evaluation and placement (§6.10) — + are outside the algebra entirely. + +In short: the algebra makes *illegal states unrepresentable* and *local fan-out +unsafe*; the planner composes operators into a plan that satisfies the remaining +§6 guarantees, and codegen renders it. Reviewers should not read a green algebra +as a proof of end-to-end spec conformance — that is what the compliance suite is +for. + +## How to extend + +- **Add an aggregate function / dialect behaviour / metric shape:** express it + by *composing* the existing operators. This is almost always the answer. +- **Change an operator's contract:** update the operator, its preconditions, its + laws in the property tests, and this document together — in one change. +- **Add a tenth operator:** this is a deliberate, reviewed spec change. It + requires a new entry in `test_algebra_closure.py`'s allow-list, a full set of + property tests (purity, totality, determinism, grain), and a spec update. Do + not do it to work around a planner limitation. + +## Testing bar + +The algebra is held to a **mutation score ≥ 90%** (`make mutation`, `mutmut` +over `src/ossie/algebra`). A surviving mutant here is a P0: it means a line of +the correctness core is unprotected by a test. Property tests use `hypothesis`; +see `tests/strategies.py` for the state generators. diff --git a/python/ossie/src/ossie/algebra/__init__.py b/python/ossie/src/ossie/algebra/__init__.py new file mode 100644 index 00000000..8d2154ec --- /dev/null +++ b/python/ossie/src/ossie/algebra/__init__.py @@ -0,0 +1,133 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""The closed algebra — the correctness boundary of the compiler. + +All compiler transformations must compose operators from this module. +See ``ARCHITECTURE.md`` in this package for the closure contract and the +state invariants, and ``core-spec/foundational_semantics.md`` §6 (the +authoritative spec) for operator semantics, grain contracts, and laws. + +The nine operators and their current planner wiring: + +- :func:`source` — initialize from a dataset. *Emitted for every leaf.* +- :func:`filter_` — apply a row-level predicate (underscored; ``filter`` + is a Python builtin). *Emitted for row-level ``WHERE`` and for + ``HAVING``.* +- :func:`enrich` — N:1 join that preserves parent grain. *Emitted for + every safe single-hop or multi-hop enrichment step.* +- :func:`aggregate` — coarsen to a target grain. *Emitted once per + measure group.* +- :func:`project` — keep only the named columns. *Emitted once at the + root.* +- :func:`add_columns` — introduce derived scalar columns. *Emitted only + for composite metrics — arithmetic combinations of already-defined + metrics. (Spec: §4.5 — Metrics, rule 2.)* +- :func:`merge` — full-outer chasm-safe join at matching grain. + *Emitted when two measure groups with different fact datasets must + be combined via the stitch plan. (Spec: §6.8.2 — stitch.)* +- :func:`filtering_join` — semi-/anti-semi-join. **Experimental.** + Emitted for ``EXISTS_IN`` / ``NOT EXISTS_IN`` predicates in + ``WHERE`` when the caller opts in via + ``FoundationFlags(experimental_exists_in=True)``. Foundation v0.1 + §10 / D-017 lists semi-join filtering as deferred, so the default + Foundation parser rejects ``EXISTS_IN`` with + ``E_DEFERRED_KEY_REJECTED``. The operator and its laws are kept + in the closed algebra so the experimental codepath remains + testable; turning the flag off does not remove this operator from + the package, only from the planner's emission path. +- :func:`broadcast` — attach a scalar column. **Reserved.** The + operator and its ``BROADCAST`` plan step exist so the algebra stays + closed under nine operators, but today's planner never emits it — + scalar-per-row calculations go through composite metrics. The + operator's ``E4004_BROADCAST_NOT_SCALAR`` precondition is still + covered by unit tests so a future sprint can turn on the planner + path without destabilising the algebra. + +Mutation-score target: ≥ 90% (see ``ARCHITECTURE.md`` in this package). A +surviving mutation in this module is a P0. +""" + +from ossie.algebra.composition import add_columns, broadcast +from ossie.algebra.grain import ( + AggregateStep, + BroadcastStep, + EnrichStep, + GrainSimulationError, + MergeStep, + OperatorTag, + SimpleStep, + SimState, + SourceStep, + Step, + combine_grains, + is_coarser, + simulate, + simulate_grain, +) +from ossie.algebra.joins import filtering_join, merge +from ossie.algebra.operations import ( + FilterMode, + JoinType, + aggregate, + enrich, + filter_, + project, + source, +) +from ossie.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + Decomposability, +) + +__all__ = [ + "AggregateFunction", + "AggregateInfo", + "AggregateStep", + "BroadcastStep", + "CalculationState", + "Column", + "ColumnKind", + "Decomposability", + "EnrichStep", + "FilterMode", + "GrainSimulationError", + "JoinType", + "MergeStep", + "OperatorTag", + "SimState", + "SimpleStep", + "SourceStep", + "Step", + "add_columns", + "aggregate", + "broadcast", + "combine_grains", + "enrich", + "filter_", + "filtering_join", + "is_coarser", + "merge", + "project", + "simulate", + "simulate_grain", + "source", +] diff --git a/python/ossie/src/ossie/algebra/composition.py b/python/ossie/src/ossie/algebra/composition.py new file mode 100644 index 00000000..ead16256 --- /dev/null +++ b/python/ossie/src/ossie/algebra/composition.py @@ -0,0 +1,147 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Scalar-composition operators: ``add_columns`` and ``broadcast``. + +Extracted from :mod:`ossie.algebra.operations` to keep the +per-file line budget (``ARCHITECTURE.md``, 600 lines). Both operators +preserve grain and only add columns, so they are grouped together here: + +* ``add_columns`` — derive new scalar columns from existing ones + (composite metrics: ratios of aggregates, etc.). +* ``broadcast`` — attach a scalar (grain-``frozenset()``) state as + a column on every row of a non-scalar state. + +Mutation-score target: **≥ 90%** (same as ``operations.py``). A +surviving mutation in these functions is a P0 bug. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import replace + +from ossie.algebra.state import CalculationState, Column, ColumnKind +from ossie.common.identifiers import Identifier +from ossie.errors import AlgebraError, ErrorCode + + +def add_columns( + state: CalculationState, definitions: Sequence[Column] +) -> CalculationState: + """Introduce derived scalar columns (no aggregation). + + In the Foundation this operator is wired to exactly one planner + path — **composite metrics** (``core-spec/foundational_semantics.md §4.5``). + A measure group that contains composite metrics (e.g. ratios of + two aggregates) is lowered to an ``AGGREGATE`` step over the base + aggregates followed by one ``ADD_COLUMNS`` step per composite. + + Preconditions + ------------- + * every definition's ``kind`` is ``DIMENSION`` or ``FACT`` (no + aggregates — see :func:`aggregate` for those, or emit the + aggregate step first and reference it from here) + * every definition's ``dependencies`` are known to ``state`` + * new column names do not collide with existing names + + Grain effect: **preserved**. UKs are preserved (the operator + only adds columns; existing keys remain keys). + """ + existing = state.column_names + new_names: set[Identifier] = set() + for col in definitions: + if col.kind is ColumnKind.AGGREGATE: + raise AlgebraError( + ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT, + f"add_columns cannot introduce AGGREGATE column {col.name!r}", + context={"column": col.name}, + ) + unknown = col.dependencies - (existing | new_names) + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"add_columns definition {col.name!r} depends on unknown " + f"columns: {sorted(unknown)}", + context={"column": col.name, "missing": sorted(unknown)}, + ) + if col.name in existing or col.name in new_names: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"add_columns definition {col.name!r} collides with existing " "column", + context={"column": col.name}, + ) + new_names.add(col.name) + return CalculationState( + grain=state.grain, + columns=state.columns + tuple(definitions), + provenance=state.provenance, + unique_keys=state.unique_keys, + ) + + +def broadcast(state: CalculationState, scalar: CalculationState) -> CalculationState: + """Attach a single scalar value (grain-``frozenset()`` state) as a column. + + **Planner status: reserved.** ``broadcast`` is part of the closed + algebra so scalar-per-row attach semantics have a stable operator, + but today's planner + never emits a :attr:`~ossie.planning.plan.PlanOperation.BROADCAST` + step. Percent-of-total style calculations go through composite + metrics + :func:`add_columns` instead. This function, and its + ``E4004_BROADCAST_NOT_SCALAR`` precondition, remain so a future + sprint can turn it on without a SPEC change — and so the algebra + stays closed under the nine declared operators. + + Preconditions + ------------- + * ``scalar.is_scalar`` (``grain == frozenset()``) + * ``scalar`` has exactly one column + * that column's name does not already exist in ``state`` + + Grain effect: **preserved**. UKs are preserved (the operator + only adds a single column; existing keys remain keys). + """ + if not scalar.is_scalar: + raise AlgebraError( + ErrorCode.E4004_BROADCAST_NOT_SCALAR, + "broadcast requires a scalar state (grain == frozenset())", + context={"scalar_grain": sorted(scalar.grain)}, + ) + if len(scalar.columns) != 1: + raise AlgebraError( + ErrorCode.E4004_BROADCAST_NOT_SCALAR, + "broadcast requires exactly one scalar column", + context={"columns": [c.name for c in scalar.columns]}, + ) + new_col = scalar.columns[0] + if new_col.name in state.column_names: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"broadcast column {new_col.name!r} collides with existing column", + context={"column": new_col.name}, + ) + tagged = replace(new_col, is_single_valued=True) + return CalculationState( + grain=state.grain, + columns=state.columns + (tagged,), + provenance=state.provenance | scalar.provenance, + unique_keys=state.unique_keys, + ) + + +__all__ = ["add_columns", "broadcast"] diff --git a/python/ossie/src/ossie/algebra/grain.py b/python/ossie/src/ossie/algebra/grain.py new file mode 100644 index 00000000..44028df6 --- /dev/null +++ b/python/ossie/src/ossie/algebra/grain.py @@ -0,0 +1,285 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Symbolic grain helpers used by tests and diagnostics. + +**Audience.** This module is internal to the algebra package: the +property tests in ``tests/properties/test_grain_closure.py`` use +:func:`simulate` / :func:`simulate_grain` to compare symbolic +computation against the concrete algebra, and +``ossie.diagnostics.explain`` uses :func:`combine_grains` to summarise +plan steps. External callers (adapters, alternative planners) should +construct real :class:`CalculationState` instances and use the +algebra operators rather than reaching into the simulator. The +``__all__`` at the bottom of this module is the supported surface. + +Per ``ARCHITECTURE.md`` +the resulting grain of any operator chain is a pure function of the +argument sequence. This module exposes that function without needing +to construct real states — the property test ``test_grain_closure.py`` +uses it to compare symbolic computation to the concrete algebra. + +The simulator tracks **two** pieces of grain-relevant state: + +* ``grain`` — the dimensions that uniquely identify a row in the + current state. This matches ``CalculationState.grain``. +* ``single_valued`` — extra columns proven single-valued over the + current grain (typically dimensions brought in via N:1 ``enrich`` or + scalars attached via ``broadcast``). ``aggregate`` may coarsen *to* + any subset of ``grain ∪ single_valued``; tracking the second set + lets the simulator accept the hot star-schema path of + *enrich-then-aggregate-by-RHS-dim* without falsely rejecting it. + +Promoting ``single_valued`` to first-class state in the simulator +mirrors the per-column ``Column.is_single_valued`` flag in the +concrete algebra (see :mod:`ossie.algebra.state`). When the +Foundation grows grain operations or filter-context manipulation, the +extra book-keeping is already there for them to use. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum, auto + +from ossie.common.identifiers import Identifier +from ossie.common.types import DimensionSet +from ossie.errors import ErrorCode, OSIError + + +class OperatorTag(StrEnum): + """Enum identifying which operator a step represents. + + Kept as an enum (not the callable) so a grain simulation can run + purely over data without importing :mod:`operations`. + """ + + SOURCE = auto() + FILTER = auto() + ENRICH = auto() + AGGREGATE = auto() + PROJECT = auto() + ADD_COLUMNS = auto() + MERGE = auto() + FILTERING_JOIN = auto() + BROADCAST = auto() + + +@dataclass(frozen=True, slots=True) +class SourceStep: + """Start a chain from a dataset primary key.""" + + tag: OperatorTag + primary_key: DimensionSet + + +@dataclass(frozen=True, slots=True) +class AggregateStep: + """Coarsen to ``target_grain``.""" + + tag: OperatorTag + target_grain: DimensionSet + + +@dataclass(frozen=True, slots=True) +class MergeStep: + """Merge with another chain; both sides must share grain.""" + + tag: OperatorTag + right_grain: DimensionSet + + +@dataclass(frozen=True, slots=True) +class EnrichStep: + """N:1 enrich; preserves grain and extends single-valued vocabulary. + + ``adds`` is the set of RHS column names that flow in. They become + valid coarsening targets for a subsequent ``aggregate`` because an + N:1 join makes them single-valued over the parent grain. + """ + + tag: OperatorTag + adds: frozenset[Identifier] = field(default_factory=frozenset) + + +@dataclass(frozen=True, slots=True) +class BroadcastStep: + """Attach a scalar; preserves grain, extends single-valued vocabulary.""" + + tag: OperatorTag + adds: Identifier + + +@dataclass(frozen=True, slots=True) +class SimpleStep: + """A grain-preserving operator with no single-valued vocabulary effect. + + Covers ``filter``, ``project``, ``add_columns``, and + ``filtering_join`` — all of which preserve the row multiset's grain + and do not extend the set of columns the next ``aggregate`` may + coarsen to. + """ + + tag: OperatorTag + + +Step = SourceStep | AggregateStep | MergeStep | EnrichStep | BroadcastStep | SimpleStep + + +@dataclass(frozen=True, slots=True) +class SimState: + """First-class symbolic shadow of :class:`CalculationState`. + + Only carries grain-relevant information (no expressions, kinds, or + provenance). Constructing one outside this module is unusual; use + :func:`simulate` to build one from a step sequence. + """ + + grain: DimensionSet + single_valued: frozenset[Identifier] = field(default_factory=frozenset) + + +_PRESERVING_TAGS: frozenset[OperatorTag] = frozenset( + { + OperatorTag.FILTER, + OperatorTag.PROJECT, + OperatorTag.ADD_COLUMNS, + OperatorTag.FILTERING_JOIN, + } +) + + +class GrainSimulationError(OSIError): + """Raised when a step sequence is malformed (e.g. no leading SOURCE). + + Carries :data:`ErrorCode.E_INTERNAL_INVARIANT` because the failure + means the symbolic simulator was driven with a malformed input — + the real compiler flows always start with ``source`` and stitch + operators in valid order. Keeping the simulator inside the typed + ``OSIError`` hierarchy means the "every failure carries a code" + invariant the architecture tests rely on holds for this path too. + """ + + def __init__(self, message: str) -> None: + super().__init__(ErrorCode.E_INTERNAL_INVARIANT, message) + + +def simulate(steps: tuple[Step, ...]) -> SimState: + """Compute the resulting :class:`SimState` of a step sequence. + + Carries both ``grain`` and ``single_valued``. Use this when you + care about which columns can serve as ``aggregate`` targets; + use :func:`simulate_grain` when only the grain matters. + """ + if not steps: + raise GrainSimulationError("step sequence is empty") + if not isinstance(steps[0], SourceStep): + raise GrainSimulationError( + f"first step must be SOURCE, got {type(steps[0]).__name__}" + ) + state = SimState(grain=steps[0].primary_key, single_valued=frozenset()) + for step in steps[1:]: + state = _step(step, state) + return state + + +def simulate_grain(steps: tuple[Step, ...]) -> DimensionSet: + """Compute the resulting grain of a step sequence. + + Backward-compatible wrapper around :func:`simulate` for callers + that only care about the grain dimension set. + """ + return simulate(steps).grain + + +def _step(step: Step, current: SimState) -> SimState: + if isinstance(step, SourceStep): + raise GrainSimulationError("SOURCE may only appear as the first step") + if isinstance(step, AggregateStep): + # Aggregate can coarsen to any subset of (grain ∪ single_valued). + permitted = current.grain | current.single_valued + if not step.target_grain.issubset(permitted): + raise GrainSimulationError( + f"aggregate target {sorted(step.target_grain)} is not a " + f"subset of grain ∪ single_valued {sorted(permitted)}" + ) + # After aggregation the single-valued extras are gone — anything + # not in the new grain is no longer addressable. + return SimState(grain=step.target_grain, single_valued=frozenset()) + if isinstance(step, MergeStep): + if step.right_grain != current.grain: + raise GrainSimulationError( + f"merge grains disagree: left={sorted(current.grain)} " + f"right={sorted(step.right_grain)}" + ) + # Merge preserves grain. The right side's single-valued extras + # would need to be modeled if callers cared; the Foundation + # leaves merge unchanged here (mirrors §3.7 of the algebra). + return current + if isinstance(step, EnrichStep): + # Enrich preserves grain and adds new single-valued columns. + return SimState( + grain=current.grain, + single_valued=current.single_valued | step.adds, + ) + if isinstance(step, BroadcastStep): + return SimState( + grain=current.grain, + single_valued=current.single_valued | frozenset({step.adds}), + ) + # SimpleStep — preservation family only (filter/project/etc). + if step.tag not in _PRESERVING_TAGS: + raise GrainSimulationError( + f"simple step with tag {step.tag!r} is invalid; use a " + f"dedicated step type for {step.tag.name.lower()}" + ) + return current + + +def is_coarser(child: DimensionSet, parent: DimensionSet) -> bool: + """Return ``True`` iff ``child`` grain is (weakly) coarser than ``parent``. + + ``coarser`` means "fewer dimensions"; by set-subset that is + ``child ⊆ parent``. + """ + return child.issubset(parent) + + +def combine_grains(*grains: frozenset[Identifier]) -> DimensionSet: + """Union of several grains. Used by the reference interpreter.""" + result: set[Identifier] = set() + for g in grains: + result.update(g) + return frozenset(result) + + +__all__ = [ + "AggregateStep", + "BroadcastStep", + "EnrichStep", + "GrainSimulationError", + "MergeStep", + "OperatorTag", + "SimState", + "SimpleStep", + "SourceStep", + "Step", + "combine_grains", + "is_coarser", + "simulate", + "simulate_grain", +] diff --git a/python/ossie/src/ossie/algebra/joins.py b/python/ossie/src/ossie/algebra/joins.py new file mode 100644 index 00000000..1c70603b --- /dev/null +++ b/python/ossie/src/ossie/algebra/joins.py @@ -0,0 +1,156 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Grain-matching join operators: ``merge`` and ``filtering_join``. + +Extracted from :mod:`ossie.algebra.operations` to keep the +per-file line budget (``ARCHITECTURE.md``, 600 lines). Everything here is +still part of the closed algebra and shares the same contract: + +* pure, total, deterministic; +* never mutates its inputs; +* raises :class:`AlgebraError` with a stable :class:`ErrorCode` on any + precondition failure. + +Mutation-score target: **≥ 90%** (same as ``operations.py``). A +surviving mutation in these functions is a P0 bug. +""" + +from __future__ import annotations + +from ossie.algebra.operations import FilterMode +from ossie.algebra.state import CalculationState +from ossie.common.types import DimensionSet +from ossie.errors import AlgebraError, ErrorCode + + +def merge( + left: CalculationState, + right: CalculationState, + *, + on: DimensionSet | None = None, +) -> CalculationState: + """FULL OUTER join at matching grain (chasm-trap resolution). + + Preconditions + ------------- + * ``left.grain == right.grain`` + * if ``on`` is given, ``on == left.grain`` (the algebra spec + ``ARCHITECTURE.md`` defines merging on the shared + grain — other joins must go through ``enrich`` or + ``filtering_join``) + * non-grain columns of the two sides are disjoint + + Grain effect: **preserved** (both sides share it). + """ + if left.grain != right.grain: + raise AlgebraError( + ErrorCode.E3008_GRAIN_MISMATCH_MERGE, + "merge requires equal grains", + context={ + "left_grain": sorted(left.grain), + "right_grain": sorted(right.grain), + }, + ) + if on is not None and on != left.grain: + raise AlgebraError( + ErrorCode.E3008_GRAIN_MISMATCH_MERGE, + "merge `on` argument must equal the shared grain", + context={ + "on": sorted(on), + "grain": sorted(left.grain), + }, + ) + left_nongrain = {c.name for c in left.columns if c.name not in left.grain} + right_nongrain = {c.name for c in right.columns if c.name not in right.grain} + overlap = left_nongrain & right_nongrain + if overlap: + raise AlgebraError( + ErrorCode.E4003_MERGE_COLUMN_OVERLAP, + f"merge cannot overlap non-grain columns: {sorted(overlap)}", + context={"columns": sorted(overlap)}, + ) + grain_columns = tuple(c for c in left.columns if c.name in left.grain) + left_extras = tuple(c for c in left.columns if c.name not in left.grain) + right_extras = tuple(c for c in right.columns if c.name not in right.grain) + return CalculationState( + grain=left.grain, + columns=grain_columns + left_extras + right_extras, + provenance=left.provenance | right.provenance, + # FULL OUTER on the shared grain may introduce rows that exist + # on only one side. A UK that held on the left may not hold on + # the right's-only rows (and vice versa), so only the + # intersection of the two UK sets is provably safe post-merge. + unique_keys=left.unique_keys & right.unique_keys, + ) + + +def filtering_join( + state: CalculationState, + rhs: CalculationState, + *, + lhs_keys: DimensionSet, + rhs_keys: DimensionSet, + mode: FilterMode, +) -> CalculationState: + """Semi-join (``SEMI``) or anti-semi-join (``ANTI``). + + Used for ``EXISTS_IN`` / ``NOT EXISTS_IN``. No columns are added — + that is the defining difference from + :func:`ossie.algebra.enrich`. + + Preconditions + ------------- + * ``lhs_keys ⊆ state.column_names`` + * ``rhs_keys ⊆ rhs.column_names`` + * ``len(lhs_keys) == len(rhs_keys)`` (composite-key join needs a + matched arity; the plan step records the pairing) + """ + if len(lhs_keys) != len(rhs_keys): + raise AlgebraError( + ErrorCode.E4005_FILTERING_JOIN_ADDS_COLUMNS, + "filtering_join requires matching key arity", + context={ + "lhs_arity": len(lhs_keys), + "rhs_arity": len(rhs_keys), + }, + ) + missing_lhs = lhs_keys - state.column_names + if missing_lhs: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"filtering_join lhs_keys missing: {sorted(missing_lhs)}", + context={"missing": sorted(missing_lhs), "side": "lhs"}, + ) + missing_rhs = rhs_keys - rhs.column_names + if missing_rhs: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"filtering_join rhs_keys missing: {sorted(missing_rhs)}", + context={"missing": sorted(missing_rhs), "side": "rhs"}, + ) + if mode not in FilterMode: + raise AlgebraError( + ErrorCode.E4005_FILTERING_JOIN_ADDS_COLUMNS, + f"unknown filtering_join mode: {mode!r}", + context={"mode": str(mode)}, + ) + _ = rhs # rhs is only used for key presence checks; no columns flow + return state + + +__all__ = ["filtering_join", "merge"] diff --git a/python/ossie/src/ossie/algebra/operations.py b/python/ossie/src/ossie/algebra/operations.py new file mode 100644 index 00000000..5a4d3957 --- /dev/null +++ b/python/ossie/src/ossie/algebra/operations.py @@ -0,0 +1,589 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Five of the nine operators of the closed algebra. + +This module hosts ``source``, ``filter_``, ``enrich``, ``aggregate``, +and ``project``. The two grain-matching joins — ``merge`` and +``filtering_join`` — live in :mod:`ossie.algebra.joins`; the +two scalar-composition operators — ``add_columns`` and ``broadcast`` +— live in :mod:`ossie.algebra.composition`. The split keeps +each file inside the 600-line per-file budget (``ARCHITECTURE.md``); +all nine operators share the same contract and are re-exported +through :mod:`ossie.algebra`. + +Every compiler transformation is expressed as a composition of these +nine operators. Adding a tenth is a SPEC change (see +``ARCHITECTURE.md``). + +Mutation-score target: **≥ 90%** for this module (``ARCHITECTURE.md``). A +surviving mutation here is a P0 bug — it means at least one property or +unit test is weaker than it looks. + +Convention: every operator returns a *new* :class:`CalculationState`; +the input is never mutated. Preconditions are checked before any work; +failures raise :class:`AlgebraError` with a specific :class:`ErrorCode` +(``E3xxx`` for shape/grain contract violations, ``E4xxx`` for +algebra-only safety failures — see :class:`AlgebraError`). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import replace +from enum import StrEnum, auto + +from ossie.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + Decomposability, +) +from ossie.common.identifiers import Identifier +from ossie.common.sql_expr import FrozenSQL +from ossie.common.types import DimensionSet +from ossie.errors import AlgebraError, ErrorCode + + +class JoinType(StrEnum): + """Foundation join types for :func:`enrich`. + + Only inner and left outer are supported; right/full live outside the + Foundation and would need a SPEC update + decision-log entry. + """ + + INNER = auto() + LEFT = auto() + + +class FilterMode(StrEnum): + """Mode for :func:`filtering_join` (semi-join / anti-semi-join). + + Lives next to :class:`JoinType` because both are join-shape enums + consumed by the same operator family. The actual ``filtering_join`` + operator is defined in :mod:`ossie.algebra.joins` to keep + that file's surface area cohesive (semi-joins, anti-semi-joins, + and the helpers they share). + """ + + SEMI = auto() + ANTI = auto() + + +# --------------------------------------------------------------------------- +# source +# --------------------------------------------------------------------------- + + +def source( + primary_key: DimensionSet, + dimension_columns: Sequence[Column], + fact_columns: Sequence[Column] = (), + *, + unique_keys: Sequence[DimensionSet] = (), +) -> CalculationState: + """Initialize a state from a dataset's declared columns. + + Parsing is responsible for declaring the primary key and the field + roles; this operator is the sole entry point into the algebra. + + Preconditions + ------------- + * ``primary_key`` is non-empty + * every name in ``primary_key`` is a dimension column + * all provided columns have kind ``DIMENSION`` or ``FACT`` (not + ``AGGREGATE`` — aggregates are produced by :func:`aggregate`) + * provided column names are unique + * every set in ``unique_keys`` is non-empty and references + dimension columns (validated by ``CalculationState.__post_init__`` + under I-9). Each UK is an *alternative* minimum key at the + dataset grain, used by :meth:`CalculationState.is_unique_on` + when a downstream :func:`enrich` joins on a column that is + unique-but-not-the-PK. ``core-spec/foundational_semantics.md §4.2`` and + ``§6.1`` mandate symmetric treatment of PK and UKs. + """ + if not primary_key: + raise AlgebraError( + ErrorCode.E2007_MISSING_PRIMARY_KEY, + "source requires a non-empty primary_key", + ) + columns = tuple(dimension_columns) + tuple(fact_columns) + names = [c.name for c in columns] + if len(names) != len(set(names)): + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + "source received duplicate column names", + context={"columns": names}, + ) + for col in columns: + if col.kind is ColumnKind.AGGREGATE: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"source column {col.name!r} cannot be AGGREGATE", + context={"column": col.name}, + ) + dim_names = {c.name for c in dimension_columns if c.kind is ColumnKind.DIMENSION} + missing_pk = primary_key - dim_names + if missing_pk: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "primary_key references non-dimension columns", + context={"missing": sorted(missing_pk)}, + ) + # Every column declared on a dataset is trivially single-valued over + # the dataset's primary key (each row has exactly one value). This + # flag lets the aggregator later group by any of these columns + # without re-proving functional dependency. + tagged = tuple(replace(c, is_single_valued=True) for c in columns) + return CalculationState( + grain=primary_key, + columns=tagged, + unique_keys=frozenset(frozenset(uk) for uk in unique_keys), + ) + + +# --------------------------------------------------------------------------- +# filter +# --------------------------------------------------------------------------- + + +def filter_( + state: CalculationState, + predicate: FrozenSQL, + *, + dependencies: frozenset[Identifier] = frozenset(), +) -> CalculationState: + """Validate a row-level predicate against ``state``. + + The Foundation algebra keeps predicates *off* the + :class:`CalculationState` (``ARCHITECTURE.md``); a predicate + is metadata of the enclosing :class:`PlanStep`, not of the + relational shape. ``filter_`` is therefore intentionally an + **identity on the state**: it returns ``state`` itself after + proving that every column the predicate reads is addressable. + + The function is still part of the closed algebra because: + + * its precondition (``dependencies ⊆ state.column_names``) is the + same kind of safety check the other operators enforce, and + * having a callable here lets plan composition walk the same + operator-application protocol for every step (uniformly + "construct → check → return new state-or-same-state"). + + Preconditions + ------------- + * ``dependencies ⊆ state.column_names`` + + Grain effect: **preserved** (filtering removes rows, not dimensions). + Columns effect: **preserved structurally** (the predicate lives on + the plan step, not on the returned state). + + Notes + ----- + Renamed to ``filter_`` because ``filter`` is a Python builtin. + Re-exported at the package level as ``filter_`` only; users must + use the module-qualified name. + """ + unknown = dependencies - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"filter predicate depends on unknown columns: {sorted(unknown)}", + context={"missing": sorted(unknown)}, + ) + _ = predicate # retained for caller introspection, not stored + return state + + +# --------------------------------------------------------------------------- +# enrich +# --------------------------------------------------------------------------- + + +def enrich( + parent: CalculationState, + child: CalculationState, + *, + parent_keys: tuple[Identifier, ...], + child_keys: tuple[Identifier, ...], + join_type: JoinType, + drop_child_columns: frozenset[Identifier] = frozenset(), +) -> CalculationState: + """N:1 join — bring the one-side's columns into the many-side state. + + The contract is symmetric with :func:`merge` and + :func:`filtering_join`: both sides are full + :class:`CalculationState` values. The algebra derives fan-out + safety from grain, *not* from a caller-asserted boolean. + + Preconditions + ------------- + * ``parent_keys`` and ``child_keys`` have the same arity (they are + the positional pairing of the equi-join condition) + * every name in ``parent_keys`` is addressable on ``parent`` + * every name in ``child_keys`` is addressable on ``child`` + * ``child.is_unique_on(child_keys)`` — i.e. ``child`` is *unique + on the join keys*. This is the **fan-trap rule**: if ``child`` + can have multiple rows per join key, joining duplicates + ``parent`` rows and silently destroys ``parent.grain``. The + check delegates to + :meth:`CalculationState.is_unique_on`, which accepts any + ``child_keys`` set that is a superset of either the child's + grain or any declared :attr:`unique_key`. ``core-spec/foundational_semantics.md + §6.1`` mandates this symmetric treatment so authors can recover + from a wider-than-necessary PK declaration with an explicit UK + on the join column. Failures raise + :attr:`ErrorCode.E4001_EXPLOSION_UNSAFE` — the algebra's internal + fan-out safety signal (``E4xxx`` family). The planner maps this + signal to the user-facing code the query shape calls for: + :attr:`ErrorCode.E_UNSAFE_REAGGREGATION` for a 1:N fan-trap in an + aggregation query (Appendix A / D-022), or + :attr:`ErrorCode.E_FAN_OUT_IN_SCALAR_QUERY` for a scalar query + (D-023). Declared N:N edges are normally rejected earlier in the + planner's path resolution with + :attr:`ErrorCode.E3012_MN_NO_SAFE_REWRITE` or + :attr:`ErrorCode.E3013_NO_STITCHING_DIMENSION`. + :attr:`ErrorCode.E3011_MN_AGGREGATION_REJECTED` is reserved for + the engine-wide "no M:N support" opt-out (§6.8) and is never + raised by the algebra. + * no child column (after the optional ``drop_child_columns`` + reduction) collides with a parent column + * no child column is an aggregate (aggregates are built by + :func:`aggregate` after the join, not before) + + ``drop_child_columns`` lets the planner skip child columns that are + redundant — typically the child-side join keys when they share a + name with the parent-side keys. The Foundation algebra does not + rename: collisions outside this drop set surface as + :attr:`ErrorCode.E3005_COLUMN_NAME_COLLISION`. + + Grain effect: **preserved** (``parent.grain``). + """ + if len(parent_keys) != len(child_keys): + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich requires parent_keys and child_keys to have the same arity", + context={ + "parent_keys": list(parent_keys), + "child_keys": list(child_keys), + }, + ) + if not parent_keys: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich requires at least one join key pair", + ) + missing_parent = frozenset(parent_keys) - parent.column_names + if missing_parent: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich parent_keys are not addressable columns on parent", + context={ + "missing": sorted(missing_parent), + "parent_columns": sorted(parent.column_names), + }, + ) + missing_child = frozenset(child_keys) - child.column_names + if missing_child: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich child_keys are not addressable columns on child", + context={ + "missing": sorted(missing_child), + "child_columns": sorted(child.column_names), + }, + ) + # Fan-trap rule: child must be unique on the join keys. Accepts + # either the grain (PK) OR any declared UK as proof of uniqueness + # — see CalculationState.is_unique_on for the symmetry rationale. + child_key_set = frozenset(child_keys) + if not child.is_unique_on(child_key_set): + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + "enrich would fan out: child is not unique on the join keys " + f"(child.grain={sorted(child.grain)}, " + f"child.unique_keys={sorted(map(sorted, child.unique_keys))}, " + f"child_keys={sorted(child_key_set)}). " + "Aggregate the child to the join key before enriching, declare " + "a unique_key covering the join column, or use the relationship " + "in the opposite direction.", + context={ + "child_grain": sorted(child.grain), + "child_unique_keys": sorted(map(sorted, child.unique_keys)), + "child_keys": sorted(child_key_set), + }, + ) + # AGGREGATE columns from the child are surfaced as scalar values on + # the parent only when the join cannot fan out the parent — i.e. + # ``child`` is unique on ``child_keys``. The fan-trap check above + # already guarantees that condition. The result column is reclassified + # as ``FACT`` because the aggregation has been discharged at the + # child's grain; downstream aggregates can re-aggregate it explicitly + # (the bridge-resolution mid-pipeline plan, ``§6.8.1``, depends on + # this). Without the join uniqueness, the original rejection still + # applies — see the fan-trap check above which raises before we + # reach this point. + incoming_raw = tuple(c for c in child.columns if c.name not in drop_child_columns) + incoming = tuple( + ( + replace( + c, + kind=ColumnKind.FACT, + aggregate=None, + dependencies=frozenset(), + is_single_valued=True, + # Mark it discharged so a later holistic aggregate over it is + # rejected (Semantic 4). Plain enriched dims (the ``else`` + # branch) are NOT marked — a single-step holistic over them is + # well-defined per D-020. + is_discharged_aggregate=True, + ) + if c.kind is ColumnKind.AGGREGATE + else c + ) + for c in incoming_raw + ) + parent_names = parent.column_names + overlap = {c.name for c in incoming} & parent_names + if overlap: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"enrich child columns collide with parent: {sorted(overlap)}. " + "Rename the colliding fields in the model, or drop them from the " + "child via drop_child_columns when bringing them in.", + context={"columns": sorted(overlap)}, + ) + _ = join_type # recorded by the plan step; here for type clarity + tagged_children = tuple( + replace(col, from_join_rhs=True, is_single_valued=True) for col in incoming + ) + return CalculationState( + grain=parent.grain, + columns=parent.columns + tagged_children, + provenance=parent.provenance | child.provenance, + # Grain preserved → parent's UKs still hold. The child's UKs + # describe the child's grain, which is gone from the post-enrich + # state, so we drop them. + unique_keys=parent.unique_keys, + ) + + +# --------------------------------------------------------------------------- +# aggregate +# --------------------------------------------------------------------------- + + +def aggregate( + state: CalculationState, + new_grain: DimensionSet, + aggregations: Sequence[Column], +) -> CalculationState: + """Reduce to a coarser grain, emitting one aggregate column per aggregation. + + Preconditions + ------------- + * every ``new_grain`` name is a ``DIMENSION`` column of ``state`` + * every ``new_grain`` column is **either** a member of + ``state.grain`` **or** single-valued over it (introduced by + :func:`enrich` on the one-side, or by :func:`broadcast`). This is + the spec's "grain coarsening" rule generalised to handle the + common case of aggregating by a dimension brought in through an + N:1 join (see the evaluation algorithm in + ``core-spec/foundational_semantics.md §6.2``). + * every aggregation column has kind ``AGGREGATE`` with populated + ``AggregateInfo`` + * every aggregation's dependency set is a subset of ``state.column_names`` + * if an aggregation reads a **discharged** aggregate column (a value + already reduced at a finer grain and brought in by :func:`enrich` — + ``is_discharged_aggregate``), it is not a ``HOLISTIC`` aggregate + (``COUNT DISTINCT`` etc.); re-aggregating a discharged value + holistically is unsafe (``E4001 EXPLOSION_UNSAFE``). A holistic over a + *plain* enriched dimension is allowed — it is a single-step aggregate + over the non-fanned joined rows, well-defined per D-020. + """ + dimension_names = {c.name for c in state.columns if c.kind is ColumnKind.DIMENSION} + unknown = new_grain - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "aggregate new_grain references unknown columns", + context={"new_grain": sorted(new_grain), "missing": sorted(unknown)}, + ) + non_dim = new_grain - dimension_names + if non_dim: + raise AlgebraError( + ErrorCode.E3004_GRAIN_NOT_SUBSET, + "aggregate new_grain includes non-dimension columns", + context={"non_dimension": sorted(non_dim)}, + ) + # Every new_grain member must be single-valued over ``state.grain``. + # Membership in ``state.grain`` counts trivially; otherwise the column + # must have been tagged ``is_single_valued`` when introduced (source + # dimensions, enrich RHS columns, broadcast scalars). + for name in new_grain: + col = state.column(name) + if name not in state.grain and not col.is_single_valued: + raise AlgebraError( + ErrorCode.E3004_GRAIN_NOT_SUBSET, + f"aggregate cannot group by {name!r}: not in state.grain and " + "not known to be single-valued over it", + context={ + "column": name, + "state_grain": sorted(state.grain), + }, + ) + for agg in aggregations: + if agg.kind is not ColumnKind.AGGREGATE or agg.aggregate is None: + raise AlgebraError( + ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT, + f"aggregate received non-AGGREGATE column {agg.name!r}", + context={"column": agg.name, "kind": agg.kind}, + ) + unknown = agg.dependencies - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"aggregation {agg.name!r} depends on unknown columns: " + f"{sorted(unknown)}", + context={"column": agg.name, "missing": sorted(unknown)}, + ) + if agg.aggregate.function.decomposability is Decomposability.HOLISTIC: + for dep in agg.dependencies: + src = state.column(dep) + if src.is_discharged_aggregate: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"holistic aggregation {agg.name!r} reads discharged " + f"(already-aggregated) column {dep!r}; a holistic " + "aggregate cannot be re-applied to a pre-aggregated " + "value — aggregate at the finer grain first", + context={"column": agg.name, "source": dep}, + ) + + dim_by_name = {c.name: c for c in state.columns if c.kind is ColumnKind.DIMENSION} + kept_dims = tuple(dim_by_name[name] for name in sorted(new_grain)) + agg_names = {a.name for a in aggregations} + overlap = agg_names & new_grain + if overlap: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"aggregation names collide with grain dimensions: {sorted(overlap)}", + context={"columns": sorted(overlap)}, + ) + # After aggregation, each output aggregate column is a fresh scalar + # at ``new_grain``. Its input-side dependencies are no longer + # addressable in the output state, so strip them to satisfy I-3 + # (every dep must resolve in the current state). Mark it + # single-valued: an aggregate is by definition one value per grain + # key. + sealed_aggregations = tuple( + replace(a, dependencies=frozenset(), is_single_valued=True) + for a in aggregations + ) + return CalculationState( + grain=new_grain, + columns=kept_dims + sealed_aggregations, + provenance=state.provenance, + # A UK that is a subset of new_grain remains an alternative + # minimum key after aggregation (each new-grain row contains + # exactly one UK value, and distinct UK values stayed distinct + # because the UK was distinct at the old grain). UKs that + # straddle out of new_grain are dropped — proving they remain + # unique would require re-deriving functional dependencies the + # algebra does not track. + unique_keys=frozenset(uk for uk in state.unique_keys if uk.issubset(new_grain)), + ) + + +# --------------------------------------------------------------------------- +# project +# --------------------------------------------------------------------------- + + +def project(state: CalculationState, columns: Sequence[Identifier]) -> CalculationState: + """Keep only ``columns``, in the order given. + + Preconditions + ------------- + * ``columns ⊆ state.column_names`` + * ``state.grain ⊆ columns`` (dropping grain dimensions is forbidden — + that would violate I-1) + """ + requested = tuple(columns) + requested_set = set(requested) + unknown = requested_set - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"project references unknown columns: {sorted(unknown)}", + context={"missing": sorted(unknown)}, + ) + missing_grain = state.grain - requested_set + if missing_grain: + raise AlgebraError( + ErrorCode.E3004_GRAIN_NOT_SUBSET, + f"project would drop grain dimensions: {sorted(missing_grain)}", + context={"missing": sorted(missing_grain)}, + ) + if len(requested_set) != len(requested): + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + "project received duplicate column names", + context={"columns": list(requested)}, + ) + by_name = {c.name: c for c in state.columns} + retained_names = frozenset(requested) + # After projection, retained columns stand alone: any + # ``dependencies`` that were pruned by the project become + # unresolvable in the output state (they name columns no longer + # present). Stripping them is semantically safe — a post-project + # column is materialised and no longer a lazy view over its + # inputs, same way AGGREGATE seals its outputs. Preserving deps + # would force every callers to also retain every transitive input, + # defeating the point of PROJECT. + retained_columns = tuple( + replace( + by_name[n], + dependencies=by_name[n].dependencies & retained_names, + ) + for n in requested + ) + return CalculationState( + grain=state.grain, + columns=retained_columns, + provenance=state.provenance, + # Grain preserved (project may not drop grain dims), so any UK + # whose columns survived projection is still a valid key. + unique_keys=frozenset( + uk for uk in state.unique_keys if uk.issubset(retained_names) + ), + ) + + +__all__ = [ + "AggregateFunction", + "AggregateInfo", + "FilterMode", + "JoinType", + "aggregate", + "enrich", + "filter_", + "project", + "source", +] diff --git a/python/ossie/src/ossie/algebra/state.py b/python/ossie/src/ossie/algebra/state.py new file mode 100644 index 00000000..80ace447 --- /dev/null +++ b/python/ossie/src/ossie/algebra/state.py @@ -0,0 +1,302 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Immutable value types that flow through the closed algebra. + +See ``ARCHITECTURE.md`` for +the normative contract. Nothing in this file imports from +``ossie.parsing`` or ``ossie.codegen``; those layers see algebra values but +never construct them directly. Construction happens only through +:func:`ossie.algebra.operations.source` (and its downstream +operator chain). + +Invariants (see ``ARCHITECTURE.md``): + +* **I-1** ``grain ⊆ {c.name for c in columns if c.kind is DIMENSION}`` +* **I-2** column names in ``columns`` are unique +* **I-5** ``grain == frozenset()`` implies scalar (one row) +* **I-6** ``column.dependencies ⊆ {other.name for other in columns}`` +* **I-8** ``provenance`` grows only through operators that serve a + requested expression +* **I-9** every set in ``unique_keys`` is non-empty and a subset of + the dimension column names; ``unique_keys`` are *alternative* + minimum keys at the current grain (the grain itself is always a + key — see :meth:`CalculationState.is_unique_on`) + +Violations are always raised as :class:`AlgebraError` (``E4xxx``) from +the operator that produced the state, never silently tolerated. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum, auto +from typing import TYPE_CHECKING + +from ossie.common.identifiers import Identifier +from ossie.common.sql_expr import FrozenSQL +from ossie.common.types import DimensionSet, ExpressionId +from ossie.errors import AlgebraError, ErrorCode + +if TYPE_CHECKING: + # Only used for type hints to avoid runtime dependency cycles. + pass + + +class ColumnKind(StrEnum): + """Classification of a column inside a :class:`CalculationState`. + + Drives the algebra's safety checks (``aggregate`` may only introduce + ``AGGREGATE`` columns, ``filter`` may not read one, etc.). + + .. note:: + + The Foundation deliberately does **not** distinguish a + ``TIME_DIMENSION`` from a plain :attr:`DIMENSION`. The + parser-level :class:`~ossie.parsing.models.FieldKind` does (see + ``parsing/models.py``), but the *algebra* only needs to know + whether a column groups (``DIMENSION``), aggregates + (``AGGREGATE``), or carries a per-row value (``FACT``). Time + semantics — period comparisons, rolling windows, snapshot + grains — are deferred features (see `core-spec/foundational_semantics.md §10`) and have + no algebra-level consequences in this slice. When that + changes, add ``TIME_DIMENSION`` here *and* update every + branch on :class:`ColumnKind` to handle it explicitly; do not + silently let it pattern-match as ``DIMENSION``. + """ + + DIMENSION = "dimension" + FACT = "fact" + AGGREGATE = "aggregate" + + +class Decomposability(StrEnum): + """Decomposability class for aggregation functions (Han 2001). + + ``DISTRIBUTIVE`` aggregates (``SUM``/``COUNT``/``MIN``/``MAX``) can be + re-aggregated losslessly. ``ALGEBRAIC`` aggregates + (``AVG``, ``STDDEV``) can be re-aggregated via auxiliary state. + ``HOLISTIC`` aggregates (``COUNT DISTINCT``, ``MEDIAN``) must run at + the final grain. This attribute guards the Foundation's fan-out + safety proofs (§5 of the algebra spec). + """ + + DISTRIBUTIVE = "distributive" + ALGEBRAIC = "algebraic" + HOLISTIC = "holistic" + + +class AggregateFunction(StrEnum): + """Foundation aggregation functions. + + Intentionally small. Adding a function means answering "what is its + decomposability class?" and "how does it behave under re-aggregation?" + """ + + SUM = auto() + COUNT = auto() + COUNT_DISTINCT = auto() + MIN = auto() + MAX = auto() + AVG = auto() + + @property + def decomposability(self) -> Decomposability: + """Static classification used by fan-out safety (see §5.1).""" + if self is AggregateFunction.COUNT_DISTINCT: + return Decomposability.HOLISTIC + if self is AggregateFunction.AVG: + return Decomposability.ALGEBRAIC + return Decomposability.DISTRIBUTIVE + + +@dataclass(frozen=True, slots=True) +class AggregateInfo: + """Static shape of an aggregation. + + Carried by :class:`Column` when ``kind == AGGREGATE``. The frozen + SQL expression captures the *argument* to the aggregation + (``SUM()`` etc.); ``function`` identifies which reduction. + """ + + function: AggregateFunction + argument: FrozenSQL + + +@dataclass(frozen=True, slots=True) +class Column: + """An addressable, fully-qualified output column of a state. + + Every field is immutable. ``dependencies`` records the other column + names this column's expression reads — checked by operators that + look at column-level dataflow (I-6). + """ + + name: Identifier + expression: FrozenSQL + dependencies: frozenset[Identifier] + kind: ColumnKind + aggregate: AggregateInfo | None = None + is_single_valued: bool = False + from_join_rhs: bool = False + # True when this column carries a value that has ALREADY been aggregated at + # a finer grain and then discharged to a scalar by :func:`enrich` (the + # bridge mid-pipeline plan). Such a value must not be re-aggregated by a + # holistic function — that would be an unsafe re-aggregation (Semantic 4). + # A plain dimension/fact brought in by an N:1 enrich is NOT discharged: a + # single-step holistic over it is one SQL aggregate over the (non-fanned) + # joined rows, well-defined per D-020, so it stays allowed. + is_discharged_aggregate: bool = False + + def __post_init__(self) -> None: + """Enforce the (kind, aggregate) contract at construction time.""" + if self.kind is ColumnKind.AGGREGATE and self.aggregate is None: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"AGGREGATE column {self.name!r} requires aggregate info", + context={"column": self.name}, + ) + if self.kind is not ColumnKind.AGGREGATE and self.aggregate is not None: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"non-aggregate column {self.name!r} has aggregate info", + context={"column": self.name, "kind": self.kind}, + ) + + +@dataclass(frozen=True, slots=True) +class CalculationState: + """The single value flowing through the algebra. + + Constructed only by :mod:`ossie.algebra.operations` — never + directly. The algebra package exports the operators, not this + constructor; callers who import ``CalculationState`` are expected to + use it for type annotations and structural equality. + + See the module docstring for the full invariant list. + """ + + grain: DimensionSet + columns: tuple[Column, ...] + provenance: frozenset[ExpressionId] = field(default_factory=frozenset) + unique_keys: frozenset[DimensionSet] = field(default_factory=frozenset) + + def __post_init__(self) -> None: + """Validate invariants I-1, I-2, I-6, and I-9 eagerly.""" + names = [c.name for c in self.columns] + if len(names) != len(set(names)): + seen: set[Identifier] = set() + dup: Identifier | None = None + for n in names: + if n in seen: + dup = n + break + seen.add(n) + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"duplicate column name {dup!r}", + context={"column": dup, "columns": names}, + ) + dimension_names = { + c.name for c in self.columns if c.kind is ColumnKind.DIMENSION + } + missing_grain = self.grain - dimension_names + if missing_grain: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"grain references non-dimension columns: {sorted(missing_grain)}", + context={"grain": sorted(self.grain), "missing": sorted(missing_grain)}, + ) + all_names = set(names) + for col in self.columns: + unknown = col.dependencies - all_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"column {col.name!r} depends on unknown columns: " + f"{sorted(unknown)}", + context={"column": col.name, "missing": sorted(unknown)}, + ) + for uk in self.unique_keys: + if not uk: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "unique_keys may not contain an empty key set", + context={"unique_keys": sorted(map(sorted, self.unique_keys))}, + ) + missing_uk = uk - dimension_names + if missing_uk: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"unique_key references non-dimension columns: " + f"{sorted(missing_uk)}", + context={ + "unique_key": sorted(uk), + "missing": sorted(missing_uk), + }, + ) + + @property + def is_scalar(self) -> bool: + """Return whether this state has scalar grain (exactly one row).""" + return len(self.grain) == 0 + + @property + def column_names(self) -> frozenset[Identifier]: + """Set of column names in this state, cached per access.""" + return frozenset(c.name for c in self.columns) + + def column(self, name: Identifier) -> Column: + """Return the column with ``name`` or raise :class:`AlgebraError`.""" + for c in self.columns: + if c.name == name: + return c + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"no column named {name!r}", + context={"column": name, "available": sorted(self.column_names)}, + ) + + def is_unique_on(self, keys: DimensionSet) -> bool: + """Return whether ``keys`` functionally determines a single row. + + The state is unique on ``keys`` when ``keys`` is a superset of + any declared key: + + 1. the **grain** itself (always a key by I-1 / I-5), or + 2. any member of :attr:`unique_keys` (alternative minimum keys + at this grain — see I-9). + + Used by :func:`ossie.algebra.enrich` to discharge the + fan-trap rule and by future operators that need to prove a + join-key set covers a key. The check is *subset*: a strict + superset of a key is still a key, so wider join-key sets stay + safe. + """ + if self.grain.issubset(keys): + return True + return any(uk.issubset(keys) for uk in self.unique_keys) + + +__all__ = [ + "AggregateFunction", + "AggregateInfo", + "CalculationState", + "Column", + "ColumnKind", + "Decomposability", +] diff --git a/python/ossie/src/ossie/common/__init__.py b/python/ossie/src/ossie/common/__init__.py new file mode 100644 index 00000000..77b60d6b --- /dev/null +++ b/python/ossie/src/ossie/common/__init__.py @@ -0,0 +1,51 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared primitives used by all layers. + +Contains: + +- :mod:`ossie.common.identifiers` — ``Identifier`` NewType, normalization, + validation. +- :mod:`ossie.common.sql_expr` — thin wrappers over SQLGlot for frozen, + comparable ASTs. +- :mod:`ossie.common.types` — cross-layer NewTypes (``DimensionSet``, + ``CTEName``, ``ExpressionId``, ``SourceLocation``). +""" + +from ossie.common.identifiers import ( + Identifier, + identifiers_equal, + is_valid_identifier, + normalize_identifier, +) +from ossie.common.sql_expr import FrozenSQL, parse_sql_expr, sql_expr_equal +from ossie.common.types import CTEName, DimensionSet, ExpressionId, SourceLocation + +__all__ = [ + "CTEName", + "DimensionSet", + "ExpressionId", + "FrozenSQL", + "Identifier", + "SourceLocation", + "identifiers_equal", + "is_valid_identifier", + "normalize_identifier", + "parse_sql_expr", + "sql_expr_equal", +] diff --git a/python/ossie/src/ossie/common/identifiers.py b/python/ossie/src/ossie/common/identifiers.py new file mode 100644 index 00000000..fbe6ad46 --- /dev/null +++ b/python/ossie/src/ossie/common/identifiers.py @@ -0,0 +1,98 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Identifier primitives shared by every compiler layer. + +An ``Identifier`` is a normalized name used for datasets, fields, columns, +CTEs, and synthetic names. Normalization (lower-casing) and validation +(shape) are centralized here to enforce **invariant 11** from +``ARCHITECTURE.md``: raw ``==`` on identifier strings is a bug. +""" + +from __future__ import annotations + +import re +from typing import NewType + +from ossie.errors import ErrorCode, OSIError + +Identifier = NewType("Identifier", str) + +# Foundation identifier shape: ASCII letter or underscore, followed by any +# run of letters / digits / underscores. Matches core-spec/foundational_semantics.md §3.1. +_IDENTIFIER_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z") + +# Reserved identifiers we refuse to accept even though SQL might. +# (Stay conservative: these conflict with pieces of the algebra contract.) +_RESERVED: frozenset[str] = frozenset( + { + "__grain__", + "__provenance__", + "__all__", + } +) + + +def is_valid_identifier(raw: str) -> bool: + """Return whether ``raw`` is a syntactically valid Foundation identifier.""" + return bool(_IDENTIFIER_RE.match(raw)) + + +def normalize_identifier(raw: str) -> Identifier: + """Normalize and validate an identifier. + + Normalization is case-folding. Two identifiers that differ only in + case are the same identifier — this matches standard SQL semantics for + unquoted identifiers. + + Raises + ------ + OSIError + ``E1005_IDENTIFIER_INVALID`` if ``raw`` is empty, has the wrong + shape, or is reserved. + """ + if not isinstance(raw, str): + raise OSIError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"identifier must be a string, got {type(raw).__name__}", + context={"value": repr(raw)}, + ) + if not raw: + raise OSIError( + ErrorCode.E1005_IDENTIFIER_INVALID, + "identifier is empty", + ) + if not _IDENTIFIER_RE.match(raw): + raise OSIError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"identifier {raw!r} has invalid shape; " + "must match [A-Za-z_][A-Za-z0-9_]*", + context={"value": raw}, + ) + normalized = raw.lower() + if normalized in _RESERVED: + raise OSIError( + ErrorCode.E2008_RESERVED_IDENTIFIER, + f"identifier {raw!r} is reserved", + context={"value": raw}, + ) + return Identifier(normalized) + + +def identifiers_equal(a: str, b: str) -> bool: + """Case-insensitive identifier equality without raising on invalid shape.""" + return a.lower() == b.lower() diff --git a/python/ossie/src/ossie/common/sql_expr.py b/python/ossie/src/ossie/common/sql_expr.py new file mode 100644 index 00000000..ceba9f66 --- /dev/null +++ b/python/ossie/src/ossie/common/sql_expr.py @@ -0,0 +1,122 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Thin wrappers over SQLGlot for frozen, comparable AST fragments. + +Invariant 10 from ``ARCHITECTURE.md``: **SQL composition via AST only.** +Every SQL fragment that flows between layers travels as a SQLGlot +``Expression``, never as a string, and every comparison goes through +:func:`sql_expr_equal`. + +The algebra and planner treat expressions as opaque values: they may +store, copy, and compare them; they do not rewrite them. Rewriting is +the job of :mod:`ossie.codegen`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, cast + +import sqlglot +from sqlglot import exp + +from ossie.errors import ErrorCode, OSIError + +PARSE_DIALECT: Final[str] = "" +"""SQLGlot's default (ANSI-like) dialect identifier. + +We intentionally parse scalar expressions with the dialect-neutral +grammar so dialect-specific surface syntax (BigQuery ``QUALIFY``, +Snowflake ``SQUARE_BRACKET_INDEXING``, ...) cannot leak into the +algebra. Dialect translation is codegen's job. +""" + + +def parse_sql_expr(source: str) -> exp.Expression: + """Parse a single SQL scalar expression. + + Raises :class:`OSIError` with ``E1006_SQL_EXPRESSION_SYNTAX`` if + ``source`` cannot be parsed. The code is in the ``E1xxx`` parsing + family because *parsing user-supplied SQL is a layer-1 concern* — + failures here are syntactic, not codegen failures. The previous + ``E5002`` (a codegen "render failed" code) was misleading and broke + the layer-to-error-prefix invariant in ``ARCHITECTURE.md``. + """ + try: + parsed = sqlglot.parse_one(source, read=PARSE_DIALECT or None) + except sqlglot.errors.ParseError as err: + raise OSIError( + ErrorCode.E1006_SQL_EXPRESSION_SYNTAX, + f"failed to parse SQL expression: {source!r}", + context={"source": source, "sqlglot_error": str(err)}, + ) from err + if parsed is None: + raise OSIError( + ErrorCode.E1006_SQL_EXPRESSION_SYNTAX, + f"SQLGlot returned no AST for {source!r}", + context={"source": source}, + ) + # sqlglot's parse_one is typed to return its internal ``Expr`` alias; it is + # an ``Expression`` in practice (and we've ruled out None above). + return cast(exp.Expression, parsed) + + +def sql_expr_equal(a: exp.Expression, b: exp.Expression) -> bool: + """Structural equality between SQLGlot expressions. + + Uses SQLGlot's canonical key so two expressions that render the same + way compare equal regardless of incidental whitespace. + """ + return bool(a == b) + + +@dataclass(frozen=True, slots=True) +class FrozenSQL: + """A SQLGlot ``Expression`` wrapped so it can live inside frozen dataclasses. + + ``frozenset``/``tuple`` members require hashable elements. Rather + than relying on SQLGlot's ``__hash__`` (which is present but walks + the AST each call), we precompute a canonical string form. + """ + + expr: exp.Expression + canonical: str + + @classmethod + def of(cls, expr: exp.Expression) -> FrozenSQL: + """Build a ``FrozenSQL`` from a SQLGlot ``Expression``.""" + return cls( + expr=expr, + canonical=expr.sql(dialect=PARSE_DIALECT or None, normalize=True), + ) + + def __hash__(self) -> int: # noqa: D105 + return hash(self.canonical) + + def __eq__(self, other: object) -> bool: # noqa: D105 + if not isinstance(other, FrozenSQL): + return NotImplemented + return self.canonical == other.canonical + + +__all__ = [ + "PARSE_DIALECT", + "FrozenSQL", + "parse_sql_expr", + "sql_expr_equal", +] diff --git a/python/ossie/src/ossie/common/types.py b/python/ossie/src/ossie/common/types.py new file mode 100644 index 00000000..13cdc7b4 --- /dev/null +++ b/python/ossie/src/ossie/common/types.py @@ -0,0 +1,90 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Cross-layer ``NewType`` aliases and small frozen value objects. + +Keeping these in ``ossie.common`` avoids circular imports between the three +compiler layers and lets ``import-linter`` enforce the one-way flow (see +``ARCHITECTURE.md``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import NewType + +from ossie.common.identifiers import Identifier + +CTEName = NewType("CTEName", str) +ExpressionId = NewType("ExpressionId", str) + +DimensionSet = frozenset[Identifier] +"""Convenience alias used by the algebra for grain sets. + +Kept as a structural alias — not a :class:`typing.NewType` — because +the algebra constructs grain sets by ordinary :class:`frozenset` +operations (union, intersection, set comprehensions) that ``NewType`` +would force every call site to wrap. The discipline we actually rely +on is ``DimensionSet`` only ever holding :class:`Identifier` strings, +which the static type already guarantees. +""" + + +class Dialect(StrEnum): + """SQL dialects the Foundation supports end-to-end. + + The string values are the canonical lower-case form used by the + CLI (``--dialect duckdb``) and SQLGlot (``Expression.sql(dialect=...)``). + YAML inputs may use the SPEC's upper-case spelling + (``ANSI_SQL``, ``DUCKDB``, ``SNOWFLAKE``) — the parsing layer + normalises those to this enum. + + This enum is the single source of truth for the dialect vocabulary + across all three compiler layers. + """ + + # OSI_SQL_2026 is the Foundation v0.1 default expression language. + # Models that don't pin a dialect are parsed and emitted as + # OSI_SQL_2026; engine dialects below are downstream lowerings that + # the codegen produces on demand. + OSI_SQL_2026 = "osi_sql_2026" + ANSI = "ansi" + DUCKDB = "duckdb" + SNOWFLAKE = "snowflake" + + +@dataclass(frozen=True, slots=True) +class SourceLocation: + """1-indexed (line, column) pointer into a YAML or SQL source file. + + Used by parser errors and diagnostics. Never by the algebra or codegen. + """ + + file: str + line: int + column: int + + +__all__ = [ + "CTEName", + "Dialect", + "DimensionSet", + "ExpressionId", + "Identifier", + "SourceLocation", +] diff --git a/python/ossie/src/ossie/errors.py b/python/ossie/src/ossie/errors.py new file mode 100644 index 00000000..53e15db1 --- /dev/null +++ b/python/ossie/src/ossie/errors.py @@ -0,0 +1,354 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Typed error hierarchy for the OSI Python reference implementation. + +See ``core-spec/foundational_semantics.md`` (Appendix A: Error Code Index) +for the full catalog. Every code listed there must have an enum value here +before it can be raised in production code. + +Tests must assert on ``error.code``, never on message text. +""" + +from __future__ import annotations + +from enum import StrEnum + + +class ErrorCode(StrEnum): + """Stable error codes. See ``core-spec/foundational_semantics.md (Appendix A)``. + + A code marked ``RESERVED`` is documented in ``core-spec/foundational_semantics.md (Appendix A)`` + and the relevant spec, but has no emit path in the current + implementation. Reserved codes belong to deferred features + (``SEMANTIC_VIEW`` SQL surface, M:N stitch paths, strict-mode + warnings). They are retained so (a) external tooling that pins to a + code is not broken when the feature lands, and (b) the spec + references in the catalog stay stable. Tests covering reserved + codes live alongside the features that raise them — not here. + """ + + # E1xxx — Parse errors + E1001_YAML_SYNTAX = "E1001" + E1002_MISSING_REQUIRED_FIELD = "E1002" + E1003_INVALID_ENUM_VALUE = "E1003" + E1004_TYPE_MISMATCH = "E1004" + E1005_IDENTIFIER_INVALID = "E1005" + E1006_SQL_EXPRESSION_SYNTAX = "E1006" + # E_* — Foundation v0.1 named codes (Appendix A of + # ``core-spec/foundational_semantics.md``). The Foundation rollout (S-1..S-17) + # is migrating every ``E1xxx``/``E2xxx``/``E3xxx`` numeric code to + # an ``E_*`` named code; new code MUST use the named form. + E_DEFERRED_KEY_REJECTED = "E_DEFERRED_KEY_REJECTED" + # S-2 / D-010 / D-011 / D-023 — query-shape errors. + E_MIXED_QUERY_SHAPE = "E_MIXED_QUERY_SHAPE" + E_AGGREGATE_IN_SCALAR_QUERY = "E_AGGREGATE_IN_SCALAR_QUERY" + E_EMPTY_AGGREGATION_QUERY = "E_EMPTY_AGGREGATION_QUERY" + E_EMPTY_SCALAR_QUERY = "E_EMPTY_SCALAR_QUERY" + E_FAN_OUT_IN_SCALAR_QUERY = "E_FAN_OUT_IN_SCALAR_QUERY" + # S-3 / D-005 / D-012 — predicate-routing errors that replace the + # legacy E3009 with named codes matching the spec's three-way + # taxonomy. + E_AGGREGATE_IN_WHERE = "E_AGGREGATE_IN_WHERE" + E_NON_AGGREGATE_IN_HAVING = "E_NON_AGGREGATE_IN_HAVING" + E_MIXED_PREDICATE_LEVEL = "E_MIXED_PREDICATE_LEVEL" + # S-5 / D-024 — a field body that references a finer grain + # without aggregating it. + E_UNAGGREGATED_FINER_GRAIN_REFERENCE = "E_UNAGGREGATED_FINER_GRAIN_REFERENCE" + # S-9 / D-022 — the chosen plan forces a multi-stage decomposition the + # aggregate cannot survive (holistic over §6.7 chasm pre-aggregation + # or §6.8.2 stitch). The §6.8.1 bridge plan is **conceptually** not + # in this family — D-027 describes it as a single-pass aggregate over + # the de-duplicated row set. The reference implementation currently + # realises that route only for the distributive operators (``SUM``, + # ``COUNT``, ``MIN``, ``MAX``) plus ``COUNT(DISTINCT)``; ``AVG``, + # ``MEDIAN``, and ``PERCENTILE_CONT`` over an N:N bridge are still + # pending and surface this code today (see ``planner_bridge.py``). + E_UNSAFE_REAGGREGATION = "E_UNSAFE_REAGGREGATION" + # RESERVED — superseded by E_NESTED_AGGREGATION_DEFERRED. The + # Foundation defers all nested aggregation in metric expressions to + # §10's grain-aware-functions proposal (core-spec/foundational_semantics.md + # §4.5, D-027). The catalog keeps this code so older tooling that + # pinned to it does not break, but no path raises it today; the + # active code is ``E_NESTED_AGGREGATION_DEFERRED``. + E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN = "E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN" + # Foundation v0.1 §4.5 / D-027 — nested aggregation in a metric + # expression (an aggregate function applied to another aggregate's + # result, e.g. ``AVG(COUNT(orders.oid))``) is deferred to §10's + # grain-aware-functions proposal. Behind the + # ``allow_nested_aggregation`` feature flag the planner accepts the + # construct via ``planner_nested``; with the flag off the parser + # rejects the metric body up front with this code. + E_NESTED_AGGREGATION_DEFERRED = "E_NESTED_AGGREGATION_DEFERRED" + # Foundation v0.1 §4.3 / D-003 — a field expression contains an + # aggregate function (``SUM``, ``COUNT``, ``AVG``, …), whether + # over the home dataset's own columns or via a ``1:N`` reach. All + # aggregates live in model-scoped metrics (§4.5); field expressions + # are non-aggregate by construction. Behind the + # ``allow_aggregate_in_field`` feature flag the planner falls back + # to the legacy implicit-home-grain rewrite in + # ``ossie.planning.home_grain``. + E_AGGREGATE_IN_FIELD = "E_AGGREGATE_IN_FIELD" + # Foundation v0.1 §4.3 — fields on the same dataset may reference + # one another, but the dependency graph must be a DAG. A cycle + # (e.g. field ``a`` depends on field ``b`` which depends on + # field ``a``) cannot be lowered to a finite sequence of + # ``ADD_COLUMNS`` stages and so is rejected at parse time. The + # planner relies on the topological order of inter-field + # dependencies to emit portable SQL — see + # :func:`ossie.planning.steps.source_step` and + # :func:`ossie.planning.columns.compute_field_dependencies`. + E_FIELD_DEPENDENCY_CYCLE = "E_FIELD_DEPENDENCY_CYCLE" + # S-10 / D-006 / D-018 / D-019 — identifier resolution + path + # errors. These replace the legacy E2001 / E2002 / E2004 / E2008 / + # E3001 numeric codes for user-facing diagnostics. + E_NAME_NOT_FOUND = "E_NAME_NOT_FOUND" + E_NAME_COLLISION = "E_NAME_COLLISION" + E_AMBIGUOUS_PATH = "E_AMBIGUOUS_PATH" + E_NO_PATH = "E_NO_PATH" + E_RESERVED_IDENTIFIER = "E_RESERVED_IDENTIFIER" + E_RESERVED_NAME = "E_RESERVED_NAME" + # S-12 / D-028 / D-030 / D-031 / D-032 — window-function placement + # and composition rules. Window functions live in ``Measures``, + # ``Fields``, ``Order By``, and ``Having``; never in ``Where`` or + # nested under another window. Frame modes other than ``ROWS`` / + # ``RANGE`` and parameterised frame bounds are deferred. + E_WINDOW_IN_WHERE = "E_WINDOW_IN_WHERE" + E_NESTED_WINDOW = "E_NESTED_WINDOW" + E_WINDOWED_METRIC_COMPOSITION = "E_WINDOWED_METRIC_COMPOSITION" + E_DEFERRED_FRAME_MODE = "E_DEFERRED_FRAME_MODE" + # Implementation extension (F-16). Spec §6.10 accepts windowed + # metrics in ``Measures`` of an aggregation query directly (D-031 + # only defers *composing* a windowed metric from another metric). + # The aggregation planner does not yet implement that surface — + # it currently misclassified windowed metrics as composite and + # raised the misleading ``E1206_METRIC_IN_RAW_AGGREGATE``. The new + # code is the precise diagnostic the spec called for in F-16. + # Scalar (Fields-only) queries continue to compile windowed + # metrics as ``ADD_COLUMNS`` per §6.10 / D-028; see + # :mod:`ossie.planning.planner_scalar`. + E_WINDOWED_MEASURE_NOT_SUPPORTED = "E_WINDOWED_MEASURE_NOT_SUPPORTED" + # RESERVED — D-030. The fan-out-vs-window failure mode is foreclosed + # earlier in the current planner: the scalar branch rejects every + # 1:N edge with ``E_FAN_OUT_IN_SCALAR_QUERY`` (D-023) before + # reaching the window step, and the aggregation branch rejects + # windowed measures at parse with ``E_WINDOWED_METRIC_COMPOSITION`` + # (windowed metric expressions are not yet planned in the + # aggregation branch — see ``ARCHITECTURE.md`` I-43). The code stays in + # the enum because Appendix A requires it and so the future + # surface — windowed measures in aggregation queries — has a + # ready landing pad. Compliance test + # ``t-052-window-over-fanout-foreclosed`` pins the current + # foreclose-before-window behaviour. + E_WINDOW_OVER_FANOUT_REWRITE = "E_WINDOW_OVER_FANOUT_REWRITE" + # D-021 — function call that is not in the OSI_SQL_2026 catalog. + # The catalog is the contract for every Foundation v0.1 + # implementation; vendor-specific functions go through the + # per-dialect ``dialects:`` block. The active whitelist and + # validator live in :mod:`ossie.parsing.function_whitelist`. + E_UNKNOWN_FUNCTION = "E_UNKNOWN_FUNCTION" + # RESERVED — D-025 catch-all for measures with multiple + # incompatible starting grains where none of the more-specific + # codes (``E3012``, ``E3013``, ``E_UNSAFE_REAGGREGATION``) + # applies. The reference implementation reaches one of those + # specific codes today; this code is reserved for engines that + # synthesise different plan choices and need to surface the + # ambiguity. The diagnostic MUST list the starting grains the + # engine identified. (Appendix A / D-025.) + E_AMBIGUOUS_MEASURE_GRAIN = "E_AMBIGUOUS_MEASURE_GRAIN" + # RESERVED — Appendix A / §4.2. Engines that opt to require + # ``primary_key`` declarations on every dataset (so the table + # grain is well-defined) raise this when a model omits one. The + # reference implementation does not currently impose this + # requirement; the code is reserved so an opt-in deployment can + # raise it under a stable name. + E_PRIMARY_KEY_REQUIRED = "E_PRIMARY_KEY_REQUIRED" + # RESERVED — Appendix A. Declared by a model that uses the + # (deferred) ``natural_grain`` proposal. Reserved here so the + # diagnostic surface is stable when the natural-grain proposal + # lands; the Foundation parser rejects ``natural_grain`` outright + # through ``E_DEFERRED_KEY_REJECTED`` today. See + # ``core-spec/foundational_semantics.md`` §3 + # (the natural_grain feature is deferred to a future proposal). + E_INVALID_NATURAL_GRAIN = "E_INVALID_NATURAL_GRAIN" + # RESERVED — sibling of ``E_INVALID_NATURAL_GRAIN`` for the + # pre-aggregation-unsafe case. Same deferred future proposal as + # above — see ``core-spec/foundational_semantics.md`` §3. + E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE = "E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE" + # Implementation extension — raised when the IR or a diagnostic + # detects a *programmer* error, never a user error. Examples: + # a ``QueryPlan`` whose step DAG is not topologically sorted + # (``plan.py:__post_init__``); a payload subclass that has no + # JSON-encoder case (``_payload_to_json``); a resolved reference + # subclass that has no ``_reference_entry`` case + # (``diagnostics/resolve.py``). The shape of the error means + # "the compiler invariants are out of sync; ship a fix" rather + # than "your model is wrong"; keeping it inside the typed + # ``OSIError`` hierarchy means our property tests ("every failure + # carries a code") still hold for these paths. + E_INTERNAL_INVARIANT = "E_INTERNAL_INVARIANT" + + # E12xx — SQL-surface errors (reserved for the future + # SQL_INTERFACE proposal §8). Only E1206 / E1207 / E1208 / E1209 / + # E1212 have active emit paths today; the rest are RESERVED for + # the SEMANTIC_VIEW clause parser. + E1201_SEMANTIC_VIEW_EMPTY = "E1201" # RESERVED — future SQL_INTERFACE §8 + E1202_CLAUSE_ORDER = "E1202" # RESERVED — future SQL_INTERFACE §8 + E1203_REFERENCE_TOO_DEEP = "E1203" # RESERVED — future SQL_INTERFACE §8 + E1204_AMBIGUOUS_BARE_REFERENCE = "E1204" # RESERVED — future SQL_INTERFACE §8 + E1205_DUPLICATE_OUTPUT_COLUMN = "E1205" # RESERVED — future SQL_INTERFACE §8 + E1206_METRIC_IN_RAW_AGGREGATE = "E1206" + E1207_FACTS_METRICS_EXCLUSIVE = "E1207" + E1208_UNSUPPORTED_SQL_CONSTRUCT = "E1208" + E1209_CROSS_DATASET_AD_HOC_AGGREGATE = "E1209" + E1210_WINDOW_METRIC_DEFERRED = "E1210" # RESERVED — window metrics deferred + E1211_CLAUSE_ONLY_OUTER = "E1211" # RESERVED — future SQL_INTERFACE §8 + E1212_COUNT_STAR_AMBIGUOUS = "E1212" + E1213_PARAMETER_USED_AS_REFERENCE = "E1213" # RESERVED — future SQL_INTERFACE §8 + + # E2xxx — Validation errors + E2001_AMBIGUOUS_NAME = "E2001" + E2002_NAME_NOT_FOUND = "E2002" + E2003_DUPLICATE_NAME = "E2003" + E2004_UNREACHABLE_DATASET = "E2004" + E2005_CIRCULAR_METRIC = "E2005" + E2006_INVALID_RELATIONSHIP = "E2006" + E2007_MISSING_PRIMARY_KEY = "E2007" + E2008_RESERVED_IDENTIFIER = "E2008" + + # E3xxx — Planning errors + E3001_AMBIGUOUS_JOIN_PATH = "E3001" + E3002_UNSATISFIABLE_GRAIN = "E3002" + # RESERVED — cardinality is inferred from declared keys today, so + # there is no path that raises this. Kept so a future explicit + # ``cardinality:`` YAML field or a constraint-free relationship can + # fail with a stable code. + E3003_AMBIGUOUS_CARDINALITY = "E3003" + E3004_GRAIN_NOT_SUBSET = "E3004" + E3005_COLUMN_NAME_COLLISION = "E3005" + E3006_MISSING_COLUMN_DEPENDENCY = "E3006" + E3007_AGGREGATE_IN_SCALAR_CONTEXT = "E3007" + E3008_GRAIN_MISMATCH_MERGE = "E3008" + # RESERVED — S-3 split this code into the named predicate-routing + # codes (E_AGGREGATE_IN_WHERE, E_NON_AGGREGATE_IN_HAVING, + # E_MIXED_PREDICATE_LEVEL). Retained so external pinning does not + # break, but no path raises it today. + E3009_POST_AGGREGATE_REF_PRE_AGGREGATE = "E3009" + # RESERVED — today's per-fact merge strategy (§4.11) means a chasm + # trap is prevented structurally rather than raised; see + # ``core-spec/foundational_semantics.md §6.4``. + E3010_CHASM_TRAP = "E3010" + # E3011 is the engine-capability opt-out code: an engine that does + # not support M:N traversal at all raises it for every M:N query + # (engine-wide, never per-query — see ``core-spec/foundational_semantics.md`` + # §6.8 and Appendix A). This reference implementation is + # M:N-supporting, so it never raises E3011; per-query M:N failures + # use ``E3012`` / ``E3013``, and the algebra's internal fan-out + # signal is ``E4001_EXPLOSION_UNSAFE`` (not E3011). + E3011_MN_AGGREGATION_REJECTED = "E3011" + # E3012 / E3013 are the user-facing per-query M:N failure codes + # for M:N-supporting engines: ``E3012`` when no safe rewrite exists + # for a particular query (no bridge, no shared-dimension stitch); + # ``E3013`` when two unrelated facts have no shared dimension to + # stitch on. See ``core-spec/foundational_semantics.md`` §6.8. + E3012_MN_NO_SAFE_REWRITE = "E3012" + E3013_NO_STITCHING_DIMENSION = "E3013" + + # E4xxx — Algebra safety errors (raised only by ossie.algebra; the + # planner translates them to the user-facing E_* codes). E4001 is the + # fan-out / explosion signal: a source column that is already an + # aggregate, an enrich whose child is not unique on the join keys + # (fan-trap), or a holistic aggregate over a discharged join-RHS + # value. See ``ossie.algebra`` and Semantic 2 / Semantic 4 (§6.1). + E4001_EXPLOSION_UNSAFE = "E4001" + # RESERVED — enrich's precondition is phrased as a fan-trap check + # over child grain, so this shape never fires independently today. + E4002_ENRICH_KEYS_NOT_IN_GRAIN = "E4002" + E4003_MERGE_COLUMN_OVERLAP = "E4003" + E4004_BROADCAST_NOT_SCALAR = "E4004" + E4005_FILTERING_JOIN_ADDS_COLUMNS = "E4005" + + # E5xxx — Codegen errors + E5001_DIALECT_UNSUPPORTED = "E5001" + E5002_SQLGLOT_RENDER_FAILED = "E5002" + # RESERVED — the Foundation lifts every dialect via SQLGlot, so a + # feature that reaches codegen is supported by construction. This + # code is carved out for when bespoke transpilers ship. + E5003_DIALECT_MISSING_FEATURE = "E5003" + + # W6xxx — Warnings (non-fatal unless strict mode). All RESERVED: + # the diagnostic warnings channel is specified but not yet wired + # into planning (``diagnostics.explain`` does not attach warnings + # to the QueryPlan today). + W6001_AVG_OF_AVG = "W6001" # RESERVED + W6002_REAGG_PRECISION_LOSS = "W6002" # RESERVED + W6003_SUSPICIOUS_PATTERN = "W6003" # RESERVED + + +class OSIError(Exception): + """Root of every error raised anywhere in the compiler. + + Carries a stable ``code`` (see ``ErrorCode``) and an optional + ``context`` dict with actionable fields (dataset, field, grain, + suggestion). Tests should assert on ``error.code``, never on + message text. + """ + + def __init__( + self, + code: ErrorCode, + message: str, + *, + context: dict[str, object] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.context: dict[str, object] = dict(context or {}) + + +class OSIParseError(OSIError): + """Raised from ``ossie.parsing``. Codes in ``E1xxx`` / ``E2xxx``.""" + + +class OSIPlanningError(OSIError): + """Raised from ``ossie.planning`` (outside the algebra). Codes in ``E3xxx``.""" + + +class AlgebraError(OSIError): + """Raised from ``ossie.algebra``. + + The algebra raises two adjacent code families: + + * ``E4xxx`` — *safety* failures (explosion, broadcast shape, merge + column overlap, filtering-join shape). These are conditions only + the algebra can detect. + * ``E3xxx`` — *contract* failures inherited from the surrounding + planning layer (grain mismatch, missing column, M:N rejection). + They are surfaced by algebra preconditions because the algebra + is the place where the planner's promises become non-negotiable. + + Tests should assert on ``error.code``, never on which family a + code happens to fall in. + """ + + +class OSICodegenError(OSIError): + """Raised from ``ossie.codegen``. Codes in ``E5xxx``.""" + + +class OSIWarning(OSIError): # noqa: N818 — a warning, deliberately not "*Error" + """Non-fatal warnings. Codes in ``W6xxx``. In strict mode these are errors.""" diff --git a/python/ossie/src/ossie/py.typed b/python/ossie/src/ossie/py.typed new file mode 100644 index 00000000..13a83393 --- /dev/null +++ b/python/ossie/src/ossie/py.typed @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/python/ossie/tests/conftest.py b/python/ossie/tests/conftest.py new file mode 100644 index 00000000..adca4879 --- /dev/null +++ b/python/ossie/tests/conftest.py @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared pytest configuration for the algebra test suite. + +The algebra is a pure, closed system: these tests exercise it in memory +only (no database, no I/O), so there are no cross-cutting fixtures here. +Property-based cases build states via ``strategies.py``. +""" diff --git a/python/ossie/tests/strategies.py b/python/ossie/tests/strategies.py new file mode 100644 index 00000000..92bec7e4 --- /dev/null +++ b/python/ossie/tests/strategies.py @@ -0,0 +1,308 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Hypothesis strategies shared by every property test. + +Per ``docs/ALGEBRA_LAWS.md §1``: strategies are deliberately minimal — +just enough to exercise the algebra without drifting into scenarios the +Foundation does not support. + +Landed so far: + +* ``identifiers()`` — syntactically valid, non-reserved identifiers +* ``dimension_sets()`` — small grain sets +* ``dimension_columns()`` / ``fact_columns()`` — individual columns +* ``states()`` — valid :class:`CalculationState`, built through + :func:`ossie.algebra.source` so every generated state is + reachable via the algebra + +Strategies that require running SQL against DuckDB (e.g. +``duckdb_fixtures()``) land alongside Phase 4 codegen. +""" + +from __future__ import annotations + +from typing import cast + +from hypothesis import strategies as st +from sqlglot import expressions as exp + +from ossie.algebra import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + source, +) +from ossie.common.identifiers import Identifier, normalize_identifier +from ossie.common.sql_expr import FrozenSQL +from ossie.common.types import DimensionSet + +_IDENTIFIER_REGEX = r"^[a-z][a-z0-9_]{0,15}$" + +# Tokens we never want Hypothesis to feed into a SQL builder. Two +# disjoint groups: +# +# * **OSI internal sentinels** — names the algebra reserves for grain / +# provenance / wildcard handling; ``normalize_identifier`` already +# refuses them, so generating them here would just be a wasted draw. +# * **SQL reserved words** in the dialects we currently target (ANSI, +# DuckDB, Snowflake). The shape regex above happily produces tokens +# like ``in`` / ``as`` / ``or`` / ``on`` because they match +# ``[a-z][a-z0-9_]{0,15}``. When such a token leaks through and is +# later concatenated into a SQL string by another strategy +# (notably :func:`aggregate_column`, which builds ``SUM()``), +# sqlglot raises a ``ParseError`` and the whole property test +# fails with what looks like an unrelated crash. +# +# Filtering at the strategy level is sufficient because every column / +# expression strategy below either (a) runs the identifier through +# :func:`_frozen_col_ref` (which quotes) or (b) builds the AST +# programmatically with ``quoted=True``. Production code is *not* +# protected by this list — ``normalize_identifier`` accepts SQL +# keywords today; if you want to close that gap, fix it at the +# parser / codegen layer rather than mirroring the keyword list here. +_OSI_RESERVED_TOKENS = frozenset({"__grain__", "__provenance__", "__all__"}) + +# Conservative subset of SQL reserved words that match the identifier +# regex (``[a-z][a-z0-9_]{0,15}``) and are known to cause sqlglot +# parser failures when used unquoted in expression position. We do +# not need to mirror the full ANSI / Snowflake / DuckDB keyword +# lists — the property tests only exercise a handful of expression +# shapes, and any keyword that survives this filter and still breaks +# parsing should be added here. +_SQL_KEYWORD_TOKENS = frozenset( + { + "all", + "and", + "as", + "asc", + "between", + "by", + "case", + "cast", + "cross", + "desc", + "distinct", + "else", + "end", + "exists", + "false", + "for", + "from", + "full", + "group", + "having", + "if", + "in", + "inner", + "is", + "join", + "left", + "like", + "limit", + "no", + "not", + "null", + "of", + "on", + "or", + "order", + "outer", + "qualify", + "right", + "select", + "set", + "some", + "table", + "then", + "to", + "true", + "union", + "unique", + "using", + "values", + "when", + "where", + "with", + } +) +_RESERVED_TOKENS = _OSI_RESERVED_TOKENS | _SQL_KEYWORD_TOKENS + + +def identifiers() -> st.SearchStrategy[Identifier]: + """Generate a syntactically valid, non-reserved identifier.""" + return ( + st.from_regex(_IDENTIFIER_REGEX, fullmatch=True) + .filter(lambda s: s not in _RESERVED_TOKENS) + .map(normalize_identifier) + ) + + +def dimension_sets( + min_size: int = 0, + max_size: int = 4, +) -> st.SearchStrategy[DimensionSet]: + """Generate a small, deduplicated frozenset of identifiers (a grain).""" + return cast( + st.SearchStrategy[DimensionSet], + st.lists(identifiers(), min_size=min_size, max_size=max_size, unique=True).map( + frozenset + ), + ) + + +def _frozen_col_ref(name: Identifier) -> FrozenSQL: + """Build a quoted column reference without going through ``parse_one``. + + Building the AST directly via :func:`exp.column` with + ``quoted=True`` avoids two failure modes that bit us in the + earlier ``parse_one(f'"{name}"')`` form: + + 1. **Reserved-word leakage** — even though we wrapped the name in + double quotes in the f-string, sqlglot still occasionally chose + to parse generated names like ``in`` or ``as`` as keyword + tokens before recognising the quoting context. Skipping the + parser sidesteps the issue entirely. + 2. **Dialect-default quote style** — ``"foo"`` is portable today + but a future dialect addition (Spark, BigQuery) might prefer + backticks. ``quoted=True`` lets sqlglot pick the right style at + render time. + """ + return FrozenSQL.of(exp.column(str(name), quoted=True)) + + +def dimension_column(name: Identifier) -> Column: + """Canonical dimension column for ``name`` (identity expression).""" + return Column( + name=name, + expression=_frozen_col_ref(name), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ) + + +def fact_column(name: Identifier) -> Column: + """Canonical fact column for ``name`` (identity expression).""" + return Column( + name=name, + expression=_frozen_col_ref(name), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ) + + +def aggregate_column( + name: Identifier, + *, + function: AggregateFunction = AggregateFunction.SUM, + over: Identifier, +) -> Column: + """Build an AGGREGATE column named ``name`` reducing ``over``. + + The aggregate AST is built programmatically rather than parsed + from a formatted string. The previous string path + (``parse_one(f"{function.name}({over})")``) fed an unquoted + identifier into sqlglot's expression parser; when Hypothesis + drew a SQL keyword like ``in``, sqlglot raised ``ParseError`` + and the test failed with what looked like an unrelated crash. + Constructing the column reference with ``quoted=True`` and the + aggregate node with :func:`exp.Anonymous` skips parsing + entirely and is keyword-safe by construction. + """ + column_ref = exp.column(str(over), quoted=True) + if function is AggregateFunction.COUNT_DISTINCT: + agg_node = exp.Count(this=exp.Distinct(expressions=[column_ref])) + else: + agg_node = exp.Anonymous( + this=function.name, + expressions=[column_ref], + ) + return Column( + name=name, + expression=FrozenSQL.of(agg_node), + dependencies=frozenset({over}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo( + function=function, + argument=FrozenSQL.of(exp.column(str(over), quoted=True)), + ), + ) + + +@st.composite +def source_states( + draw: st.DrawFn, + *, + min_dims: int = 1, + max_dims: int = 4, + min_facts: int = 0, + max_facts: int = 3, +) -> CalculationState: + """Generate a valid :class:`CalculationState` by calling ``source``. + + This guarantees **invariant I-3**: every generated state arrives + through the algebra, not through direct construction. Hypothesis + shrinking stays inside the valid-state space because ``source`` + validates its preconditions. + """ + # Draw dims and facts separately so we can honour ``min_facts`` + # without relying on partition arithmetic. ``unique=True`` on a + # case-folding map would under-count, so we deduplicate after map. + n_dims = draw(st.integers(min_value=min_dims, max_value=max_dims)) + n_facts = draw(st.integers(min_value=min_facts, max_value=max_facts)) + pool = draw( + st.lists( + identifiers(), + unique=True, + min_size=n_dims + n_facts, + max_size=n_dims + n_facts, + ) + ) + dim_names = pool[:n_dims] + fact_names = pool[n_dims:] + pk_size = draw(st.integers(min_value=1, max_value=len(dim_names))) + primary_key: DimensionSet = frozenset(dim_names[:pk_size]) + return source( + primary_key=primary_key, + dimension_columns=[dimension_column(n) for n in dim_names], + fact_columns=[fact_column(n) for n in fact_names], + ) + + +def states( + *, min_dims: int = 1, max_dims: int = 4, min_facts: int = 0, max_facts: int = 3 +) -> st.SearchStrategy[CalculationState]: + """Public wrapper for :func:`source_states`.""" + return source_states( + min_dims=min_dims, + max_dims=max_dims, + min_facts=min_facts, + max_facts=max_facts, + ) + + +__all__ = [ + "aggregate_column", + "dimension_column", + "dimension_sets", + "fact_column", + "identifiers", + "source_states", + "states", +] diff --git a/python/ossie/tests/test_aggregate_idempotent.py b/python/ossie/tests/test_aggregate_idempotent.py new file mode 100644 index 00000000..5231f77d --- /dev/null +++ b/python/ossie/tests/test_aggregate_idempotent.py @@ -0,0 +1,50 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.5 — Aggregate Idempotence at same grain. + +For any state whose grain already matches ``target_grain`` and whose +aggregations are identity re-aggregations at that grain, ``aggregate`` +returns a state that agrees on grain and columns. + +Mutation target: ``src/osi/planning/algebra/operations.py::aggregate``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from strategies import aggregate_column, states + +from ossie.algebra import CalculationState, aggregate +from ossie.common.identifiers import normalize_identifier + + +@given(state=states(min_facts=1, max_facts=3)) +@settings(max_examples=200, deadline=None) +def test_same_grain_agg_preserves_grain(state: CalculationState) -> None: + fact = next(c for c in state.columns if c.kind.value == "fact") + out = aggregate( + state, + state.grain, + [ + aggregate_column( + normalize_identifier(f"total_{fact.name}"), + over=fact.name, + ) + ], + ) + assert out.grain == state.grain diff --git a/python/ossie/tests/test_algebra_closure.py b/python/ossie/tests/test_algebra_closure.py new file mode 100644 index 00000000..349629d2 --- /dev/null +++ b/python/ossie/tests/test_algebra_closure.py @@ -0,0 +1,90 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mechanical guard for the closed-algebra contract. + +``CalculationState`` is the algebra's carrier of correctness. Its +``__post_init__`` validates the state invariants, but the *closure* rule is +architectural and cannot be expressed in the type system: a ``CalculationState`` +may be **constructed only by the algebra's operator implementations**. Every +other module (present and future — parsing, planning, codegen, and any caller) +must obtain states by *composing operators*, never by hand-building one. + +This test walks the whole ``ossie`` source tree with the ``ast`` module and +fails if ``CalculationState(...)`` is instantiated anywhere outside the +allow-listed operator modules. See ``src/ossie/algebra/ARCHITECTURE.md``. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_SRC = Path(__file__).resolve().parents[1] / "src" / "ossie" + +# The only modules permitted to construct a CalculationState: the algebra +# operator implementations. Adding a module here must be a deliberate, +# reviewed decision — that is the whole point of this gate. +_ALLOWED = { + Path("algebra/operations.py"), + Path("algebra/joins.py"), + Path("algebra/composition.py"), +} + +_GUARDED_TYPE = "CalculationState" + + +def _constructs_guarded_type(tree: ast.AST) -> bool: + """True if ``tree`` calls ``CalculationState(...)`` directly.""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = ( + func.id + if isinstance(func, ast.Name) + else func.attr + if isinstance(func, ast.Attribute) + else None + ) + if name == _GUARDED_TYPE: + return True + return False + + +def test_calculation_state_constructed_only_by_operators() -> None: + offenders: list[str] = [] + for path in sorted(_SRC.rglob("*.py")): + rel = path.relative_to(_SRC) + if rel in _ALLOWED: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if _constructs_guarded_type(tree): + offenders.append(str(rel)) + + assert not offenders, ( + "CalculationState must be constructed only by the algebra operator " + f"modules {sorted(str(p) for p in _ALLOWED)}; found direct construction " + f"in: {offenders}. Compose operators instead of hand-building state — " + "see src/ossie/algebra/ARCHITECTURE.md." + ) + + +def test_allowed_operator_modules_exist() -> None: + """The allow-list must not rot: every listed module must exist.""" + missing = [str(p) for p in _ALLOWED if not (_SRC / p).is_file()] + assert not missing, f"allow-listed operator modules are missing: {missing}" diff --git a/python/ossie/tests/test_algebra_determinism.py b/python/ossie/tests/test_algebra_determinism.py new file mode 100644 index 00000000..1d474837 --- /dev/null +++ b/python/ossie/tests/test_algebra_determinism.py @@ -0,0 +1,50 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.3 — Determinism. + +Same inputs ⇒ same output, including column order. + +At Phase 1 we only have states and algebra ops — byte-identical SQL +rendering (the ultimate determinism test) lands in +``test_sql_determinism.py`` during Phase 4. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from strategies import states + +from ossie.algebra import CalculationState, project + + +@given(state=states()) +@settings(max_examples=300, deadline=None) +def test_project_preserves_column_order(state: CalculationState) -> None: + names = [c.name for c in state.columns] + out = project(state, names) + assert [c.name for c in out.columns] == names + + +@given(state=states(min_dims=2, max_dims=4)) +@settings(max_examples=200, deadline=None) +def test_same_projection_twice_is_identical(state: CalculationState) -> None: + names = [c.name for c in state.columns] + a = project(state, names) + b = project(state, names) + assert a == b + assert tuple(c.name for c in a.columns) == tuple(c.name for c in b.columns) diff --git a/python/ossie/tests/test_algebra_purity.py b/python/ossie/tests/test_algebra_purity.py new file mode 100644 index 00000000..c1635ed1 --- /dev/null +++ b/python/ossie/tests/test_algebra_purity.py @@ -0,0 +1,91 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.2 — Purity. + +Every operator is pure: no I/O, no clocks, no randomness, no mutation of +inputs. Calling the same operator twice with the same arguments returns +equal results, and the input state is unchanged. + +Mutation target: whole ``src/osi/planning/algebra/`` package. +""" + +from __future__ import annotations + +from copy import deepcopy + +from hypothesis import given, settings +from strategies import ( + aggregate_column, + dimension_column, + fact_column, + states, +) + +from ossie.algebra import CalculationState, aggregate, project, source +from ossie.common.identifiers import normalize_identifier + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_does_not_mutate_state(state: CalculationState) -> None: + before = deepcopy(state) + _ = project(state, [c.name for c in state.columns]) + assert state == before + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_is_deterministic(state: CalculationState) -> None: + names = [c.name for c in state.columns] + a = project(state, names) + b = project(state, names) + assert a == b + assert a is not b or a == b + + +@given(state=states(min_facts=1, max_facts=3)) +@settings(max_examples=100, deadline=None) +def test_aggregate_is_deterministic(state: CalculationState) -> None: + target = state.grain + fact = next(c for c in state.columns if c.kind.value == "fact") + agg = aggregate_column( + normalize_identifier("total_repeat"), + over=fact.name, + ) + a = aggregate(state, target, [agg]) + b = aggregate(state, target, [agg]) + assert a == b + + +def test_source_with_equal_args_is_equal() -> None: + # Concrete case — property generator cannot compare because it + # already returns a built state, but we can double-build here. + pk = frozenset({normalize_identifier("a")}) + a = source( + primary_key=pk, + dimension_columns=[dimension_column(normalize_identifier("a"))], + fact_columns=[fact_column(normalize_identifier("x"))], + ) + b = source( + primary_key=pk, + dimension_columns=[dimension_column(normalize_identifier("a"))], + fact_columns=[fact_column(normalize_identifier("x"))], + ) + assert a == b + # Strong structural equality — frozen dataclasses hash/compare by value. + assert hash(a.grain) == hash(b.grain) diff --git a/python/ossie/tests/test_algebra_totality.py b/python/ossie/tests/test_algebra_totality.py new file mode 100644 index 00000000..d33e6dd6 --- /dev/null +++ b/python/ossie/tests/test_algebra_totality.py @@ -0,0 +1,95 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.1 — Totality. + +Every operator either returns a valid :class:`CalculationState` or raises +:class:`AlgebraError` / :class:`OSIError` with an ``E4xxx`` / ``E3xxx`` +code. No ``None``, no silent fallback. + +Mutation target: ``src/osi/planning/algebra/operations.py``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from strategies import aggregate_column, dimension_sets, states + +from ossie.algebra import CalculationState, aggregate, project +from ossie.errors import ErrorCode, OSIError + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_source_states_are_valid(state: CalculationState) -> None: + assert isinstance(state, CalculationState) + assert state.grain.issubset(state.column_names) + + +@given(state=states(), target=dimension_sets(max_size=4)) +@settings(max_examples=200, deadline=None) +def test_aggregate_is_total(state: CalculationState, target: frozenset) -> None: + # Pick an aggregation that is always valid: SUM over some fact if + # one exists; else skip the aggregation entirely by passing []. + fact_names = [ + c.name + for c in state.columns + if c.kind.value == "fact" # stringly to avoid importing enum here + ] + aggs = ( + [aggregate_column(_unused_name(state), over=fact_names[0])] + if fact_names + else [] + ) + try: + out = aggregate(state, target, aggs) + except OSIError as err: + assert err.code.value.startswith(("E3", "E4")), err.code + return + assert isinstance(out, CalculationState) + assert out.grain == target + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_is_total(state: CalculationState) -> None: + # Always project exactly onto the current columns: happy path. + names = [c.name for c in state.columns] + out = project(state, names) + assert isinstance(out, CalculationState) + assert out.column_names == state.column_names + + +@given(state=states()) +@settings(max_examples=50, deadline=None) +def test_project_unknown_raises_osi_error(state: CalculationState) -> None: + try: + project(state, ["definitely_not_a_real_column_name_xyzzy"]) + except OSIError as err: + assert err.code is ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + return + raise AssertionError("project on unknown column should raise E3006") + + +def _unused_name(state: CalculationState) -> str: + base = "agg" + i = 0 + while True: + candidate = f"{base}_{i}" + if candidate not in state.column_names: + return candidate + i += 1 diff --git a/python/ossie/tests/test_enrich_operator.py b/python/ossie/tests/test_enrich_operator.py new file mode 100644 index 00000000..bfb7075c --- /dev/null +++ b/python/ossie/tests/test_enrich_operator.py @@ -0,0 +1,114 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit coverage for the ``enrich`` operator (N:1 join). + +Pins two behaviours: + +* the happy path — an N:1 enrich preserves the parent grain and brings the + one-side's columns in as ``from_join_rhs`` values; and +* the **fan-trap rule** (Semantic 2, §6.1): enriching with a child that is not + unique on the join keys would duplicate parent rows, so it is rejected with + the algebra's fan-out signal ``E4001_EXPLOSION_UNSAFE``. That code — not the + engine-wide M:N opt-out ``E3011`` — is what the algebra raises; the planner + maps it to the user-facing ``E_UNSAFE_REAGGREGATION`` / + ``E_FAN_OUT_IN_SCALAR_QUERY`` per the query shape. +""" + +from __future__ import annotations + +import pytest +from strategies import dimension_column, fact_column + +from ossie.algebra import JoinType, enrich, source +from ossie.common.identifiers import normalize_identifier as ident +from ossie.errors import AlgebraError, ErrorCode + + +def _orders() -> object: + """orders(oid PK, cust_id dim join-key, amount fact).""" + return source( + primary_key=frozenset({ident("oid")}), + dimension_columns=[dimension_column(ident("oid")), dimension_column(ident("cust_id"))], + fact_columns=[fact_column(ident("amount"))], + ) + + +def test_enrich_n1_preserves_grain_and_adds_rhs_columns() -> None: + orders = _orders() + customers = source( + primary_key=frozenset({ident("cid")}), + dimension_columns=[dimension_column(ident("cid")), dimension_column(ident("region"))], + ) + + out = enrich( + orders, + customers, + parent_keys=(ident("cust_id"),), + child_keys=(ident("cid"),), + join_type=JoinType.LEFT, + ) + + assert out.grain == orders.grain # N:1 preserves the many-side grain + assert ident("region") in out.column_names # one-side column brought in + assert out.column(ident("region")).from_join_rhs is True + + +def test_enrich_fan_trap_raises_e4001() -> None: + orders = _orders() + # returns is keyed by rid; cust_id2 is neither the PK nor a declared unique + # key, so returns is NOT unique on the join key -> joining would fan out. + returns = source( + primary_key=frozenset({ident("rid")}), + dimension_columns=[dimension_column(ident("rid")), dimension_column(ident("cust_id2"))], + ) + + with pytest.raises(AlgebraError) as excinfo: + enrich( + orders, + returns, + parent_keys=(ident("cust_id"),), + child_keys=(ident("cust_id2"),), + join_type=JoinType.LEFT, + ) + + assert excinfo.value.code is ErrorCode.E4001_EXPLOSION_UNSAFE + + +def test_enrich_on_declared_unique_key_is_allowed() -> None: + """A child unique on the join key via a declared UK (not the PK) is safe.""" + orders = _orders() + # customers PK is a surrogate sid, but email is a declared unique key. + customers = source( + primary_key=frozenset({ident("sid")}), + dimension_columns=[ + dimension_column(ident("sid")), + dimension_column(ident("email")), + dimension_column(ident("region")), + ], + unique_keys=[frozenset({ident("email")})], + ) + + out = enrich( + orders, + customers, + parent_keys=(ident("cust_id"),), + child_keys=(ident("email"),), + join_type=JoinType.LEFT, + ) + assert out.grain == orders.grain + assert ident("region") in out.column_names diff --git a/python/ossie/tests/test_error_taxonomy.py b/python/ossie/tests/test_error_taxonomy.py new file mode 100644 index 00000000..d3df6ead --- /dev/null +++ b/python/ossie/tests/test_error_taxonomy.py @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.13 — Error Taxonomy. + +Every exception raised from the algebra is an :class:`OSIError` subclass +with a code that matches a value in :class:`ErrorCode`. No bare +:class:`ValueError` / :class:`AssertionError` / :class:`RuntimeError` at +runtime. + +Mutation target: ``src/osi/errors.py``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from strategies import dimension_column, identifiers, states + +from ossie.algebra import CalculationState, project, source +from ossie.common.identifiers import normalize_identifier +from ossie.errors import ErrorCode, OSIError + + +@given(state=states(), bogus=identifiers()) +@settings(max_examples=200, deadline=None) +def test_project_unknown_column_raises_typed_osi_error( + state: CalculationState, bogus: str +) -> None: + if bogus in state.column_names: + return + try: + project(state, [bogus]) + except OSIError as err: + assert err.code in ErrorCode + assert err.code.value.startswith(("E3", "E4")) + return + except Exception as err: # pragma: no cover — law-breach signal + raise AssertionError( + f"non-OSIError raised from project: {type(err).__name__}" + ) from err + raise AssertionError("project on unknown column must raise OSIError") + + +def test_source_empty_pk_raises_typed_osi_error() -> None: + """Example-based: empty primary key is a structural violation.""" + pk = frozenset() + try: + source( + primary_key=pk, + dimension_columns=[dimension_column(normalize_identifier("id"))], + ) + except OSIError as err: + assert err.code is ErrorCode.E2007_MISSING_PRIMARY_KEY + return + except Exception as err: # pragma: no cover + raise AssertionError( + f"non-OSIError raised from source: {type(err).__name__}" + ) from err + raise AssertionError("source with empty PK must raise") diff --git a/python/ossie/tests/test_filter_commute.py b/python/ossie/tests/test_filter_commute.py new file mode 100644 index 00000000..0a115929 --- /dev/null +++ b/python/ossie/tests/test_filter_commute.py @@ -0,0 +1,54 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.6 — Filter Commutativity. + +``filter(filter(s, p1), p2)`` yields a state structurally equivalent to +``filter(filter(s, p2), p1)``. The Foundation algebra represents filter +by a no-op on the state (predicates live on the plan step), so this +law reduces to "filter never changes the state shape, regardless of +order." + +A DuckDB-executed row-set equivalence test is added alongside the +Phase 4 codegen harness. +""" + +from __future__ import annotations + +import sqlglot +from hypothesis import given, settings +from strategies import states + +from ossie.algebra import CalculationState, filter_ +from ossie.common.sql_expr import FrozenSQL + +_p1 = FrozenSQL.of(sqlglot.parse_one("1 = 1")) +_p2 = FrozenSQL.of(sqlglot.parse_one("2 = 2")) + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_filter_order_does_not_change_state_shape( + state: CalculationState, +) -> None: + left = filter_( + filter_(state, _p1, dependencies=frozenset()), _p2, dependencies=frozenset() + ) + right = filter_( + filter_(state, _p2, dependencies=frozenset()), _p1, dependencies=frozenset() + ) + assert left == right diff --git a/python/ossie/tests/test_frozensql_canonical.py b/python/ossie/tests/test_frozensql_canonical.py new file mode 100644 index 00000000..dfd4a224 --- /dev/null +++ b/python/ossie/tests/test_frozensql_canonical.py @@ -0,0 +1,100 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Canonical-form stability of :class:`FrozenSQL`. + +``FrozenSQL.canonical`` is the *only* thing +:class:`FrozenSQL.__hash__` and :class:`FrozenSQL.__eq__` look at. +Three properties have to hold for the algebra's immutability story to +work: + +1. **Reproducibility** — calling :meth:`FrozenSQL.of` twice on the same + parsed expression yields the same canonical string. +2. **Copy-stability** — :meth:`exp.Expression.copy` produces an AST + that, when wrapped, has the same canonical string. The transpiler + and the algebra rely on this every time they pass an argument + through ``.copy()`` defensively. +3. **Round-trip stability** — re-parsing the rendered SQL of an + expression produces a wrapper with the same canonical string. This + is what makes :meth:`FrozenSQL` safe to serialize via + :attr:`canonical` and reconstruct. + +If any of these fail, two structurally identical predicates can hash +differently and the algebra's "set of FrozenSQL columns is a value" +contract breaks silently. +""" + +from __future__ import annotations + +import sqlglot +from hypothesis import given +from hypothesis import strategies as st + +from ossie.common.sql_expr import FrozenSQL + +_BASE_EXPRS: list[str] = [ + "x", + "x + 1", + "x * y", + "(x + y) * z", + "SUM(x)", + "SUM(price * qty)", + "COUNT(DISTINCT customer_id)", + "CASE WHEN x > 0 THEN 1 ELSE 0 END", + "lower(name)", + "x = 1 AND y = 2", + "x IN (1, 2, 3)", + "COALESCE(x, 0) + COALESCE(y, 0)", +] + + +@st.composite +def parsed_exprs(draw: st.DrawFn) -> sqlglot.exp.Expression: + """Pick one of the curated source strings and parse it fresh.""" + src = draw(st.sampled_from(_BASE_EXPRS)) + return sqlglot.parse_one(src) + + +@given(parsed_exprs()) +def test_of_is_idempotent(expr: sqlglot.exp.Expression) -> None: + """Wrapping the same AST twice gives the same canonical form.""" + a = FrozenSQL.of(expr) + b = FrozenSQL.of(expr) + assert a.canonical == b.canonical + assert hash(a) == hash(b) + assert a == b + + +@given(parsed_exprs()) +def test_of_stable_under_copy(expr: sqlglot.exp.Expression) -> None: + """``expr.copy()`` must not change the canonical form.""" + a = FrozenSQL.of(expr) + b = FrozenSQL.of(expr.copy()) + assert a.canonical == b.canonical + assert hash(a) == hash(b) + assert a == b + + +@given(parsed_exprs()) +def test_of_stable_under_reparse(expr: sqlglot.exp.Expression) -> None: + """Round-tripping through SQL text preserves canonical equality.""" + a = FrozenSQL.of(expr) + rendered = a.canonical + b = FrozenSQL.of(sqlglot.parse_one(rendered)) + assert a.canonical == b.canonical + assert hash(a) == hash(b) + assert a == b diff --git a/python/ossie/tests/test_grain_closure.py b/python/ossie/tests/test_grain_closure.py new file mode 100644 index 00000000..74634313 --- /dev/null +++ b/python/ossie/tests/test_grain_closure.py @@ -0,0 +1,117 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.4 — Closure of Grain. + +For any step sequence the resulting grain is a pure function of the +argument stream. The symbolic simulator in +:mod:`ossie.algebra.grain` and the concrete algebra agree. + +Mutation target: ``src/osi/planning/algebra/grain.py``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st +from strategies import aggregate_column, states + +from ossie.algebra import CalculationState, aggregate, filter_, project +from ossie.algebra.grain import ( + AggregateStep, + OperatorTag, + SimpleStep, + SourceStep, + simulate_grain, +) +from ossie.common.identifiers import normalize_identifier + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_source_grain_matches_simulation(state: CalculationState) -> None: + symbolic = simulate_grain((SourceStep(OperatorTag.SOURCE, state.grain),)) + assert symbolic == state.grain + + +@given(data=st.data(), state=states()) +@settings(max_examples=200, deadline=None) +def test_aggregate_grain_matches_simulation( + data: st.DataObject, state: CalculationState +) -> None: + # Draw the aggregation target as a non-empty subset of the state's + # grain — this trivially satisfies the aggregate precondition and + # avoids filtering out most generated states via ``assume``. + grain_list = sorted(state.grain) + size = data.draw(st.integers(min_value=1, max_value=len(grain_list))) + target = frozenset(data.draw(st.permutations(grain_list))[:size]) + fact_names = [c.name for c in state.columns if c.kind.value == "fact"] + aggs = ( + [aggregate_column(_unused_name(state), over=fact_names[0])] + if fact_names + else [] + ) + concrete = aggregate(state, target, aggs) + symbolic = simulate_grain( + ( + SourceStep(OperatorTag.SOURCE, state.grain), + AggregateStep(OperatorTag.AGGREGATE, target), + ) + ) + assert concrete.grain == symbolic == target + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_preserves_grain(state: CalculationState) -> None: + concrete = project(state, [c.name for c in state.columns]) + symbolic = simulate_grain( + ( + SourceStep(OperatorTag.SOURCE, state.grain), + SimpleStep(OperatorTag.PROJECT), + ) + ) + assert concrete.grain == symbolic == state.grain + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_filter_preserves_grain(state: CalculationState) -> None: + # Use a predicate with no dependencies — structural preservation + # is what the law tests, not predicate validation. + import sqlglot + + from ossie.common.sql_expr import FrozenSQL + + pred = FrozenSQL.of(sqlglot.parse_one("1 = 1")) + concrete = filter_(state, pred, dependencies=frozenset()) + symbolic = simulate_grain( + ( + SourceStep(OperatorTag.SOURCE, state.grain), + SimpleStep(OperatorTag.FILTER), + ) + ) + assert concrete.grain == symbolic == state.grain + + +def _unused_name(state: CalculationState) -> str: + i = 0 + while True: + candidate = normalize_identifier(f"agg_{i}") + if candidate not in state.column_names: + return candidate + i += 1 diff --git a/python/ossie/tests/test_merge_associative.py b/python/ossie/tests/test_merge_associative.py new file mode 100644 index 00000000..5c523a85 --- /dev/null +++ b/python/ossie/tests/test_merge_associative.py @@ -0,0 +1,77 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.7 — Merge Associativity. + +``merge(merge(a, b), c) ≡ merge(a, merge(b, c))`` at equal grains with +disjoint non-grain columns. + +Mutation target: ``src/osi/planning/algebra/operations.py::merge``. +""" + +from __future__ import annotations + +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from strategies import ( + dimension_column, + dimension_sets, + fact_column, + identifiers, +) + +from ossie.algebra import merge, source + + +@st.composite +def _states_on_grain(draw, grain): + """Build a CalculationState on ``grain`` with exactly one unique fact.""" + fact_name = draw(identifiers()) + # Avoid collisions with the grain identifiers. + assume(fact_name not in grain) + dims = [dimension_column(n) for n in sorted(grain)] + return source( + primary_key=grain if grain else frozenset({dims[0].name}) if dims else None, + dimension_columns=dims, + fact_columns=[fact_column(fact_name)], + ) + + +@given(grain=dimension_sets(min_size=1, max_size=3), data=st.data()) +@settings(max_examples=100, deadline=None) +def test_merge_is_associative(grain, data) -> None: + a = data.draw(_states_on_grain(grain)) + b_name = data.draw( + identifiers().filter(lambda n: n not in {c.name for c in a.columns}) + ) + b = source( + primary_key=grain, + dimension_columns=[dimension_column(n) for n in sorted(grain)], + fact_columns=[fact_column(b_name)], + ) + c_name = data.draw( + identifiers().filter(lambda n: n not in {c.name for c in a.columns} | {b_name}) + ) + c = source( + primary_key=grain, + dimension_columns=[dimension_column(n) for n in sorted(grain)], + fact_columns=[fact_column(c_name)], + ) + left = merge(merge(a, b), c) + right = merge(a, merge(b, c)) + assert left.grain == right.grain + assert {col.name for col in left.columns} == {col.name for col in right.columns} diff --git a/python/ossie/tests/test_project_idempotent.py b/python/ossie/tests/test_project_idempotent.py new file mode 100644 index 00000000..54da28db --- /dev/null +++ b/python/ossie/tests/test_project_idempotent.py @@ -0,0 +1,71 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Law §4.8 — Projection Idempotence. + +``project(project(s, c1), c2) ≡ project(s, c2)`` whenever ``c2 ⊆ c1`` +and ``c2`` covers ``s.grain``. + +Mutation target: ``src/osi/planning/algebra/operations.py::project``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st +from strategies import states + +from ossie.algebra import CalculationState, project + + +@given(state=states(min_dims=1, max_dims=3, min_facts=1, max_facts=3), data=st.data()) +@settings(max_examples=200, deadline=None) +def test_project_is_idempotent(state: CalculationState, data) -> None: + # c1 ⊇ c2 ⊇ grain so both projections succeed. + all_names = [c.name for c in state.columns] + grain_names = sorted(state.grain) + extras = [n for n in all_names if n not in state.grain] + # Draw a size for c2 from [len(grain), len(all_names)], then grow c1. + size_c2 = data.draw( + st.integers(min_value=len(grain_names), max_value=len(all_names)) + ) + c2_extras = data.draw( + st.lists( + st.sampled_from(extras) if extras else st.nothing(), + max_size=max(0, size_c2 - len(grain_names)), + unique=True, + ) + if extras + else st.just([]) + ) + c2 = grain_names + list(dict.fromkeys(c2_extras)) + # Draw c1 strictly containing c2. + remaining = [n for n in extras if n not in c2_extras] + c1_extras = data.draw( + st.lists( + st.sampled_from(remaining) if remaining else st.nothing(), + max_size=len(remaining), + unique=True, + ) + if remaining + else st.just([]) + ) + c1 = c2 + list(dict.fromkeys(c1_extras)) + + via_two = project(project(state, c1), c2) + direct = project(state, c2) + assert via_two == direct diff --git a/python/ossie/tests/test_reaggregation_safety.py b/python/ossie/tests/test_reaggregation_safety.py new file mode 100644 index 00000000..f3ced4eb --- /dev/null +++ b/python/ossie/tests/test_reaggregation_safety.py @@ -0,0 +1,131 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Re-aggregation safety of ``aggregate`` (Semantic 4, §6.1). + +The algebra blocks *holistic* re-aggregation of a **discharged** value — one +already aggregated at a finer grain and brought in by :func:`enrich` (the bridge +mid-pipeline plan). It does NOT block: + +* a holistic aggregate over a *plain* enriched dimension — that is a single-step + aggregate over the non-fanned joined rows, well-defined per D-020; or +* a *distributive* re-aggregation of a discharged value (``SUM`` of ``SUM``). + +These tests pin the discriminator (they guard the F2 fix: the guard keys off +``is_discharged_aggregate``, not the broader ``from_join_rhs``). +""" + +from __future__ import annotations + +import pytest +from strategies import aggregate_column, dimension_column, fact_column + +from ossie.algebra import AggregateFunction, JoinType, aggregate, enrich, source +from ossie.common.identifiers import normalize_identifier as ident +from ossie.errors import AlgebraError, ErrorCode + + +def _orders() -> object: + """orders(oid PK, cust_id dim join-key, amount fact).""" + return source( + primary_key=frozenset({ident("oid")}), + dimension_columns=[dimension_column(ident("oid")), dimension_column(ident("cust_id"))], + fact_columns=[fact_column(ident("amount"))], + ) + + +def _orders_enriched_with_customer_region() -> object: + """orders N:1 customers, bringing in ``region`` as a plain enriched dim.""" + customers = source( + primary_key=frozenset({ident("cid")}), + dimension_columns=[dimension_column(ident("cid")), dimension_column(ident("region"))], + ) + return enrich( + _orders(), + customers, + parent_keys=(ident("cust_id"),), + child_keys=(ident("cid"),), + join_type=JoinType.LEFT, + ) + + +def _orders_enriched_with_discharged_total() -> object: + """orders N:1 (returns pre-aggregated to ckey), bringing in a discharged SUM.""" + returns = source( + primary_key=frozenset({ident("rid")}), + dimension_columns=[dimension_column(ident("rid")), dimension_column(ident("ckey"))], + fact_columns=[fact_column(ident("amt"))], + ) + returns_by_customer = aggregate( + returns, + frozenset({ident("ckey")}), + [aggregate_column(ident("tot"), function=AggregateFunction.SUM, over=ident("amt"))], + ) + return enrich( + _orders(), + returns_by_customer, + parent_keys=(ident("cust_id"),), + child_keys=(ident("ckey"),), + join_type=JoinType.LEFT, + ) + + +def test_holistic_over_plain_enriched_dim_is_allowed() -> None: + # COUNT(DISTINCT region) grouped by cust_id: region is enriched (N:1) but + # NOT discharged, so this single-step holistic is well-defined (D-020). + state = _orders_enriched_with_customer_region() + out = aggregate( + state, + frozenset({ident("cust_id")}), + [ + aggregate_column( + ident("n_regions"), + function=AggregateFunction.COUNT_DISTINCT, + over=ident("region"), + ) + ], + ) + assert out.grain == frozenset({ident("cust_id")}) + assert ident("n_regions") in out.column_names + + +def test_holistic_over_discharged_aggregate_raises_e4001() -> None: + state = _orders_enriched_with_discharged_total() + with pytest.raises(AlgebraError) as excinfo: + aggregate( + state, + frozenset({ident("cust_id")}), + [ + aggregate_column( + ident("n"), + function=AggregateFunction.COUNT_DISTINCT, + over=ident("tot"), + ) + ], + ) + assert excinfo.value.code is ErrorCode.E4001_EXPLOSION_UNSAFE + + +def test_distributive_over_discharged_aggregate_is_allowed() -> None: + # SUM of a discharged SUM is distributive and therefore safe to re-aggregate. + state = _orders_enriched_with_discharged_total() + out = aggregate( + state, + frozenset({ident("cust_id")}), + [aggregate_column(ident("s"), function=AggregateFunction.SUM, over=ident("tot"))], + ) + assert ident("s") in out.column_names diff --git a/python/uv.lock b/python/uv.lock index a1766e10..1b93a812 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -3,7 +3,9 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [manifest] @@ -28,10 +30,14 @@ source = { editable = "ossie" } dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, + { name = "sqlglot" }, ] [package.dev-dependencies] dev = [ + { name = "hypothesis" }, + { name = "import-linter" }, + { name = "mutmut" }, { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, @@ -42,10 +48,14 @@ dev = [ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "sqlglot", specifier = ">=25.0" }, ] [package.metadata.requires-dev] dev = [ + { name = "hypothesis", specifier = ">=6.0" }, + { name = "import-linter", specifier = ">=2.0" }, + { name = "mutmut", specifier = ">=3.0" }, { name = "mypy", specifier = ">=1.10" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.11" }, @@ -118,6 +128,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -127,6 +149,254 @@ 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 = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "grimp" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/06/dd37dd3e282e90856215ea406415ba0c18cb77cf3150ffa47bd1137c8daf/grimp-3.15-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cc467bdf890b26545989994c5f91d99fb2b911bbc09d48cebf906082fb2c01d1", size = 2140928, upload-time = "2026-07-03T12:08:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b0/9329b8df4916cd60a1b358e527fcb9239604a1eca88483deb68ab95d9d5a/grimp-3.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:571d62c2f2e264ed83a9b283b0c1e322902ea3af8a9ad20d9f7cc5be3049be25", size = 2097876, upload-time = "2026-07-03T12:08:41.571Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/4352eb0a6549faefeaae1514f2994f6d9148b12db10e49d94935109cd4cd/grimp-3.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0c36369902389e080f708560621f29e2b64957a6bfa5afb06c13b74105039d6", size = 2262242, upload-time = "2026-07-03T12:07:33Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/a7ddecd677891070277f92e71bc41c10c423a3317eb412f9636b4cbb32e3/grimp-3.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44a3a576bb0c5d0754108f0b6cedbeaee66a123112c71a8f4796656c9e52c5cd", size = 2196892, upload-time = "2026-07-03T12:07:43.574Z" }, + { url = "https://files.pythonhosted.org/packages/27/e2/8a7502011e324902853df7daecc3d6d651464920aad93a1a7ad9bd833105/grimp-3.15-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b833af82a197371c0b0b69ec600413d9b50109fec2f8e1e1317b1c226a3e07", size = 2348919, upload-time = "2026-07-03T12:08:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/0570ff9ea16c8280de2e84b52006dd588b208716e73794cb332c43d1eb72/grimp-3.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6167c3d6e30ac8ff0a32260edf51fad5c22ec77b04e85a8a0042c1ba78bf6846", size = 2609501, upload-time = "2026-07-03T12:07:53.841Z" }, + { url = "https://files.pythonhosted.org/packages/33/14/34482976316834848df5eb68ec2283c13f84bfab12b21b8c11e4f3442e77/grimp-3.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6c18582eb87e148da11ad6549ec006186460ed29959afb15e093c34696d0134", size = 2329567, upload-time = "2026-07-03T12:08:04.663Z" }, + { url = "https://files.pythonhosted.org/packages/78/1b/2803b1234cf5e663c00b9fb2318a93b8db3831942ff053276234fa6731ab/grimp-3.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c941ed53672f9d136f898bf41557062dc4ed4c4fd10c88e47499e7db1c436ec", size = 2277972, upload-time = "2026-07-03T12:08:29.175Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd25b4a61851db6ca82d4007e1981d9832dff7c34c7b02a975e0bf3da89f/grimp-3.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4d46417bd6fffa56814b1cf8ffe756fe761d8e5eb691f1d7aaa8a0aba37af05a", size = 2440215, upload-time = "2026-07-03T12:08:56.046Z" }, + { url = "https://files.pythonhosted.org/packages/7c/44/d065613d4cbdc108c33db5978351303cfcedfa2f6d55dcff432c722b3568/grimp-3.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1f39ca69733dc9cc2f4e7c6647ebbb9a85e1b615b6b808187b0fc6dc6f3a9354", size = 2471902, upload-time = "2026-07-03T12:09:06.502Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/1eb96d3b2f61d04a5bfe0502216e248cfa0dfe34697cf56ab74e762983d7/grimp-3.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dcbea03a4502291b6014530267a6447365557a7870524202034a9ceac4f26fdd", size = 2509411, upload-time = "2026-07-03T12:09:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/89a69f121848b2324403ace3e3487c0074bf1257507b743fbebd281d270f/grimp-3.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c88386fac08a96933387d4058ef246b191872f7bd75a01c8655b70c8d1a0433a", size = 2519817, upload-time = "2026-07-03T12:09:26.999Z" }, + { url = "https://files.pythonhosted.org/packages/6a/18/9916c71ad9039e74148d9d3b8da4b6cf87900290edc0e7be1a891c840b66/grimp-3.15-cp311-cp311-win32.whl", hash = "sha256:788e1f019052fd6b4dc24ed9892de4f0f24e8e845bd01d146ee549515f70a9bc", size = 1856929, upload-time = "2026-07-03T12:09:46.611Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/6b349a4aee939b52a244c87e49f584ecad7ca4db67aea70d2d49c7709c26/grimp-3.15-cp311-cp311-win_amd64.whl", hash = "sha256:bfe2d28a94eb97db739382c4d377c8c014020eb6cc1599bcf950c9a2a60fce9d", size = 1988289, upload-time = "2026-07-03T12:09:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928", size = 2143914, upload-time = "2026-07-03T12:08:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db", size = 2089296, upload-time = "2026-07-03T12:08:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840", size = 2254829, upload-time = "2026-07-03T12:07:34.365Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256", size = 2193553, upload-time = "2026-07-03T12:07:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d", size = 2345941, upload-time = "2026-07-03T12:08:18.082Z" }, + { url = "https://files.pythonhosted.org/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886", size = 2604369, upload-time = "2026-07-03T12:07:55.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82", size = 2326619, upload-time = "2026-07-03T12:08:05.931Z" }, + { url = "https://files.pythonhosted.org/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d", size = 2272955, upload-time = "2026-07-03T12:08:30.488Z" }, + { url = "https://files.pythonhosted.org/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0", size = 2431271, upload-time = "2026-07-03T12:08:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73", size = 2466937, upload-time = "2026-07-03T12:09:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f", size = 2504734, upload-time = "2026-07-03T12:09:17.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6", size = 2514448, upload-time = "2026-07-03T12:09:28.401Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e", size = 1855806, upload-time = "2026-07-03T12:09:48.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9", size = 1985556, upload-time = "2026-07-03T12:09:40.786Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197, upload-time = "2026-07-03T12:08:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342, upload-time = "2026-07-03T12:08:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509, upload-time = "2026-07-03T12:07:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831, upload-time = "2026-07-03T12:07:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289, upload-time = "2026-07-03T12:08:19.458Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407, upload-time = "2026-07-03T12:07:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769, upload-time = "2026-07-03T12:08:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498, upload-time = "2026-07-03T12:08:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075, upload-time = "2026-07-03T12:08:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361, upload-time = "2026-07-03T12:09:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500, upload-time = "2026-07-03T12:09:19.585Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834, upload-time = "2026-07-03T12:09:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743, upload-time = "2026-07-03T12:09:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546, upload-time = "2026-07-03T12:09:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4f/311bd40c02d61eee0182cb8c9b6ded37d42bed9a334e5ba4dacbe1c4c997/grimp-3.15-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6db0b30683612be4571b6b6208e1b39b77faa54566c5ca4f086d64cc783e4864", size = 2144799, upload-time = "2026-07-03T12:08:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/86e941cc26bd3419b5e2abb8b48fa35b850e5508f14e033f3ce28bfce608/grimp-3.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e731a1f8a192a802e04d4018d6cce9f4a12ce48b6b73f43014bd4e0a5d04a8e9", size = 2090802, upload-time = "2026-07-03T12:08:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/6c/09/bcb4e380596e533ef9ebb3f164ad3cc113fde62318ca5af71a27886b1612/grimp-3.15-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb607ee3e16440fc59ec2b49eef1b6dbbe701af9e99990b6491e6f120bd60b5", size = 2255870, upload-time = "2026-07-03T12:07:37.207Z" }, + { url = "https://files.pythonhosted.org/packages/c3/84/361cebbd7b14ce15d93bfb65e2b0ff30287252ffa6ab0b7fe08e029eae6c/grimp-3.15-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0492b9f1120176146d81e51aa0691aa6c004cdf5192ab309a221f9b223dedfc", size = 2193359, upload-time = "2026-07-03T12:07:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d6/c4dd785e149c3c66494822c8b46f0a36cbdf5a5400eeb2172e0a8b029d0f/grimp-3.15-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bb3dbb0471179e732400fdf31c8c8d0299c88d13dd3a3bbe77ce54fb3f1b545", size = 2346350, upload-time = "2026-07-03T12:08:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/4b/470922cb9d0a4b0436bb298801f770c98c6953374ff84e545dd7a196aa66/grimp-3.15-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b578512dbaee7eba2900d8078eaf1f7f78aa7616c609b0849b8403012ffddda", size = 2604959, upload-time = "2026-07-03T12:07:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/061dd80f5f0abb3ac53b29ade25ffac3e464e6853e8ce31dc6c6a33a06a9/grimp-3.15-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a550a14cd2f8123fb06814b705f3af093fa1728b244f21ebcccc8d0da0f851", size = 2327185, upload-time = "2026-07-03T12:08:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/32281e7b5fcd5622f4666b36003b9931ca0e112f2955f09458f198a30f65/grimp-3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db5ebb94ec356eaa5242145c993bc84c4b52d0d2c6980dfcd12b22d51c5b2c46", size = 2273241, upload-time = "2026-07-03T12:08:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/b4f6ae15484541decbe4f12cf39a4a769352cb06332e428cc8e64aed4a32/grimp-3.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e03331418d7fee746e2447ecf5296986e6f094ef15fd25125efad2328844edf7", size = 2433114, upload-time = "2026-07-03T12:09:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/a2/7bdc628435a1e908aa53b351ce031a6134efd18245c4a5a4c28e1e6d19aa/grimp-3.15-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4cc91cb69e73e7899feb6ce73747f4dc092c2bfe2653f6cba69a398cd1dfc6ea", size = 2467235, upload-time = "2026-07-03T12:09:10.677Z" }, + { url = "https://files.pythonhosted.org/packages/5d/18/77853f5693d607d5f49c7e3cd58e482ef8362ea9cf86d0fcb108e4168195/grimp-3.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a848d5b4b656c1e3a28cce0d399f2790ecbeb2eeb78bb49896018614c87219f3", size = 2506552, upload-time = "2026-07-03T12:09:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/a094d7dd7115e8764e8069d5d3a44045c333b41d98a5746e99ec712b4b18/grimp-3.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:208ef56397cabfab52864b3d8a461fb5015a056fede1be4587e4453b13aa8c2f", size = 2514255, upload-time = "2026-07-03T12:09:31.383Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/013c640df50968b5d25802a5f01b157514d4e0ccd48c37c63947610a0e05/grimp-3.15-cp314-cp314-win32.whl", hash = "sha256:74d32fae3d222888f6b61579998043579dd810ed948785896e552906cbfdfcb7", size = 1856182, upload-time = "2026-07-03T12:09:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/648a1d77dfc5c2fd24fbdca0a45ee1c24669d981d12652424f5c34e3352c/grimp-3.15-cp314-cp314-win_amd64.whl", hash = "sha256:6b36e179d485c797e7fa234803620af752039fa27a8c7e3182333d7c96041f70", size = 1985451, upload-time = "2026-07-03T12:09:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/fd88ea799d181c22ab47c6cb328e7be3e196c8395444af89fd8da401cf6b/grimp-3.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9107d1037ee6e250ab7a15e1b758e0af76c841c19838b3cc688885a0b3817b5", size = 2253182, upload-time = "2026-07-03T12:07:39.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/d9/25377ba259772edfa97e35e265d89eb89b966a82652967c8479902741067/grimp-3.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b88975b2882edc55e8946640f7b36dfb83cce1303cff5f8528c3f4819d7fbb07", size = 2191822, upload-time = "2026-07-03T12:07:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/cae8ca43e05aa5217ca2e17ffbda77e86cb215c1a9a03592c110c0162f27/grimp-3.15-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fe01a5393e415e3d99b22c7dc6c6a9cc1b633714707d1c6b56ecbaccc2132f5", size = 2344413, upload-time = "2026-07-03T12:08:21.976Z" }, + { url = "https://files.pythonhosted.org/packages/39/32/feea8fec71e62394013865314854ccb3d7bb54f226564fd267e6662f509a/grimp-3.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd4d70a0de010a9b452b59326a00574f7488dd1333d003df624114e5e00e877e", size = 2602856, upload-time = "2026-07-03T12:08:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/46ccdeb698398264d774273a9b8d9b013c8d23127d05371489b22dcf3ea5/grimp-3.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:204beed673ff43ab4ebe52ff4efd29b80025ec777136a62109afb69792d7fae0", size = 2326095, upload-time = "2026-07-03T12:08:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/69/ae/f98c2c964a850ab80341d02576ef8681e75178f893ba2c73dc03e6563925/grimp-3.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602be339f8a4d368d71758814e5505200cf665818eb0f9a2cc74d71ecc8cf1c", size = 2274375, upload-time = "2026-07-03T12:08:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/9355cdb28fab458fb8defca6406de303d78af617aa0d8c9ae5253b4e5a8b/grimp-3.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:077c27a1e6ff3baf23a8caaa1d74cbbac27024b069956dd6ca5fc3acd43ddb4b", size = 2430307, upload-time = "2026-07-03T12:09:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/edfa7f8064c1a3502fe4aba56c07bd122e94e329188939d52c02bd7e85b6/grimp-3.15-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5516fc38899f4396eff68e6b1997310b4de2d0155e3fad9f545e666c993985b3", size = 2464014, upload-time = "2026-07-03T12:09:12.185Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c2/153b5cc3106814504b4195c7d4c178d85732d35b8895185a02112b8f9a03/grimp-3.15-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbe8e14cc61af4eea8e7ed6f2108828101f57fe86273d5b61cff044b69e6c6b5", size = 2502010, upload-time = "2026-07-03T12:09:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3f/ae4ba51cf484030e3d15b3304eb7ab9039fe91fb35ff5e90d60fde135bff/grimp-3.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1763b7b48ed6c9e6de838d0d64f19510a392235266cf2240c9be9cc25ca7c54b", size = 2514787, upload-time = "2026-07-03T12:09:33Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/0fb2d2bc9fe6bce899947802c94531c24e581c715db19216b941c1b2422f/grimp-3.15-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:116c3a6dba61bd302919b82cb5d603f5977e321d80d7de3c7a1265357ab9dd66", size = 2345635, upload-time = "2026-07-03T12:08:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/00/ac/05d859a62ed9282f43a0f0962da230c46a2eb3d3ecb5b39117b3b4888405/grimp-3.15-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc2e5065d35047729e41a55c9c3eb99810cf322fe3b3e89fa126f306ce6b3b5", size = 2273647, upload-time = "2026-07-03T12:08:36.139Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dd/f726316f54e29da4f24d545d6299e1ea0accfbbf52729077fd4a619de055/grimp-3.15-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b38327673df49203cff0ebcbbf078999a80f0b11bb654760059f6f00f56f64d6", size = 2344080, upload-time = "2026-07-03T12:08:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/523a8f969f0a0b0a8ce43fbe8bc7a04e90f52724efc85f3305f1e07ae626/grimp-3.15-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965b37dad2f73164a103381c9fd1255bb3b2f6021ca2f38ffe70dd00fdc0fc55", size = 2275041, upload-time = "2026-07-03T12:08:37.582Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/562e56ba1abecc40169aa40fbf62ba5dcda0f56d953b53eeeb5e43535fb2/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:391bca4c4f1c4dc58f10fadd8e4bba1ca1dbf848a5adb4e66907258135aa0b26", size = 2263708, upload-time = "2026-07-03T12:07:40.836Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6a/2fe608f847630675b06a4e635024771b897f4ea9d1c0d3063f83ed1b7fc8/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08f7e663a465b524c5c0e121e6dd759f282ce35f406bc95bd68deda63a604361", size = 2198790, upload-time = "2026-07-03T12:07:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/49/8365239abbe735d8e103dc19f485e57383f8a435a0ae3b2ba7576e2d8bb7/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5625a037e9fc04c6371effb213efb827d225f5a64e04bff5f448adf180193da9", size = 2352309, upload-time = "2026-07-03T12:08:26.377Z" }, + { url = "https://files.pythonhosted.org/packages/5b/76/c1679a9eb0ac4ac2fc943fdcf47c1d59a200fd8daac3db6bd72c63e45cf9/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4651d29d4ee69bd5a1d9aa963ac73e420da1386f0a2263b8acbc0ae495fda12c", size = 2611854, upload-time = "2026-07-03T12:08:01.912Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cf/56e47ded5188088ad0b9455cd74182c536a2f6fed29f1d4c0d7b20617b5d/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f73dcb7fb94b3a450dea3a95d63e179a715f315e2e0ff5b78aedf8b3dad8707c", size = 2331437, upload-time = "2026-07-03T12:08:12.758Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c8/47cb1dcf0819506915bb5a20dff70bddd518199808c67e1aba730a2fedbc/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:228e044bc458ee840ab20e1839efbecdecdd151d22ae3902a0f03aa94de772df", size = 2279755, upload-time = "2026-07-03T12:08:38.982Z" }, + { url = "https://files.pythonhosted.org/packages/38/90/6202e5fa8ffd3648b2fbcd1d6028c6224a278f557073cc3f8094f0a01cd5/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bbaa9053652d29733ff88277b554e716e5acc40b37d12841c972779196d2c0ef", size = 2441806, upload-time = "2026-07-03T12:09:03.85Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b6/2d82c6b6161169b0cb87ccb746e3a590c5a9fb54cb7c59d6c657ed731394/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:12fd9e675e6fcf633a5d14f6cbeee4b667a3d3307c91653ccc1a5aef03f53ec6", size = 2472941, upload-time = "2026-07-03T12:09:13.481Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/fb2906ed60288b236867829f9b3ced558a2725225ec0ed2354a7124a0474/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0012653561d80b4e4972ac246deb569051a1fbf3880d620a9475e7159722bbab", size = 2513392, upload-time = "2026-07-03T12:09:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d2/ec5f501d9623fe8eb2d517ab7528708b672dba51322a931515b58b659f6f/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e438ae00dfd554c6262e92c945ec233c5e311cc99fdb0d602c6063b4f5fc3d9c", size = 2522530, upload-time = "2026-07-03T12:09:34.518Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.161.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/96/35022710908b82d20af0c57ab8be4d4a3e4045a74d3ae9c806eb4443297c/hypothesis-6.161.0.tar.gz", hash = "sha256:c357150f826fc7492304621d535a23e8f1b7440a3b10a337c23bea52102e2e7f", size = 485855, upload-time = "2026-07-23T07:17:40.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/b9/c8b80fb7517e6f3039d0ef9a5df6aaee53667935e62c6c9d9d635436708d/hypothesis-6.161.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c9f877e288dfb46207b5c3bfcc8ab28e2613e529be8621816423960403377286", size = 766230, upload-time = "2026-07-23T07:16:54.313Z" }, + { url = "https://files.pythonhosted.org/packages/90/16/e5c1287fee682f7c1e9afccc91c07ae36a6855a5863d0b3c15d7bfa0b322/hypothesis-6.161.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b7b6980265cb04605b2b42132dc8ef5735917fc482869298611a49d2e06dc322", size = 761883, upload-time = "2026-07-23T07:17:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/d9792e24e53f82bb1455935f79cf3b56ccf556fac325c8a90f7968180706/hypothesis-6.161.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f10253cac459922cad3bc09e397718188e57dffefe810e5db1d444e7113d7fb5", size = 1091083, upload-time = "2026-07-23T07:17:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/f7/15/33cba9c6bee8a80ab18f48e40669038275bf4b82e8dfb0fa9fe716925265/hypothesis-6.161.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:232ab78dd8cb0a891914d20697e0fd340ca1e6d4d8d5855df5e433d8161173e2", size = 1140530, upload-time = "2026-07-23T07:16:14.195Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f6/30c421822cd65b8edd56b2b90a5e1acf4a624d5619067957668027ab7e46/hypothesis-6.161.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:264336ca1e9f31edd24a8885c4020db8e18986c51a255613db28c076ac4289a8", size = 1132680, upload-time = "2026-07-23T07:16:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/db/d18e45339b2ffda57a52395e03df6166bc4e428bc90e878dd3f20a7423c0/hypothesis-6.161.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:323aac4347e6ffa86929407b7f386cbf54e1c66faf923ffd6b4b86c21815d117", size = 1264892, upload-time = "2026-07-23T07:16:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/f4b7600272fbc9a2b28c95b96059d89cf5093ae705e52360a818df8154a5/hypothesis-6.161.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5dc83c8e83b9d133babcf9468703cece0ddb25413983561f2516927edddcc52d", size = 1307563, upload-time = "2026-07-23T07:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/d6/28/fa4f2d50c7434076ec7653a8372750531576e4d11cd5f3316ad83e12a553/hypothesis-6.161.0-cp310-abi3-win32.whl", hash = "sha256:75a3036121e6ae2cf55b7433f1953834cc9eca97c2e4e4be3369fe080c86b237", size = 652098, upload-time = "2026-07-23T07:16:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/93/5c/6811eee772a5cc33f9bf863326983f493977e9aee9535c8dbb6c172575d3/hypothesis-6.161.0-cp310-abi3-win_amd64.whl", hash = "sha256:e3f5b2527789a748b54d6ef46b2b042f3225164d54c24ba74a137ddb10a39407", size = 658272, upload-time = "2026-07-23T07:16:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1f/07054e18c7696fe5aa127952e1ff2b74c7917100e0998d77405f9aea7bbf/hypothesis-6.161.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2658a95ac7cf1943b9397725d58481373a1709e79b6867628108f695b202ff3f", size = 766737, upload-time = "2026-07-23T07:16:27.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/6a04fbda729f5889b486aa3b20912ee6c7391c8db0a2926346a1b3ad0834/hypothesis-6.161.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:584906b4f8f9504d7c9d6fd3c42bf991fa42ab64c3a4490a84d6e4acf69fe7fe", size = 762514, upload-time = "2026-07-23T07:17:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/12/609a956b716ab20cb81263d8e0cecfe442fb46e4d54ae9b82b453f2465fe/hypothesis-6.161.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87a996d0a1c865173ed67a0b3537efbe6736bcf9f58ac28826712a48ed8d2d23", size = 1091414, upload-time = "2026-07-23T07:16:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/fc/99/093bc8aca6dddc05e88dd0baa64c5586e031737d798c66e770fbeb034510/hypothesis-6.161.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0250e209ed2401cf6c80c956c5c1906097b537d05fa748f03e9c53060a837d3f", size = 1140888, upload-time = "2026-07-23T07:17:07.579Z" }, + { url = "https://files.pythonhosted.org/packages/65/fc/f681828dc1ca13243622eb6ddc3f7370efb1cb7931ea7505f8ade4d37ba6/hypothesis-6.161.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:210024a6e84c361803545ca055218bcae06e17fcb53d5956db7db6a1e14d7769", size = 1265245, upload-time = "2026-07-23T07:16:56.103Z" }, + { url = "https://files.pythonhosted.org/packages/53/d4/98deace31c31369196ece4d6f32bb8c1820bf5cf5584721bda791bffa0cc/hypothesis-6.161.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3e39be8141496a73115f1ea2c8fd7146f78d26f148e884f3d4a68dec06418a0", size = 1307841, upload-time = "2026-07-23T07:16:59.457Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5d/d9bbe1fc769e46b21d368497d4739375fcb17270eba611bac1117473e337/hypothesis-6.161.0-cp311-cp311-win_amd64.whl", hash = "sha256:e234937af9de105e28dc7ffdac5d7932265abb5881f0264aff01d3515baee732", size = 657953, upload-time = "2026-07-23T07:17:18.224Z" }, + { url = "https://files.pythonhosted.org/packages/01/f5/b01692ea422f9995260435a5c2a425dd558ceb0b6544cf4037d312b52927/hypothesis-6.161.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8cf6149e5bcb1deaa3d029280c2c9a47fede2186ea58d8dc3e71864428b748b5", size = 767859, upload-time = "2026-07-23T07:16:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ea/147f96f352a1c62f4fa4d46ec2d8b103d39cafce236826531aba9dfbf6fc/hypothesis-6.161.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:439b9ccefc2b87b9752752d2ec6c9d40f92de2acaf07f4da94c2ebcbde4eb660", size = 759491, upload-time = "2026-07-23T07:17:37.046Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/c1ddd72ca9af054332a05bdb19b666b1dbb4ff904cac2b6e04bc483518fa/hypothesis-6.161.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35c577d4b635b914e2cdd59d144448e0de82e47f8422d8373cbb48daa5571686", size = 1089838, upload-time = "2026-07-23T07:16:52.774Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/0d9adc2ca7fe226e4f81e0e9ff88ab9a2025395a705e55e8447e8833b2e2/hypothesis-6.161.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:894af9777f2fd51bca9625fd07573456c1fa67b6bed3f6aa1659cb60255594e0", size = 1139915, upload-time = "2026-07-23T07:16:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/50/48/c557ee9899ab58e6d373712dcfe019b4eb65035f5bbffcb7624201984bd4/hypothesis-6.161.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c9f15d18d3041216d80d2a3203c1b8881efb7c20296f068651e2ad34f3852392", size = 1262692, upload-time = "2026-07-23T07:17:09.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/37/8eede820af48d8f7a73c0741c659b4839ffe9825b020affe43d52f58acb5/hypothesis-6.161.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f5588bde17a517c943b5ef6172cacfce6ef2d542c9249cc1bd86ce3c4c07161b", size = 1306904, upload-time = "2026-07-23T07:16:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6b/16282f58b92b6698dbed7b23d7015fb9f7d5dfb78e7ba2e4b44c88116bca/hypothesis-6.161.0-cp312-cp312-win_amd64.whl", hash = "sha256:c7994d32bcca19b7cbf3c087172245fe9f4d55b21bccef81693efd5a0637d4d9", size = 655392, upload-time = "2026-07-23T07:16:16.988Z" }, + { url = "https://files.pythonhosted.org/packages/27/13/50b3fabaa9a52f82905d6bf70b0027cbc61972f0b60dcf68506e4a85674f/hypothesis-6.161.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5217e3df508ab71303bf1288b412548e96483eef195f6008b6472770e4fe4ed", size = 767734, upload-time = "2026-07-23T07:16:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/72/0c/0176f7722896dffef2aa677699df75cd2a53ed00d4dc6b2959c40c1c8389/hypothesis-6.161.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad4899908abec1888d0d16eb70acd54c9d8198b58410ec26a346ba35d384fc0d", size = 759394, upload-time = "2026-07-23T07:16:57.551Z" }, + { url = "https://files.pythonhosted.org/packages/97/1d/5ade6e0c80ce8160bbcd55c300d95a5450cdb82fa04fdcdd8a33f6198441/hypothesis-6.161.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fee553e5150af6d66ee058f3ccbc3b5b83e6df62139e8935abd0510254a2d4a7", size = 1089752, upload-time = "2026-07-23T07:16:34.18Z" }, + { url = "https://files.pythonhosted.org/packages/99/02/14c6d54e60159ba9991a52b14ea5a9b6935d4878ffe9d0a8fabd2d166767/hypothesis-6.161.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f8110cd815f3a79e2351700654c21771eec47d98a78db56cc81879d41f08ed1", size = 1139731, upload-time = "2026-07-23T07:17:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/36/98/c099c382b0fbf6dfd209d35961e6ee9739ad1d787288a40eb364b278217a/hypothesis-6.161.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16aacb26a277d25da7466f0588c2687811334455753d513c55d8ca4dbbc5174e", size = 1262736, upload-time = "2026-07-23T07:16:07.875Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/6b1fbde6e2a1c9bd54acb1e5d8fa866c6fef59a829a67ca310f5d12a8fbe/hypothesis-6.161.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eb24cf5f7f301ad3db14caa4462ebc2e693fe38815d793ff6dbc116820b18dff", size = 1306628, upload-time = "2026-07-23T07:16:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c2/033da1694f956f0c566b12a1f0667138ad06a3b0a67837f17c6873cc2513/hypothesis-6.161.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4f715f5598444a0d569aa8a3e74ebc14a46c67a873193db38c8542b2838e91f", size = 655355, upload-time = "2026-07-23T07:16:48.054Z" }, + { url = "https://files.pythonhosted.org/packages/2b/26/29582b8ba467eedf270515422f41cb564d06b4eef38bdf06e236cc841546/hypothesis-6.161.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a046954e17a1edd20b6c95e9d29f1df7bc20ccab94c01aa8b4177552896230fb", size = 767928, upload-time = "2026-07-23T07:17:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/bb7cfd851f6b0f4b0130485785a3447621523116b89f8d82042fb9897752/hypothesis-6.161.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2d503b0fdb916371d536b33fad0c4f909846af2fc4273d3049ca6fe661aa81ff", size = 759542, upload-time = "2026-07-23T07:16:41.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/bc31d4aa5840c2438e011a615095b0e2fae5de94c3abfc18a01686825ea7/hypothesis-6.161.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df011a94870dc3e1b5fb4fb8d2c68dd641b412a3b73050288d85af1467c9a689", size = 1090304, upload-time = "2026-07-23T07:16:19.817Z" }, + { url = "https://files.pythonhosted.org/packages/51/ee/6304f6184aee6a1b91fff746a767b4f3aab58c29e48e64cc16d56fc6dedc/hypothesis-6.161.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4678e3988503b3bd5be0ed995f84cc15ac4f99c168bade32456a08c04868f3f3", size = 1139915, upload-time = "2026-07-23T07:17:14.565Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6d/23efce26bf7f1773346732c58a23cbe33ed4f171da1bb3aa11bf349fdb4c/hypothesis-6.161.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74c6e5b5623f37eb6af8be6f861d138fac3ee3528ee30c3b48ff11c39f7be4b7", size = 1263070, upload-time = "2026-07-23T07:16:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a2/e0b4bf410630f16661eea6fdf9c3970e47c749a837de12098d63c81bb01b/hypothesis-6.161.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b841c25267ab360812a523d1861e0b0ed0f5cc4e6d7bcecc9d9eddd3f835aa0f", size = 1306929, upload-time = "2026-07-23T07:16:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/03/13/58047eb148a31ae7cce26ec0b2f0e980c46874c6673c1110c53684ba181a/hypothesis-6.161.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a0f3830c1e816e34bd8cd940244c8c877dedc2ecfea771d2ecea252bc35eb21d", size = 599455, upload-time = "2026-07-23T07:16:25.552Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0e/a206013edd7dfd44b9a81ae1946a1ea30d878974850d60a61fa128cc170d/hypothesis-6.161.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1d38f05acb9c25181157f1756f5faaa1759b4641ff6b32cb1d2ab1d55d6af2d", size = 655306, upload-time = "2026-07-23T07:17:30.273Z" }, + { url = "https://files.pythonhosted.org/packages/e2/74/32d224a0ccf4ca9af6acc1d805047e12cd13105e0769d54ac00a86f25850/hypothesis-6.161.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:81570959521eebd0172ea9132ffab71b90050e7cd44d045da67210aa9a594376", size = 766503, upload-time = "2026-07-23T07:16:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/8b61c3490fd8195a25fdc37e951ccba3ae4df2c74839ffe6a6d5720fdb79/hypothesis-6.161.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:46a6039181337b85a995666e9bf87cc2390282720ba345479bb9be9c867914da", size = 758013, upload-time = "2026-07-23T07:17:26.71Z" }, + { url = "https://files.pythonhosted.org/packages/d9/11/ac4ab15ec4586a23bb4e2acdcbed814517e341f2940eeb74fbf428dac243/hypothesis-6.161.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f2d4141c952f522d6aae0e493d170f9b0127c001319bd312ee9cfba7ed419d4", size = 1088871, upload-time = "2026-07-23T07:17:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/27/13/fd83965bcdd44dc5002c67fe8e8e2e974ed45c82dc6864e104c1c70093d5/hypothesis-6.161.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ed3e1b9c036954bfabe64899072a18e6b5113703d32a96645c6656a8cfc43e", size = 1138801, upload-time = "2026-07-23T07:17:35.354Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/a4ad5f3b0b805fd2e583d193e2a762cd413465b524ef07829452521dca6d/hypothesis-6.161.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aba1508da6c317819305e875bf6c4d4dd0922193c76416d4cc907447ebe08fea", size = 1261305, upload-time = "2026-07-23T07:17:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/38/54/35e1b62ece96c24921e9a1e811179d54a6429d9611a2cd49504f5b95382e/hypothesis-6.161.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:981acb3efa88df363a0d31e0f4bacd36ef739104eee789a6b12c7f7200a457fc", size = 1305689, upload-time = "2026-07-23T07:17:21.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/6491e9a36d8e8df67a3b9c3eeb5a85c12c6b0d5302b5ce395b5427698b52/hypothesis-6.161.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e71e229f2dec694685245b8e55e908855b475c17ab8337bcf848768b4c32aa97", size = 655436, upload-time = "2026-07-23T07:17:04.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/0782e45562fb091cd75bd12f69932c8aa55a9e3bb699599f43e5258d013c/hypothesis-6.161.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:33a19886dc05b489f0ab632c89b8ca9dc6f89a0b380b6405671cecb2b0d5b5c4", size = 767674, upload-time = "2026-07-23T07:17:16.37Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/eaccb5375ed83396be3153857f9de808b5d5ecc001fab8e30bf8e30d33d4/hypothesis-6.161.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b4fd38d0e757ae290583774b3695ab0d7f0da80e924c6473de84483449c6da8d", size = 763579, upload-time = "2026-07-23T07:16:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/99/48/31ca6b9414cf30388ba825c740b13ab74f6afcf856a194a9256f7f1ca38d/hypothesis-6.161.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59dc6871e2fbbfd2d28e7e6f33d19bd8513a3b7510bd0b224a59bbebdd5cc1b3", size = 1092394, upload-time = "2026-07-23T07:17:01.136Z" }, + { url = "https://files.pythonhosted.org/packages/75/1d/709c03af162418c0b3e0cf624549b13ecacd8df0f2ca09d53214702cb1f8/hypothesis-6.161.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55ff16ec4b4e98bfd97e9a2a44905eaf7a3ec8f3f157d8c7eac2385a88059a29", size = 1142170, upload-time = "2026-07-23T07:17:32.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ae/63458a5f80db8433beb7556482e3de1a6789b6c469a5f2f99c21aaa7fdef/hypothesis-6.161.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:77ae374d9ed7046b15443053b11f5b05e185bca24b3b849c3f473a9e4cc85451", size = 659069, upload-time = "2026-07-23T07:16:12.922Z" }, +] + +[[package]] +name = "import-linter" +version = "2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -136,6 +406,66 @@ 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 = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + [[package]] name = "librt" version = "0.13.0" @@ -211,6 +541,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mutmut" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b0/ebcae42b90b07756b7aa10c4176835f436332e6c1cb28bc35bae83462382/mutmut-3.6.0.tar.gz", hash = "sha256:bcbd3e4d0d2d4edf3dfb42955417279a8866a3dbbcb87d619f2f3fd0ac7fafda", size = 51538, upload-time = "2026-06-06T07:44:51.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5a/a0caa3f9db407b5d12c311bd4c87aa67fdd6e3f329377149e303108a1c51/mutmut-3.6.0-py3-none-any.whl", hash = "sha256:a9f5b8dcf6cbf9496769d7cf8bdbba37a0ec709ad98f88d103238b62f10bdf37", size = 47770, upload-time = "2026-06-06T07:44:50.038Z" }, +] + [[package]] name = "mypy" version = "2.3.0" @@ -290,6 +687,15 @@ 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 = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -496,6 +902,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.22" @@ -521,6 +964,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlglot" version = "30.12.0" @@ -530,6 +1053,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/9e/82a390ecc85f066ff80affa01d195f744e3de60ad4d695b8de31c9a66da3/sqlglot-30.12.0-py3-none-any.whl", hash = "sha256:86cccc610073c645c03e72b55b60ae0518aa3253a7fc3bd56551370d003c6554", size = 707583, upload-time = "2026-06-26T14:09:38.525Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518" @@ -559,3 +1099,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +]