diff --git a/docs/generators/bigquery.rst b/docs/generators/bigquery.rst new file mode 100644 index 0000000000..658420441f --- /dev/null +++ b/docs/generators/bigquery.rst @@ -0,0 +1,59 @@ +BigQuery DDL +============ + +Overview +-------- + +The BigQuery DDL generator produces native `Google BigQuery `_ +``CREATE TABLE`` statements from a LinkML schema. It extends the SQL DDL generator with +BigQuery-specific types (``ARRAY``, ``STRUCT<...>``, ``TIMESTAMP``) and supports +BigQuery table options such as time/range partitioning, clustering, and table descriptions +via LinkML annotations. + +.. note:: + + This generator requires the ``sqlalchemy-bigquery`` optional dependency. + Install it with: ``pip install 'linkml[bigquery]'`` + +Example Output +-------------- + +Given a schema with a ``Person`` class, the generator produces DDL like: + +.. code-block:: sql + + CREATE TABLE `Person` ( + id STRING NOT NULL, + name STRING, + age INT64, + aliases ARRAY + ); + +BigQuery Annotations +-------------------- + +You can control BigQuery-specific table options via slot/class annotations: + +- ``bigquery_type`` — override the column type (e.g. ``TIMESTAMP``) +- ``bigquery_partition_by`` — field name to use for time/range partitioning +- ``bigquery_partition_type`` — ``DAY``, ``HOUR``, ``MONTH``, ``YEAR``, or ``RANGE`` +- ``bigquery_cluster_by`` — comma-separated field names for clustering +- ``bigquery_description`` — table-level description string + +Docs +---- + +Command Line +^^^^^^^^^^^^ + +.. currentmodule:: linkml.generators.bigquerygen + +.. click:: linkml.generators.bigquerygen:cli + :prog: gen-bigquery + :nested: short + +Code +^^^^ + +.. autoclass:: BigQueryGenerator + :members: serialize, generate_ddl diff --git a/docs/generators/index.rst b/docs/generators/index.rst index 90a69abb0f..39f628966e 100644 --- a/docs/generators/index.rst +++ b/docs/generators/index.rst @@ -93,6 +93,7 @@ Generators specific to database frameworks, including SQL and graph databases. sqltable sqlalchemy sqlvalidation + bigquery typedb Others diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index e0729c8347..abce3b4510 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -10,7 +10,7 @@ authors = [ {name = "Sierra Moxon", email = "smoxon@lbl.gov"}, {name = "Harold Solbrig", email = "solbrig@jhu.edu"}, {name = "Sujay Patil", email = "spatil@lbl.gov"}, - {name = "Harshad Hegde", email = "hhegde@lbl.gov"}, + {name = "Harshad Hegde", email = "hegdehb@gmail.com"}, {name = "Mark Andrew Miller", email = "MAM@lbl.gov"}, {name = "Deepak Unni"}, {name = "Gaurav Vaidya", email = "gaurav@renci.org"}, @@ -80,6 +80,7 @@ tests = [ { include-group = "typing" }, { include-group = "shacl" }, "duckdb>=1.5.2", + "sqlalchemy-bigquery >= 1.9.0", ] dev = [ {include-group = "tests" }, @@ -111,6 +112,9 @@ tests-rustgen = [ typedb = [ "typedb-driver >= 3.0, < 4.0", ] +bigquery = [ + "sqlalchemy-bigquery >= 1.9.0", +] tests-extra = [ "pytest >= 7.4.0", @@ -178,6 +182,7 @@ gen-sqla = "linkml.generators.sqlalchemygen:cli" gen-sqlddl = "linkml.generators.sqltablegen:cli" gen-sqltables = "linkml.generators.sqltablegen:cli" gen-sqlvalidation = "linkml.generators.sqlvalidationgen:cli" +gen-bigquery = "linkml.generators.bigquerygen:cli" gen-summary = "linkml.generators.summarygen:cli" gen-project = "linkml.generators.projectgen:cli" run-tutorial = "linkml.utils.execute_tutorial:cli" diff --git a/packages/linkml/src/linkml/generators/bigquerygen.py b/packages/linkml/src/linkml/generators/bigquerygen.py new file mode 100644 index 0000000000..a315e98c3f --- /dev/null +++ b/packages/linkml/src/linkml/generators/bigquerygen.py @@ -0,0 +1,313 @@ +import logging +import os +from dataclasses import dataclass + +import click +from sqlalchemy import Column, MetaData, Table # noqa: F401 — used in generate_ddl and get_sql_range +from sqlalchemy.schema import CreateTable # noqa: F401 — used in generate_ddl and get_sql_range +from sqlalchemy.types import ( # noqa: F401 — used in generate_ddl and get_sql_range + INTEGER, + Boolean, + Date, + DateTime, + Float, + Integer, + LargeBinary, + Numeric, + String, + Time, +) + +from linkml._version import __version__ +from linkml.generators.sqltablegen import ( # noqa: F401 — used in generate_ddl and get_sql_range + METAMODEL_TYPE_TO_BASE, + SQLTableGenerator, +) +from linkml.utils.generator import shared_arguments + +logger = logging.getLogger(__name__) + +# Module-level stubs so the file is importable without sqlalchemy-bigquery installed. +_BQ_AVAILABLE = False +ARRAY = STRUCT = BigQueryDialect = TIMESTAMP = bigquery = None + + +def _require_bq(): + """Import sqlalchemy-bigquery lazily. Call this at the top of any method that needs it.""" + global _BQ_AVAILABLE, ARRAY, STRUCT, BigQueryDialect, TIMESTAMP, bigquery + if not _BQ_AVAILABLE: + try: + import google.cloud.bigquery as bigquery + from sqlalchemy_bigquery import ARRAY, STRUCT, TIMESTAMP, BigQueryDialect + + _BQ_AVAILABLE = True + except ImportError as exc: # pragma: no cover + raise ImportError("sqlalchemy-bigquery is required. Install with: pip install 'linkml[bigquery]'") from exc + + +# Maps LinkML type bases to BigQuery SQLAlchemy types. +# Replaces the parent's RANGEMAP — every mapping here is explicit and BQ-correct. +BQ_TYPEMAP = { + "str": String(), + "string": String(), + "int": INTEGER(), + "float": Float(), + "double": Float(), + "Decimal": Numeric(), # parent maps this to Integer() — incorrect for BQ + "Bool": Boolean(), + "URI": String(), + "URIorCURIE": String(), + "NCName": String(), + "ElementIdentifier": String(), + "NodeIdentifier": String(), + "XSDDate": Date(), + "XSDTime": Time(), + "XSDDateTime": DateTime(), # → DATETIME in BQ; use bigquery_type: TIMESTAMP to override +} + + +@dataclass +class BigQueryGenerator(SQLTableGenerator): + """ + A Generator for BigQuery CREATE TABLE DDL. + + Produces native BigQuery DDL including ARRAY, STRUCT<...>, + PARTITION BY, CLUSTER BY, and OPTIONS clauses. + + Usage:: + + gen = BigQueryGenerator("schema.yaml") + print(gen.serialize()) + """ + + generatorname = os.path.basename(__file__) + generatorversion = "0.1.0" + valid_formats = ["bigquery"] + file_extension = "sql" + uses_schemaloader = False + + # Overrides parent defaults — BQ doesn't use normalised join tables or + # enforced primary keys, so both are off by default. + use_foreign_keys: bool = False + inject_primary_keys: bool = False + + # Optional dataset prefix: when set, table names are emitted as + # `dataset.TableName` for fully-qualified DDL. + dataset: str | None = None + + def serialize(self, **kwargs) -> str: + return self.generate_ddl(**kwargs) + + def generate_ddl(self, **kwargs) -> str: + """Generate BigQuery CREATE TABLE DDL for all non-abstract, non-mixin classes.""" + _require_bq() + from linkml_runtime.utils.schemaview import SchemaView + + sv = SchemaView(self.schema) + dialect = BigQueryDialect() + ddl_parts = [] + + for cn in sv.all_classes(): + c = sv.get_class(cn) + if c.abstract or c.mixin: + continue + + self._validate_partition(c, sv) + + cols = [ + Column( + slot.name, + self.get_sql_range(slot, sv=sv), + nullable=not slot.required, + ) + for slot in sv.class_induced_slots(cn) + ] + if not cols: + continue + + table_name = f"{self.dataset}.{cn}" if self.dataset else cn + table_kwargs = self._bq_table_kwargs(c, sv) + + try: + table = Table(table_name, MetaData(), *cols, **table_kwargs) + ddl = str(CreateTable(table).compile(dialect=dialect)) + ddl_parts.append(ddl.rstrip() + ";") + except Exception as exc: # pragma: no cover + raise ValueError(f"Failed to generate DDL for class {cn!r}: {exc}") from exc + + return "\n\n".join(ddl_parts) + + def _validate_partition(self, class_def, sv) -> None: + """Raise ValueError with a clear message if partition annotations are misconfigured. + + Called before CreateTable.compile() so errors surface cleanly, not as crashes. + """ + ann = class_def.annotations + partition_field_ann = ann.get("bigquery_partition_by") + if partition_field_ann is None: + return + + cn = class_def.name + field_name = partition_field_ann.value + induced_slot_names = {s.name for s in sv.class_induced_slots(cn)} + + if field_name not in induced_slot_names: + raise ValueError( + f"Class {cn!r}: bigquery_partition_by field {field_name!r} " + f"is not a slot of this class. Available slots: {sorted(induced_slot_names)}" + ) + + slot = next(s for s in sv.class_induced_slots(cn) if s.name == field_name) + bq_type = self.get_sql_range(slot, sv=sv) + partition_type = ann["bigquery_partition_type"].value if "bigquery_partition_type" in ann else "DAY" + + if partition_type == "RANGE": + # Range partitioning requires an integer column (INT64 in BQ). + if not isinstance(bq_type, INTEGER | Integer): + raise ValueError( + f"Class {cn!r}: bigquery_partition_type=RANGE requires an integer field, " + f"but {field_name!r} resolves to {type(bq_type).__name__}. " + f"Set the slot range to 'integer'." + ) + else: + # Time partitioning requires DATE, DATETIME, or TIMESTAMP. + # sqlalchemy_bigquery.TIMESTAMP inherits from DateTime, so isinstance covers all three. + if not isinstance(bq_type, Date | DateTime): + raise ValueError( + f"Class {cn!r}: time partitioning requires a date/datetime/timestamp field, " + f"but {field_name!r} resolves to {type(bq_type).__name__}. " + f"Set the slot range to 'date' or 'datetime', or add a " + f"'bigquery_type: TIMESTAMP' annotation." + ) + + def _bq_table_kwargs(self, class_def, _sv) -> dict: + """Build BigQuery dialect kwargs from bigquery_* class annotations.""" + _require_bq() + ann = class_def.annotations + kwargs = {} + + partition_field_ann = ann.get("bigquery_partition_by") + cluster_by_ann = ann.get("bigquery_cluster_by") + + if cluster_by_ann: + kwargs["bigquery_clustering_fields"] = [f.strip() for f in cluster_by_ann.value.split(",")] + + if "bigquery_description" in ann: + kwargs["bigquery_description"] = ann["bigquery_description"].value + + if partition_field_ann is None: + return kwargs + + field_name = partition_field_ann.value + partition_type = ann["bigquery_partition_type"].value if "bigquery_partition_type" in ann else "DAY" + expiration_days_ann = ann.get("bigquery_partition_expiration_days") + require_filter_ann = ann.get("bigquery_require_partition_filter") + + if partition_type == "RANGE": + raw = ann["bigquery_partition_range"].value + start, end, interval = [int(x.strip()) for x in raw.split(",")] + kwargs["bigquery_range_partitioning"] = bigquery.RangePartitioning( + field=field_name, + range_=bigquery.PartitionRange(start=start, end=end, interval=interval), + ) + else: + tp_kwargs = {"field": field_name, "type_": partition_type} + if expiration_days_ann: + days = float(expiration_days_ann.value) + tp_kwargs["expiration_ms"] = int(days * 24 * 60 * 60 * 1000) + kwargs["bigquery_time_partitioning"] = bigquery.TimePartitioning(**tp_kwargs) + + if require_filter_ann and require_filter_ann.value.lower() == "true": + kwargs["bigquery_require_partition_filter"] = True + + return kwargs + + def get_sql_range(self, slot, schema=None, sv=None): + """Returns the BigQuery SQLAlchemy column type for the given slot.""" + _require_bq() + from linkml_runtime.utils.schemaview import SchemaView + + if schema is None: + schema = self.schema + if sv is None: + sv = SchemaView(schema) + + # 1. Explicit annotation override takes precedence over everything. + if "bigquery_type" in slot.annotations: + return self._resolve_type_override(slot.annotations["bigquery_type"].value) + + # 2. Multivalued scalar → ARRAY. + if slot.multivalued: + inner = self._get_scalar_type(slot.range, sv) + return ARRAY(inner) + + # 3. Inlined class-range → STRUCT. + if slot.range in sv.all_classes() and (slot.inlined or slot.inlined_as_list): + return self._build_struct(slot.range, sv) + + # 4. Scalar (default path). + return self._get_scalar_type(slot.range, sv) + + def _get_scalar_type(self, range_, sv): + """Resolve a scalar LinkML range name to a BQ SQLAlchemy type.""" + if range_ is None: + return String() + if range_ in sv.all_enums(): + return String() + if range_ in sv.all_classes(): + pk = sv.get_identifier_slot(range_) + if pk: + return self._get_scalar_type(pk.range, sv) + return String() + if range_ in METAMODEL_TYPE_TO_BASE: + base = METAMODEL_TYPE_TO_BASE[range_] + elif range_ in sv.all_types(): + base = sv.all_types()[range_].base + else: + logger.warning("Unknown range %r — defaulting to STRING", range_) + return String() + return BQ_TYPEMAP.get(base, String()) + + def _build_struct(self, class_name, sv): + """Build a STRUCT<...> type from the induced slots of class_name.""" + _require_bq() + fields = {} + for slot in sv.class_induced_slots(class_name): + fields[slot.name.replace(" ", "_")] = self.get_sql_range(slot, sv=sv) + return STRUCT(**fields) + + def _resolve_type_override(self, type_str): + """Return the BQ SQLAlchemy type for a bigquery_type annotation value.""" + _require_bq() + overrides = { + "TIMESTAMP": lambda: TIMESTAMP(), + "DATE": lambda: Date(), + "DATETIME": lambda: DateTime(), + "STRING": lambda: String(), + "INT64": lambda: INTEGER(), + "FLOAT64": lambda: Float(), + "NUMERIC": lambda: Numeric(), + "BOOL": lambda: Boolean(), + "TIME": lambda: Time(), + "BYTES": lambda: LargeBinary(), + } + factory = overrides.get(type_str.upper()) + if factory is None: + raise ValueError(f"Unknown bigquery_type annotation value {type_str!r}. Valid values: {sorted(overrides)}") + return factory() + + +@shared_arguments(BigQueryGenerator) +@click.command(name="bigquery") +@click.option("--dataset", default=None, help="BigQuery dataset prefix") +@click.version_option(__version__, "-V", "--version") +def cli(yamlfile, dataset=None, **args): + """Generate BigQuery DDL representation""" + gen = BigQueryGenerator(yamlfile, **args) + if dataset: + gen.dataset = dataset + print(gen.serialize()) + + +if __name__ == "__main__": # pragma: no cover + cli() diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 88cd3fa10f..6c818a2323 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -209,7 +209,11 @@ class OwlSchemaGenerator(Generator): one direct ``is_a`` child, the generator adds ``AbstractClass rdfs:subClassOf (Child1 or Child2 or …)``, expressing the open-world covering constraint that every instance of the abstract class must also be an instance of one of its - direct subclasses.""" + direct subclasses. + + .. note:: An info message is emitted when an abstract class has no children (no axiom generated). + A warning is emitted when there is only one child (covering axiom degenerates to equivalence + Parent ≡ Child). Use this flag to suppress covering axioms entirely if equivalence is undesired.""" @staticmethod def _present(values: Iterable[_T | None]) -> list[_T]: @@ -505,6 +509,26 @@ def condition_to_bnode(expr: AnonymousClassExpression) -> OWL_EXPRESSION | None: # must be an instance of at least one of its direct subclasses. if cls.abstract and not self.skip_abstract_class_as_unionof_subclasses: children = sorted(sv.class_children(cls.name, imports=self.mergeimports, mixins=False, is_a=True)) + if not children: + logger.info( + "Abstract class '%s' has no children. No covering axiom will be generated.", + cls.name, + ) + elif len(children) == 1: + # Warn: with one child C, the covering axiom degenerates to + # Parent ⊑ C which, combined with C ⊑ Parent (from is_a), + # creates Parent ≡ C (equivalence). This is semantically + # correct per OWL 2 but may be surprising for extensible + # ontologies where more children are added later. + logger.warning( + "Abstract class '%s' has only 1 direct child ('%s'). " + "The covering axiom makes them equivalent (%s ≡ %s). " + "Use --skip-abstract-class-as-unionof-subclasses to suppress.", + cls.name, + children[0], + cls.name, + children[0], + ) if children: child_uris = [self._class_uri(child) for child in children] union_node = self._union_of(child_uris) @@ -1654,7 +1678,9 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: show_default=True, help=( "If true, suppress rdfs:subClassOf owl:unionOf(subclasses) covering axioms for abstract classes. " - "By default such axioms are emitted for every abstract class that has direct is_a children." + "By default such axioms are emitted for every abstract class that has direct is_a children. " + "Note: an info message is logged for abstract classes with zero children (no axiom); " + "a warning is emitted for one child (equivalence)." ), ) @click.option( diff --git a/packages/linkml/src/linkml/generators/rustgen/rustgen.py b/packages/linkml/src/linkml/generators/rustgen/rustgen.py index f1cf12349d..53113291b7 100644 --- a/packages/linkml/src/linkml/generators/rustgen/rustgen.py +++ b/packages/linkml/src/linkml/generators/rustgen/rustgen.py @@ -50,6 +50,7 @@ StubUtilsFile, ) from linkml.utils.generator import Generator +from linkml.utils.helpers import get_range_associated_slots from linkml_runtime.linkml_model.meta import ( ClassDefinition, EnumDefinition, @@ -609,19 +610,43 @@ def generate_class_as_key_value(self, cls: ClassDefinition) -> AsKeyValue | None # attribute to serve as the value, do not treat this as a key/value class. if len(value_attrs) == 0: return None - value_attr = value_attrs[0] + # Defer to linkml's documented `SimpleDict` rules (see + # `linkml.utils.helpers.get_range_associated_slots` and linkml/linkml#1250): + # 1. The class has exactly one non-key slot, or + # 2. Exactly one non-key slot is annotated with `simple_dict_value: true`, or + # 3. Exactly one non-key slot is required. + # When one of those conditions selects a value slot, the inlined-dict + # entry `key: ` may collapse into that slot. + # `get_range_associated_slots` is `lru_cache`d and unhashable on + # ClassDefinition; pass the class name (it normalizes internally). + _, simple_dict_value_slot, _ = get_range_associated_slots(self.schemaview, cls.name) + value_attr = simple_dict_value_slot or value_attrs[0] + # Additional Rust-specific guard: a primitive scalar can only deserialize + # into a value slot whose Rust type is a primitive or a linkml:Any + # wildcard (`Anything`/`AnyValue`, which wraps `serde_value::Value`). + # Other class ranges either store an inlined struct (which can't accept a + # primitive) or a reference string (which would mismatch `Self::Value`). + value_range_is_primitive_friendly = ( + value_attr.range not in self.schemaview.all_classes() + or self.schemaview.get_class(value_attr.range).class_uri == "linkml:Any" + ) simple_dict_possible = ( - len(non_key_attrs) == 1 - and not value_attr.multivalued - and ( - value_attr.range not in self.schemaview.all_classes() - or not bool(getattr(value_attr, "inlined", False)) - ) + simple_dict_value_slot is not None and not value_attr.multivalued and value_range_is_primitive_friendly ) + key_property_name = get_name(key_attr) + # The generated struct field for the key slot accepts its canonical name + # plus the slot's `alias` (via serde(alias = "...")). When building the + # deserialization map from an inlined-as-dict entry we must not inject + # the canonical key if the inner map already carries the alias for it, + # or serde will reject the duplicate field. + key_property_aliases: list[str] = [] + if key_attr.alias and key_attr.alias != key_property_name: + key_property_aliases.append(key_attr.alias) return AsKeyValue( name=get_name(cls), - key_property_name=get_name(key_attr), + key_property_name=key_property_name, key_property_type=get_rust_type(key_attr.range, self.schemaview, self.pyo3), + key_property_aliases=key_property_aliases, value_property_name=get_name(value_attr), value_property_type=get_rust_type(value_attr.range, self.schemaview, self.pyo3), can_convert_from_primitive=simple_dict_possible, diff --git a/packages/linkml/src/linkml/generators/rustgen/template.py b/packages/linkml/src/linkml/generators/rustgen/template.py index e9b45fe146..3c1174f073 100644 --- a/packages/linkml/src/linkml/generators/rustgen/template.py +++ b/packages/linkml/src/linkml/generators/rustgen/template.py @@ -366,6 +366,7 @@ class AsKeyValue(RustTemplateModel): name: str key_property_name: str key_property_type: str + key_property_aliases: list[str] = [] value_property_name: str value_property_type: str can_convert_from_primitive: bool = False diff --git a/packages/linkml/src/linkml/generators/rustgen/templates/anything.rs.jinja b/packages/linkml/src/linkml/generators/rustgen/templates/anything.rs.jinja index ecfe4334d3..4dad28e0e0 100644 --- a/packages/linkml/src/linkml/generators/rustgen/templates/anything.rs.jinja +++ b/packages/linkml/src/linkml/generators/rustgen/templates/anything.rs.jinja @@ -41,13 +41,22 @@ impl<'py> FromPyObject<'py> for Anything { return Ok(Value::Unit); } - // Try simple primitives first - if let Ok(s) = o.extract::<&str>() { - return Ok(Value::String(s.to_string())); - } + // Try simple primitives first. + // Order matters: Python's `bool` is a subclass of `int`, so `bool` + // must be tried before `i64`. Likewise, `i64` before `f64` so we + // don't widen integers to floats. if let Ok(b) = o.extract::() { return Ok(Value::Bool(b)); } + if let Ok(i) = o.extract::() { + return Ok(Value::I64(i)); + } + if let Ok(f) = o.extract::() { + return Ok(Value::F64(f)); + } + if let Ok(s) = o.extract::<&str>() { + return Ok(Value::String(s.to_string())); + } // Sequences (list/tuple) if let Ok(list) = o.downcast::() { @@ -98,7 +107,7 @@ impl<'py> IntoPyObject<'py> for Anything { type Error = PyErr; fn into_pyobject(self, py: Python<'py>) -> Result { - use pyo3::types::{PyAny, PyDict, PyList, PyString}; + use pyo3::types::{PyAny, PyBytes, PyDict, PyList, PyString}; use serde_value::Value; fn value_to_py<'py>(py: Python<'py>, v: &Value) -> pyo3::PyResult> { @@ -106,6 +115,23 @@ impl<'py> IntoPyObject<'py> for Anything { Value::Unit => Ok(py.None().into_bound(py)), Value::Bool(b) => Ok(pyo3::types::PyBool::new(py, *b).to_owned().into_any()), Value::String(s) => Ok(PyString::new(py, s).into_any()), + Value::U8(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::U16(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::U32(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::U64(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::I8(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::I16(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::I32(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::I64(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::F32(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::F64(n) => Ok(n.into_pyobject(py)?.into_any()), + Value::Char(c) => Ok(PyString::new(py, &c.to_string()).into_any()), + Value::Bytes(b) => Ok(PyBytes::new(py, b).into_any()), + Value::Option(opt) => match opt { + Some(inner) => value_to_py(py, inner), + None => Ok(py.None().into_bound(py)), + }, + Value::Newtype(inner) => value_to_py(py, inner), Value::Seq(seq) => { let list = PyList::empty(py); for item in seq.iter() { @@ -123,14 +149,6 @@ impl<'py> IntoPyObject<'py> for Anything { } Ok(dict.into_any()) } - // Best-effort for other serde_value variants - // (numbers, bytes, chars, etc.) - other => { - // Try common cases without bringing extra deps - // Numbers are converted via string if not covered above - let s = format!("{:?}", other); - Ok(PyString::new(py, &s).into_any()) - } } } diff --git a/packages/linkml/src/linkml/generators/rustgen/templates/as_key_value.rs.jinja b/packages/linkml/src/linkml/generators/rustgen/templates/as_key_value.rs.jinja index f69392a1a9..cd1f50de4a 100644 --- a/packages/linkml/src/linkml/generators/rustgen/templates/as_key_value.rs.jinja +++ b/packages/linkml/src/linkml/generators/rustgen/templates/as_key_value.rs.jinja @@ -19,7 +19,14 @@ impl serde_utils::InlinedPair for {{ name }} { }; let key_value = serde_value::to_value(k.clone()) .map_err(|e| format!("unable to serialize key: {}", e))?; - map.insert(Value::String("{{key_property_name}}".into()), key_value); + // Only inject the inferred key if the inner map does not already carry it + // under its canonical name or any accepted alias. Injecting unconditionally + // would produce a duplicate field once serde resolves aliases. + let already_keyed = map.contains_key(&Value::String("{{ key_property_name }}".into())) + {%- for a in key_property_aliases %} || map.contains_key(&Value::String("{{ a }}".into())){%- endfor %}; + if !already_keyed { + map.insert(Value::String("{{ key_property_name }}".into()), key_value); + } let de = Value::Map(map).into_deserializer(); match serde_path_to_error::deserialize(de) { Ok(ok) => Ok(ok), diff --git a/packages/linkml/src/linkml/utils/deprecation.py b/packages/linkml/src/linkml/utils/deprecation.py index 6d9f7f36a0..62f196708c 100644 --- a/packages/linkml/src/linkml/utils/deprecation.py +++ b/packages/linkml/src/linkml/utils/deprecation.py @@ -19,7 +19,7 @@ import re import warnings from dataclasses import dataclass -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError, version from typing import Optional, TypeVar # Stolen from https://github.com/pypa/packaging/blob/main/src/packaging/version.py @@ -115,8 +115,11 @@ def from_str(cls, v: str) -> Optional["SemVer"]: @classmethod def from_package(cls, package: str) -> "SemVer": - """Get semver from package name""" - v = version(package) + """Get semver from package name, returning 0.0.0 if metadata is unavailable.""" + try: + v = version(package) + except PackageNotFoundError: + v = "0.0.0" return SemVer.from_str(v) def __eq__(self, other: "SemVer"): diff --git a/pyproject.toml b/pyproject.toml index 684f7ac3ee..ad08529d9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ markers = [ "sqlalchemygen: Tests for SQL Alchemy generator", "sqlddlgen: Tests for SQL DDL generator", "sqlddlpostgresgen: Tests for SQL DDL postgres generator", + "sqlddlbigquerygen: Tests for BigQuery DDL generator", "owlgen: Tests for OWL generator", "yamlgen: Tests for the YAML generator", "yarrrml: End-to-end tests for the YARRRML generator", diff --git a/tests/linkml/test_compliance/helper.py b/tests/linkml/test_compliance/helper.py index 5476882278..23e6d8956f 100644 --- a/tests/linkml/test_compliance/helper.py +++ b/tests/linkml/test_compliance/helper.py @@ -54,6 +54,15 @@ ShExGenerator, sqlalchemygen, ) + +try: + from linkml.generators.bigquerygen import BigQueryGenerator + + _BQ_AVAILABLE = True +except ImportError: + BigQueryGenerator = None # type: ignore[assignment,misc] + _BQ_AVAILABLE = False + from linkml.utils.generator import Generator from linkml.utils.sqlutils import SQLStore from linkml.validator import JsonschemaValidationPlugin, Validator @@ -85,6 +94,7 @@ DATAFRAME_POLARS_SCHEMA = "dataframe_polars_schema" SQL_DDL_SQLITE = "sql_ddl_sqlite" SQL_DDL_POSTGRES = "sql_ddl_postgres" +SQL_DDL_BIGQUERY = "sql_ddl_bigquery" OWL = "owl" GENERATORS: dict[FRAMEWORK, type[Generator] | tuple[type[Generator], dict[str, Any]]] = { PYDANTIC: generators.PydanticGenerator, @@ -117,6 +127,9 @@ ), } +if _BQ_AVAILABLE: + GENERATORS[SQL_DDL_BIGQUERY] = (BigQueryGenerator, {"dataset": "compliance_test"}) + class ValidationBehavior(str, enum.Enum): IMPLEMENTS: str = "implements" diff --git a/tests/linkml/test_compliance/test_compliance.py b/tests/linkml/test_compliance/test_compliance.py index c576123671..e457e9254d 100644 --- a/tests/linkml/test_compliance/test_compliance.py +++ b/tests/linkml/test_compliance/test_compliance.py @@ -14,6 +14,7 @@ PYTHON_DATACLASSES, SHACL, SHEX, + SQL_DDL_BIGQUERY, SQL_DDL_POSTGRES, SQL_DDL_SQLITE, ) @@ -78,5 +79,6 @@ # SQL_ALCHEMY_DECLARATIVE, pytest.param(SQL_DDL_SQLITE, marks=[pytest.mark.sqlddlgen]), pytest.param(SQL_DDL_POSTGRES, marks=[pytest.mark.sqlddlpostgresgen]), + pytest.param(SQL_DDL_BIGQUERY, marks=[pytest.mark.sqlddlbigquerygen]), pytest.param(OWL, marks=[pytest.mark.owlgen]), ] diff --git a/tests/linkml/test_generators/input/bigquery_features.yaml b/tests/linkml/test_generators/input/bigquery_features.yaml new file mode 100644 index 0000000000..82508d5d82 --- /dev/null +++ b/tests/linkml/test_generators/input/bigquery_features.yaml @@ -0,0 +1,201 @@ +id: https://example.org/bigquery-features +name: bigquery-features +description: >- + Test schema exercising every BigQuery DDL generator feature: + ARRAY, STRUCT (nested), PARTITION BY (time + range), CLUSTER BY, + OPTIONS (expiration, require_partition_filter), bigquery_type override, + abstract class exclusion, mixin flattening, enum → STRING, decimal → NUMERIC. + +prefixes: + linkml: https://w3id.org/linkml/ + bqtest: https://example.org/bigquery-features/ + +default_prefix: bqtest +default_range: string + +imports: + - linkml:types + +# --------------------------------------------------------------------------- +# Enums — must compile to STRING in BigQuery (no native ENUM type) +# --------------------------------------------------------------------------- +enums: + EventStatus: + permissible_values: + active: + archived: + pending: + +# --------------------------------------------------------------------------- +# Abstract base — must NOT produce a CREATE TABLE +# --------------------------------------------------------------------------- +classes: + Identifiable: + abstract: true + description: Abstract base providing a string identifier key + slots: + - id + +# --------------------------------------------------------------------------- +# Mixin — must NOT produce a CREATE TABLE; slots must appear in mixing classes +# --------------------------------------------------------------------------- + HasTimestamps: + mixin: true + description: Provides created_at / updated_at audit columns + slots: + - created_at + - updated_at + +# --------------------------------------------------------------------------- +# GeoPoint — concrete nested class; inlined as STRUCT inside Address +# --------------------------------------------------------------------------- + GeoPoint: + description: WGS-84 coordinate pair + slots: + - lat + - lon + +# --------------------------------------------------------------------------- +# Address — concrete nested class; inlined as STRUCT inside Event +# Its `location` slot is itself an inlined GeoPoint → nested STRUCT +# --------------------------------------------------------------------------- + Address: + description: Postal address with optional geo coordinate + slots: + - street + - city + - location + +# --------------------------------------------------------------------------- +# Event — main analytics table demonstrating: +# • Inherited slots from Identifiable (id) +# • Mixin slots from HasTimestamps (created_at, updated_at) +# • tags: multivalued scalar → ARRAY +# • home_address: inlined Address → STRUCT (with nested STRUCT for GeoPoint) +# • score: decimal → NUMERIC +# • status: enum → STRING +# • recorded_at: datetime with bigquery_type:TIMESTAMP → TIMESTAMP column +# • PARTITION BY DATETIME_TRUNC(created_at, DAY) +# • CLUSTER BY name, status +# • OPTIONS(partition_expiration_ms=..., require_partition_filter=true) +# --------------------------------------------------------------------------- + Event: + is_a: Identifiable + mixins: + - HasTimestamps + description: User event log + slots: + - name + - status + - tags + - home_address + - score + - recorded_at + annotations: + bigquery_partition_by: + tag: bigquery_partition_by + value: created_at + bigquery_partition_type: + tag: bigquery_partition_type + value: DAY + bigquery_partition_expiration_days: + tag: bigquery_partition_expiration_days + value: "90" + bigquery_require_partition_filter: + tag: bigquery_require_partition_filter + value: "true" + bigquery_cluster_by: + tag: bigquery_cluster_by + value: "name, status" + +# --------------------------------------------------------------------------- +# MetricSnapshot — demonstrates integer RANGE partitioning +# • bucket_id: integer → RANGE_BUCKET(bucket_id, GENERATE_ARRAY(0, 1000, 100)) +# --------------------------------------------------------------------------- + MetricSnapshot: + description: Aggregated metric bucketed by integer range partition + slots: + - id + - bucket_id + - value + annotations: + bigquery_partition_by: + tag: bigquery_partition_by + value: bucket_id + bigquery_partition_type: + tag: bigquery_partition_type + value: RANGE + bigquery_partition_range: + tag: bigquery_partition_range + value: "0,1000,100" + +# --------------------------------------------------------------------------- +# Slots +# --------------------------------------------------------------------------- +slots: + id: + identifier: true + range: string + description: Primary key (string identifier) + + name: + range: string + + status: + range: EventStatus + description: Enum-ranged slot — must compile to STRING in BQ + + tags: + range: string + multivalued: true + description: Repeated scalar — must compile to ARRAY + + home_address: + range: Address + inlined: true + description: Inlined object — must compile to STRUCT> + + score: + range: decimal + description: Decimal value — must compile to NUMERIC (not INTEGER) + + recorded_at: + range: datetime + description: Datetime with bigquery_type override — must compile to TIMESTAMP + annotations: + bigquery_type: + tag: bigquery_type + value: TIMESTAMP + + created_at: + range: datetime + description: Partition field — datetime compiles to DATETIME; used in PARTITION BY clause + + updated_at: + range: datetime + + street: + range: string + + city: + range: string + + lat: + range: float + description: Latitude coordinate — compiles to FLOAT64 + + lon: + range: float + description: Longitude coordinate — compiles to FLOAT64 + + location: + range: GeoPoint + inlined: true + description: Inlined GeoPoint — produces nested STRUCT inside Address STRUCT + + bucket_id: + range: integer + description: Integer partition field — used in RANGE_BUCKET clause + + value: + range: float diff --git a/tests/linkml/test_generators/test_bigquerygen.py b/tests/linkml/test_generators/test_bigquerygen.py new file mode 100644 index 0000000000..57fce97926 --- /dev/null +++ b/tests/linkml/test_generators/test_bigquerygen.py @@ -0,0 +1,715 @@ +import os as _os + +import pytest +from sqlalchemy.types import Boolean, Date, DateTime, Float, LargeBinary, Numeric, String, Time + +from linkml.generators.bigquerygen import BigQueryGenerator +from linkml_runtime.linkml_model.meta import SlotDefinition +from linkml_runtime.utils.schema_builder import SchemaBuilder + + +def test_decimal_maps_to_numeric(): + """decimal range must map to NUMERIC, not INTEGER (regression guard against parent RANGEMAP)""" + b = SchemaBuilder() + b.add_class("Thing", slots=["price"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen.get_sql_range(SlotDefinition("price", range="decimal"), b.schema) + assert isinstance(result, Numeric) + + +def test_enum_range_maps_to_string(): + """BQ has no ENUM type; enum-ranged slots must compile as STRING""" + from linkml_runtime.linkml_model.meta import EnumDefinition, PermissibleValue + + b = SchemaBuilder() + b.schema.enums["Status"] = EnumDefinition( + name="Status", + permissible_values={"active": PermissibleValue(text="active")}, + ) + b.add_class("Thing", slots=["status"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen.get_sql_range(SlotDefinition("status", range="Status"), b.schema) + assert isinstance(result, String) + + +def test_datetime_maps_to_datetime(): + """XSDDateTime defaults to DATETIME (timezone-naive) in BQ DDL""" + b = SchemaBuilder() + b.add_class("Thing", slots=["ts"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen.get_sql_range(SlotDefinition("ts", range="datetime"), b.schema) + assert isinstance(result, DateTime) + + +def test_bigquery_type_annotation_overrides_mapping(): + """bigquery_type slot annotation takes precedence over automatic type resolution""" + from sqlalchemy_bigquery import TIMESTAMP + + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_class("Thing", slots=["ts"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + slot = SlotDefinition("ts", range="datetime") + slot.annotations["bigquery_type"] = Annotation("bigquery_type", "TIMESTAMP") + result = gen.get_sql_range(slot, b.schema) + assert isinstance(result, TIMESTAMP) + + +def test_multivalued_scalar_produces_array(): + """multivalued slot with scalar range → ARRAY, not a join table""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("aliases", range="string", multivalued=True)) + b.add_class("Person", slots=["id", "aliases"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "ARRAY" in ddl + assert "CREATE TABLE" in ddl + + +def test_inlined_object_produces_struct(): + """inlined class-range slot → STRUCT""" + from linkml_runtime.linkml_model.meta import SlotDefinition as SD + + b = SchemaBuilder() + b.add_slot(SD("street", range="string")) + b.add_slot(SD("city", range="string")) + b.add_class("Address", slots=["street", "city"]) + b.add_slot(SD("address", range="Address", inlined=True)) + b.add_class("Person", slots=["id", "address"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "STRUCT<" in ddl + assert "street STRING" in ddl + assert "city STRING" in ddl + + +def test_nested_struct_recursion(): + """a STRUCT whose fields include another inlined class → nested STRUCT<...>""" + from linkml_runtime.linkml_model.meta import SlotDefinition as SD + + b = SchemaBuilder() + b.add_slot(SD("lat", range="float")) + b.add_slot(SD("lon", range="float")) + b.add_class("GeoPoint", slots=["lat", "lon"]) + b.add_slot(SD("location", range="GeoPoint", inlined=True)) + b.add_slot(SD("street", range="string")) + b.add_class("Address", slots=["street", "location"]) + b.add_slot(SD("address", range="Address", inlined=True)) + b.add_class("Place", slots=["id", "address"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "STRUCT<" in ddl + + +def _make_partitioned_schema(partition_type="DAY", field_range="datetime"): + """Helper: schema with a class annotated for time partitioning.""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("created_at", range=field_range)) + b.add_class("Event", slots=["id", "created_at"]) + b.add_defaults() + c = b.schema.classes["Event"] + c.annotations["bigquery_partition_by"] = Annotation("bigquery_partition_by", "created_at") + c.annotations["bigquery_partition_type"] = Annotation("bigquery_partition_type", partition_type) + return b.schema + + +def test_time_partition_by_day(): + """bigquery_partition_by + bigquery_partition_type=DAY on a datetime slot""" + gen = BigQueryGenerator(_make_partitioned_schema("DAY")) + ddl = gen.generate_ddl() + assert "PARTITION BY DATETIME_TRUNC(created_at, DAY)" in ddl + + +def test_time_partition_by_month(): + """bigquery_partition_type=MONTH produces DATETIME_TRUNC(..., MONTH)""" + gen = BigQueryGenerator(_make_partitioned_schema("MONTH")) + ddl = gen.generate_ddl() + assert "PARTITION BY DATETIME_TRUNC(created_at, MONTH)" in ddl + + +def test_range_partition(): + """bigquery_partition_type=RANGE on an integer slot produces RANGE_BUCKET(...)""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("user_id", range="integer")) + b.add_class("Event", slots=["id", "user_id"]) + b.add_defaults() + c = b.schema.classes["Event"] + c.annotations["bigquery_partition_by"] = Annotation("bigquery_partition_by", "user_id") + c.annotations["bigquery_partition_type"] = Annotation("bigquery_partition_type", "RANGE") + c.annotations["bigquery_partition_range"] = Annotation("bigquery_partition_range", "0,100,10") + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "PARTITION BY RANGE_BUCKET" in ddl + + +def test_cluster_by(): + """bigquery_cluster_by annotation produces CLUSTER BY clause""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("region", range="string")) + b.add_class("Event", slots=["id", "region"]) + b.add_defaults() + c = b.schema.classes["Event"] + c.annotations["bigquery_cluster_by"] = Annotation("bigquery_cluster_by", "region, id") + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "CLUSTER BY region, id" in ddl + + +def test_partition_and_cluster_combined(): + """PARTITION BY and CLUSTER BY can appear together on the same table""" + from linkml_runtime.linkml_model.meta import Annotation + + schema = _make_partitioned_schema("DAY") + c = schema.classes["Event"] + c.annotations["bigquery_cluster_by"] = Annotation("bigquery_cluster_by", "id") + gen = BigQueryGenerator(schema) + ddl = gen.generate_ddl() + assert "PARTITION BY" in ddl + assert "CLUSTER BY" in ddl + + +def test_partition_expiration_and_filter(): + """partition_expiration_days and require_partition_filter appear in OPTIONS(...)""" + from linkml_runtime.linkml_model.meta import Annotation + + schema = _make_partitioned_schema("DAY") + c = schema.classes["Event"] + c.annotations["bigquery_partition_expiration_days"] = Annotation("bigquery_partition_expiration_days", "90") + c.annotations["bigquery_require_partition_filter"] = Annotation("bigquery_require_partition_filter", "true") + gen = BigQueryGenerator(schema) + ddl = gen.generate_ddl() + assert "OPTIONS(" in ddl + assert "require_partition_filter=true" in ddl + + +def test_partition_on_nonexistent_field_raises(): + """bigquery_partition_by naming a field not in the class must raise ValueError""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_class("Event", slots=["id"]) + b.add_defaults() + c = b.schema.classes["Event"] + c.annotations["bigquery_partition_by"] = Annotation("bigquery_partition_by", "nonexistent") + gen = BigQueryGenerator(b.schema) + with pytest.raises(ValueError, match="nonexistent"): + gen.generate_ddl() + + +def test_time_partition_on_string_field_raises(): + """time partitioning on a STRING field must raise ValueError mentioning 'date'""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("name", range="string")) + b.add_class("Event", slots=["id", "name"]) + b.add_defaults() + c = b.schema.classes["Event"] + c.annotations["bigquery_partition_by"] = Annotation("bigquery_partition_by", "name") + c.annotations["bigquery_partition_type"] = Annotation("bigquery_partition_type", "DAY") + gen = BigQueryGenerator(b.schema) + with pytest.raises(ValueError, match="date"): + gen.generate_ddl() + + +def test_range_partition_on_non_integer_field_raises(): + """range partitioning on a non-integer field must raise ValueError mentioning 'integer'""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("label", range="string")) + b.add_class("Event", slots=["id", "label"]) + b.add_defaults() + c = b.schema.classes["Event"] + c.annotations["bigquery_partition_by"] = Annotation("bigquery_partition_by", "label") + c.annotations["bigquery_partition_type"] = Annotation("bigquery_partition_type", "RANGE") + c.annotations["bigquery_partition_range"] = Annotation("bigquery_partition_range", "0,100,10") + gen = BigQueryGenerator(b.schema) + with pytest.raises(ValueError, match="integer"): + gen.generate_ddl() + + +# --------------------------------------------------------------------------- +# Task 6 — Schema traversal: inheritance, abstract classes, mixins +# --------------------------------------------------------------------------- + + +def test_inherited_slots_appear_in_child_ddl(): + """A child class must include slots defined on its parent in the generated DDL.""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("name", range="string")) + b.add_slot(SlotDefinition("email", range="string")) + b.add_class("NamedThing", slots=["id", "name"]) + b.add_class("Person", slots=["email"], is_a="NamedThing") + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + person_ddl = next(block for block in ddl.split("\n\n") if "Person" in block) + assert "id" in person_ddl + assert "name" in person_ddl + assert "email" in person_ddl + + +def test_abstract_class_produces_no_table(): + """Abstract classes must not appear as CREATE TABLE statements.""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_class("AbstractBase", slots=["id"]) + b.schema.classes["AbstractBase"].abstract = True + b.add_class("Concrete", slots=["id"], is_a="AbstractBase") + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "AbstractBase" not in ddl + assert "Concrete" in ddl + + +def test_mixin_slots_appear_in_class_ddl(): + """Slots contributed by a mixin must be flattened into the mixing class's DDL.""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_slot(SlotDefinition("created_at", range="datetime")) + b.add_class("Timestamped", slots=["created_at"]) + b.schema.classes["Timestamped"].mixin = True + b.add_class("Event", slots=["id"]) + b.schema.classes["Event"].mixins = ["Timestamped"] + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + event_ddl = next(block for block in ddl.split("\n\n") if "Event" in block) + assert "created_at" in event_ddl + assert "Timestamped" not in ddl + + +# --------------------------------------------------------------------------- +# Task 8 — Integration test with personinfo.yaml +# --------------------------------------------------------------------------- + +_PERSONINFO_YAML = _os.path.join( + _os.path.dirname(__file__), + "..", + "..", + "..", + "examples", + "PersonSchema", + "personinfo.yaml", +) + + +@pytest.mark.integration +def test_personinfo_generates_valid_ddl(): + """BigQueryGenerator must produce valid CREATE TABLE DDL for the personinfo schema + without errors, and include expected concrete classes (Person, Organization) + while excluding abstract/mixin classes.""" + gen = BigQueryGenerator(_PERSONINFO_YAML) + ddl = gen.generate_ddl() + assert "CREATE TABLE" in ddl + # Concrete classes must appear + assert "Person" in ddl + assert "Organization" in ddl + # Each statement must be terminated + for block in ddl.split("\n\n"): + if block.strip(): + assert block.rstrip().endswith(";"), f"DDL block missing semicolon:\n{block}" + + +# --------------------------------------------------------------------------- +# Task 7 — Generator defaults and --dataset flag +# --------------------------------------------------------------------------- + + +def test_use_foreign_keys_is_false_by_default(): + """BigQueryGenerator must default use_foreign_keys=False (BQ doesn't enforce FKs).""" + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + assert gen.use_foreign_keys is False + + +def test_inject_primary_keys_is_false_by_default(): + """BigQueryGenerator must default inject_primary_keys=False (BQ PKs are non-enforced).""" + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + assert gen.inject_primary_keys is False + + +def test_dataset_prefix_applied_to_table_names(): + """When dataset is set, table names must be emitted as `dataset.ClassName`.""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_class("Person", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + gen.dataset = "my_dataset" + ddl = gen.generate_ddl() + assert "my_dataset.Person" in ddl + + +def test_serialize_delegates_to_generate_ddl(): + """serialize() must return the same output as generate_ddl().""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + assert gen.serialize() == gen.generate_ddl() + + +def test_get_sql_range_without_sv_argument(): + """get_sql_range() must build SchemaView internally when sv is not supplied.""" + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen.get_sql_range(SlotDefinition("id", range="string")) + assert isinstance(result, String) + + +def test_inlined_as_list_produces_array_of_struct(): + """inlined_as_list slot → ARRAY> (list of embedded objects).""" + from linkml_runtime.linkml_model.meta import SlotDefinition as SD + + b = SchemaBuilder() + b.add_slot(SD("street", range="string")) + b.add_class("Address", slots=["street"]) + b.add_slot(SD("addresses", range="Address", inlined_as_list=True, multivalued=True)) + b.add_class("Person", slots=["id", "addresses"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "ARRAY<" in ddl + + +def test_unknown_range_defaults_to_string(caplog): + """An unrecognised range name must log a warning and map to STRING.""" + import logging + + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + with caplog.at_level(logging.WARNING, logger="linkml.generators.bigquerygen"): + result = gen.get_sql_range(SlotDefinition("x", range="NonExistentType"), b.schema) + assert isinstance(result, String) + assert "NonExistentType" in caplog.text + + +def test_class_range_without_identifier_defaults_to_string(): + """A slot whose range is a class with no identifier slot must resolve to STRING.""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("zip_code", range="string")) + b.add_class("PostalAddress", slots=["zip_code"]) + b.add_slot(SlotDefinition("mailing_address", range="PostalAddress")) + b.add_class("Person", slots=["id", "mailing_address"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen.get_sql_range(SlotDefinition("mailing_address", range="PostalAddress"), b.schema) + assert isinstance(result, String) + + +@pytest.mark.parametrize( + "type_str, expected_class", + [ + ("DATE", Date), + ("DATETIME", DateTime), + ("STRING", String), + ("FLOAT64", Float), + ("NUMERIC", Numeric), + ("BOOL", Boolean), + ("TIME", Time), + ("BYTES", LargeBinary), + ], +) +def test_resolve_type_override_all_types(type_str, expected_class): + """Every valid bigquery_type annotation value must resolve without error.""" + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen._resolve_type_override(type_str) + assert isinstance(result, expected_class) + + +def test_resolve_type_override_int64(): + """INT64 bigquery_type annotation must resolve to INTEGER.""" + from sqlalchemy.types import INTEGER + + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + assert isinstance(gen._resolve_type_override("INT64"), INTEGER) + + +def test_resolve_type_override_invalid_raises(): + """An unrecognised bigquery_type annotation value must raise ValueError.""" + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + with pytest.raises(ValueError, match="NOTATYPE"): + gen._resolve_type_override("NOTATYPE") + + +def test_bigquery_description_in_options(): + """bigquery_description annotation must appear in the generated OPTIONS clause.""" + from linkml_runtime.linkml_model.meta import Annotation + + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_class("Event", slots=["id"]) + b.add_defaults() + b.schema.classes["Event"].annotations["bigquery_description"] = Annotation( + "bigquery_description", "Tracks user events" + ) + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "Tracks user events" in ddl + + +def test_cli_produces_ddl(tmp_path): + """The gen-bigquery CLI entry point must print valid DDL to stdout.""" + from click.testing import CliRunner + + from linkml.generators.bigquerygen import cli + + runner = CliRunner() + result = runner.invoke(cli, [str(_PERSONINFO_YAML)]) + assert result.exit_code == 0, result.output + assert "CREATE TABLE" in result.output + + +def test_cli_dataset_flag(): + """--dataset flag must prefix table names in CLI output.""" + from click.testing import CliRunner + + from linkml.generators.bigquerygen import cli + + runner = CliRunner() + result = runner.invoke(cli, [str(_PERSONINFO_YAML), "--dataset", "my_ds"]) + assert result.exit_code == 0, result.output + assert "my_ds." in result.output + + +def test_class_with_no_slots_skipped_in_ddl(): + """A concrete class that induces no slots must produce no CREATE TABLE statement.""" + b = SchemaBuilder() + b.add_slot(SlotDefinition("id", range="string", identifier=True)) + b.add_class("Empty") # no slots + b.add_class("HasId", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "Empty" not in ddl + assert "HasId" in ddl + + +def test_custom_type_with_unmapped_base_defaults_to_string(): + """A custom type whose base is not in BQ_TYPEMAP must fall back to STRING.""" + from linkml_runtime.linkml_model.meta import TypeDefinition + + b = SchemaBuilder() + b.schema.types["MyBytes"] = TypeDefinition(name="MyBytes", base="bytes", uri="xsd:base64Binary") + b.add_slot(SlotDefinition("payload", range="MyBytes")) + b.add_class("Thing", slots=["id", "payload"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + result = gen.get_sql_range(SlotDefinition("payload", range="MyBytes"), b.schema) + assert isinstance(result, String) + + +def test_none_range_defaults_to_string(): + """A slot with no range set must resolve to STRING without error.""" + b = SchemaBuilder() + b.add_class("Thing", slots=["id"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + from linkml_runtime.utils.schemaview import SchemaView + + sv = SchemaView(b.schema) + result = gen._get_scalar_type(None, sv) + assert isinstance(result, String) + + +def test_require_partition_filter_false_omitted_from_options(): + """bigquery_require_partition_filter=false must not add require_partition_filter to OPTIONS.""" + from linkml_runtime.linkml_model.meta import Annotation + + schema = _make_partitioned_schema("DAY") + c = schema.classes["Event"] + c.annotations["bigquery_require_partition_filter"] = Annotation("bigquery_require_partition_filter", "false") + gen = BigQueryGenerator(schema) + ddl = gen.generate_ddl() + assert "require_partition_filter" not in ddl + + +def test_inlined_as_list_non_multivalued_produces_struct(): + """inlined_as_list=True on a non-multivalued slot must produce STRUCT, not ARRAY.""" + from linkml_runtime.linkml_model.meta import SlotDefinition as SD + + b = SchemaBuilder() + b.add_slot(SD("label", range="string")) + b.add_class("Tag", slots=["label"]) + b.add_slot(SD("primary_tag", range="Tag", inlined_as_list=True)) + b.add_class("Article", slots=["id", "primary_tag"]) + b.add_defaults() + gen = BigQueryGenerator(b.schema) + ddl = gen.generate_ddl() + assert "STRUCT<" in ddl + assert "ARRAY<" not in ddl.split("Article")[1].split(";")[0] + + +def test_semver_from_package_unknown_returns_zero(): + """SemVer.from_package() must return 0.0.0 for an uninstalled package.""" + from linkml.utils.deprecation import SemVer + + v = SemVer.from_package("linkml-nonexistent-package-xyzzy") + assert v.major == 0 + assert v.minor == 0 + assert v.patch == 0 + + +# --------------------------------------------------------------------------- +# Full-features integration test using bigquery_features.yaml +# --------------------------------------------------------------------------- + +_FEATURES_YAML = _os.path.join( + _os.path.dirname(__file__), + "input", + "bigquery_features.yaml", +) + + +@pytest.mark.integration +def test_bigquery_features_schema(): + """Full integration test against tests/linkml/test_generators/input/bigquery_features.yaml. + + Expected DDL (abbreviated): + + CREATE TABLE `GeoPoint` ( + `lat` FLOAT64, + `lon` FLOAT64 + ); + + CREATE TABLE `Address` ( + `street` STRING, + `city` STRING, + `location` STRUCT -- inlined GeoPoint + ); + + CREATE TABLE `Event` ( + `name` STRING, + `status` STRING, -- enum EventStatus → STRING + `tags` ARRAY, -- multivalued scalar + `home_address` STRUCT>, + `score` NUMERIC, -- decimal → NUMERIC + `recorded_at` TIMESTAMP, -- bigquery_type: TIMESTAMP override + `created_at` DATETIME, -- partition field + `updated_at` DATETIME, -- from HasTimestamps mixin + `id` STRING NOT NULL -- from Identifiable base class + ) PARTITION BY DATETIME_TRUNC(created_at, DAY) + CLUSTER BY name, status + OPTIONS(partition_expiration_days=90.0, require_partition_filter=true); + + CREATE TABLE `MetricSnapshot` ( + `id` STRING NOT NULL, + `bucket_id` INT64, -- integer range partition field + `value` FLOAT64 + ) PARTITION BY RANGE_BUCKET(bucket_id, GENERATE_ARRAY(0, 1000, 100)); + + Classes NOT expected in output: + - Identifiable (abstract=true) + - HasTimestamps (mixin=true) + """ + gen = BigQueryGenerator(_FEATURES_YAML) + ddl = gen.generate_ddl() + + # Helpers: extract per-table DDL blocks for targeted assertions + def get_block(table: str) -> str: + return next( + b + for b in ddl.split("\n\n") + if f"`{table}`" in b or f" {table} " in b or b.startswith(f"CREATE TABLE `{table}`") + ) + + event = get_block("Event") + metric = get_block("MetricSnapshot") + address = get_block("Address") + + # ── Abstract and mixin classes produce no table ────────────────────────── + assert "Identifiable" not in ddl + assert "HasTimestamps" not in ddl + + # ── GeoPoint and Address exist as standalone tables ────────────────────── + assert "GeoPoint" in ddl + assert "Address" in ddl + + # ── Event: inherited slot from abstract Identifiable ───────────────────── + assert "`id` STRING NOT NULL" in event + + # ── Event: mixin slots from HasTimestamps ──────────────────────────────── + assert "`created_at` DATETIME" in event + assert "`updated_at` DATETIME" in event + + # ── Event: enum-ranged slot → STRING ───────────────────────────────────── + assert "`status` STRING" in event + + # ── Event: multivalued scalar slot → ARRAY ─────────────────────── + assert "`tags` ARRAY" in event + + # ── Event: inlined Address → STRUCT with nested GeoPoint STRUCT ────────── + assert "STRUCT>" in event + + # ── Event: decimal slot → NUMERIC ──────────────────────────────────────── + assert "`score` NUMERIC" in event + + # ── Event: bigquery_type:TIMESTAMP annotation overrides DATETIME ───────── + assert "`recorded_at` TIMESTAMP" in event + + # ── Event: PARTITION BY DATETIME_TRUNC (time partition, DAY) ──────────── + assert "PARTITION BY DATETIME_TRUNC(created_at, DAY)" in event + + # ── Event: CLUSTER BY ──────────────────────────────────────────────────── + assert "CLUSTER BY name, status" in event + + # ── Event: OPTIONS with partition expiration and required filter ───────── + assert "OPTIONS(" in event + assert "partition_expiration_days=90.0" in event + assert "require_partition_filter=true" in event + + # ── Address: nested STRUCT for inlined GeoPoint ────────────────────────── + assert "`location` STRUCT" in address + + # ── MetricSnapshot: integer RANGE partition ─────────────────────────────── + assert "PARTITION BY RANGE_BUCKET(bucket_id, GENERATE_ARRAY(0, 1000, 100))" in metric + + # ── Every statement ends with a semicolon ──────────────────────────────── + for block in ddl.split("\n\n"): + if block.strip(): + assert block.rstrip().endswith(";"), f"Missing semicolon:\n{block}" diff --git a/tests/linkml/test_generators/test_owlgen.py b/tests/linkml/test_generators/test_owlgen.py index ead3359ee2..062d4c31ac 100644 --- a/tests/linkml/test_generators/test_owlgen.py +++ b/tests/linkml/test_generators/test_owlgen.py @@ -1,3 +1,4 @@ +import logging from enum import Enum import pytest @@ -526,6 +527,175 @@ def test_abstract_class_without_subclasses_gets_no_union_of_axiom(): assert _union_members(g, EX.Orphan) is None +def test_abstract_class_with_no_children_emits_info(caplog): + """An abstract class with no children emits an info message about missing coverage. + + When an abstract class has zero subclasses, no covering axiom can be + generated. An info message alerts users that the class hierarchy is + incomplete — this is not a warning because abstract leaf classes are + a normal pattern in base schemas designed for downstream extension. + + See: mgskjaeveland's review on linkml/linkml#3309. + See: matentzn's review on linkml/linkml#3309. + """ + sb = SchemaBuilder() + sb.add_class("Orphan", abstract=True) + sb.add_defaults() + + with caplog.at_level(logging.INFO, logger="linkml.generators.owlgen"): + g = _owl_graph(sb) + + # No covering axiom emitted + assert _union_members(g, EX.Orphan) is None + + # An info message must be logged (not a warning) + assert any("has no children" in msg for msg in caplog.messages), ( + "Expected an info message about abstract class with no children" + ) + assert any("No covering axiom" in msg for msg in caplog.messages), ( + "Info message should mention that no covering axiom will be generated" + ) + + +def test_no_children_info_suppressed_by_skip_flag(caplog): + """When --skip-abstract-class-as-unionof-subclasses is set, no info for zero children.""" + sb = SchemaBuilder() + sb.add_class("Orphan", abstract=True) + sb.add_defaults() + + with caplog.at_level(logging.INFO, logger="linkml.generators.owlgen"): + _owl_graph(sb, skip_abstract_class_as_unionof_subclasses=True) + + assert not any("has no children" in msg for msg in caplog.messages) + + +def test_abstract_class_with_single_child_emits_warning(caplog): + """An abstract class with one child still gets a covering axiom but emits a warning. + + Per OWL 2 semantics, the covering axiom with a single child creates an + equivalence (Parent ≡ Child). This is logically correct but may surprise + users who plan to extend the ontology later. The generator should warn + and recommend ``--skip-abstract-class-as-unionof-subclasses``. + + See: W3C OWL 2 Primer §4.2 — bidirectional rdfs:subClassOf = equivalence. + See: mgskjaeveland's review on linkml/linkml#3309. + """ + sb = SchemaBuilder() + sb.add_class("GrandParent") + sb.add_class("Parent", is_a="GrandParent", abstract=True) + sb.add_class("Child", is_a="Parent") + sb.add_defaults() + + with caplog.at_level(logging.WARNING, logger="linkml.generators.owlgen"): + g = _owl_graph(sb) + + # Covering axiom IS still emitted (single child → equivalence is OWL-correct). + # With one child, _union_of returns the child URI directly (no owl:unionOf wrapper), + # so the covering axiom materialises as Parent rdfs:subClassOf Child. + # Combined with Child rdfs:subClassOf Parent (from is_a), this is the equivalence. + assert (EX.Parent, RDFS.subClassOf, EX.Child) in g, ( + "Covering axiom should produce Parent rdfs:subClassOf Child for single-child case" + ) + assert (EX.Child, RDFS.subClassOf, EX.Parent) in g + assert (EX.Parent, RDFS.subClassOf, EX.GrandParent) in g + + # But a warning must be logged + assert any("only 1 direct child" in msg for msg in caplog.messages), ( + "Expected a warning about single-child covering axiom creating equivalence" + ) + assert any("--skip-abstract-class-as-unionof-subclasses" in msg for msg in caplog.messages), ( + "Warning should recommend the skip flag" + ) + + +def test_single_child_warning_suppressed_by_skip_flag(caplog): + """When --skip-abstract-class-as-unionof-subclasses is set, no warning is emitted. + + The skip flag suppresses covering axioms entirely, so the single-child + equivalence case never arises. + """ + sb = SchemaBuilder() + sb.add_class("Parent", abstract=True) + sb.add_class("Child", is_a="Parent") + sb.add_defaults() + + with caplog.at_level(logging.WARNING, logger="linkml.generators.owlgen"): + g = _owl_graph(sb, skip_abstract_class_as_unionof_subclasses=True) + + # No covering axiom emitted + assert (EX.Parent, RDFS.subClassOf, EX.Child) not in g + # No warning either + assert not any("only 1 direct child" in msg for msg in caplog.messages) + + +def test_multiple_children_no_warning(caplog): + """An abstract class with 2+ children must NOT emit a warning. + + The covering axiom is a proper union (not a degenerate equivalence), + so no warning is needed. + """ + sb = SchemaBuilder() + sb.add_class("Animal", abstract=True) + sb.add_class("Dog", is_a="Animal") + sb.add_class("Cat", is_a="Animal") + sb.add_defaults() + + with caplog.at_level(logging.WARNING, logger="linkml.generators.owlgen"): + g = _owl_graph(sb) + + # Covering axiom emitted (proper union) + members = _union_members(g, EX.Animal) + assert members == {EX.Dog, EX.Cat} + + # No warning about children count + assert not any("has no children" in msg for msg in caplog.messages) + assert not any("only 1 direct child" in msg for msg in caplog.messages) + + +def test_non_abstract_class_no_warning(caplog): + """A non-abstract class must NOT emit covering axiom warnings. + + Covering axioms only apply to abstract classes. Concrete classes + should be silently skipped regardless of child count. + """ + sb = SchemaBuilder() + sb.add_class("Parent") # not abstract + sb.add_class("Child", is_a="Parent") + sb.add_defaults() + + with caplog.at_level(logging.WARNING, logger="linkml.generators.owlgen"): + g = _owl_graph(sb) + + # No covering axiom for non-abstract class + assert _union_members(g, EX.Parent) is None + assert (EX.Parent, RDFS.subClassOf, EX.Child) not in g + + # No warning either + assert not any("has no children" in msg for msg in caplog.messages) + assert not any("only 1 direct child" in msg for msg in caplog.messages) + + +def test_abstract_class_with_only_mixin_children_emits_info(caplog): + """An abstract class whose only children are via mixins (not is_a) gets the no-children info. + + The covering axiom only considers direct is_a children (not mixins). + If an abstract class has mixin children but no is_a children, it should + log an info message about having no children for covering axiom purposes. + """ + sb = SchemaBuilder() + sb.add_class("Base", abstract=True) + sb.add_class("MixinChild", mixins=["Base"]) + sb.add_defaults() + + with caplog.at_level(logging.INFO, logger="linkml.generators.owlgen"): + g = _owl_graph(sb) + + assert _union_members(g, EX.Base) is None + assert any("has no children" in msg for msg in caplog.messages), ( + "Abstract class with only mixin children should log info about no is_a children" + ) + + @pytest.mark.parametrize("skip", [False, True]) def test_union_of_axiom_only_covers_direct_children(skip: bool): """Union-of axiom lists only direct is_a children, not grandchildren. diff --git a/tests/linkml/test_generators/test_rustgen.py b/tests/linkml/test_generators/test_rustgen.py index e93d9acc3e..47950ca0b3 100644 --- a/tests/linkml/test_generators/test_rustgen.py +++ b/tests/linkml/test_generators/test_rustgen.py @@ -551,6 +551,269 @@ def test_rustgen_special_cases_roundtrip(temp_dir): assert key in output_data +def test_rustgen_aliased_key_preserves_explicit_value_in_inlined_dict(temp_dir): + """ + When a class is used as an inlined-as-dict and its key slot has a serde alias, + the generated InlinedPair::from_pair_mapping must not unconditionally inject + the inferred key. If the inner map already carries the key under either its + canonical name or its alias, the inferred insert produces a duplicate field + that serde rejects via alias resolution. + + This mirrors the linkml metamodel's Extension/Annotation pattern where the + key slot ``extension_tag`` has ``alias: tag`` and real-world schemas write + the tag explicitly inside the inner mapping. + """ + schema_yaml = textwrap.dedent( + """ + id: https://example.org/rustgen/aliased_key + name: rustgen_aliased_key + prefixes: + ex: https://example.org/rustgen/ + linkml: https://w3id.org/linkml/ + default_prefix: ex + default_range: string + imports: + - linkml:types + + classes: + AnnotationLike: + attributes: + ann_tag: + identifier: true + alias: tag + range: string + ann_value: + range: string + required: true + + Root: + tree_root: true + attributes: + annotations: + range: AnnotationLike + inlined: true + multivalued: true + """ + ) + + schema_path = Path(temp_dir) / "rustgen_aliased_key.yaml" + schema_path.write_text(schema_yaml, encoding="utf-8") + + sv = SchemaView(str(schema_path)) + out_dir = Path(temp_dir) / "aliased_key_crate" + rg = RustGenerator( + sv.schema, + mode="crate", + pyo3=False, + serde=True, + output=str(out_dir), + handwritten_lib=False, + ) + rg.serialize(force=True) + + generated_rs = (out_dir / "src" / "lib.rs").read_text(encoding="utf-8") + + # Template-level assertions: the guard exists and references both names. + assert 'contains_key(&Value::String("ann_tag".into()))' in generated_rs + assert 'contains_key(&Value::String("tag".into()))' in generated_rs + + # And the struct really does declare the serde alias we are guarding against. + assert 'serde(alias = "tag")' in generated_rs + + cargo_toml = (out_dir / "Cargo.toml").read_text(encoding="utf-8") + crate_match = re.search(r"^name\s*=\s*\"([A-Za-z0-9_-]+)\"", cargo_toml, re.MULTILINE) + assert crate_match + crate_ident = crate_match.group(1).replace("-", "_") + + tests_dir = out_dir / "tests" + tests_dir.mkdir(exist_ok=True) + roundtrip_rs = tests_dir / "aliased_key.rs" + roundtrip_rs.write_text( + ( + '#[cfg(feature = "serde")]\n' + "#[test]\n" + "fn explicit_alias_inside_inlined_dict_round_trips() {\n" + f" use {crate_ident}::Root;\n" + " // Inner map carries `tag:` (the alias) explicitly. The outer key\n" + " // agrees with it. The old generator injected `ann_tag` on top of\n" + " // the existing `tag`, which serde then rejected as a duplicate.\n" + ' let yaml = "annotations:\\n foo:\\n tag: foo\\n ann_value: bar\\n";\n' + ' let value: Root = serde_yml::from_str(yaml).expect("decode with alias key");\n' + ' let anns = value.annotations.as_ref().expect("annotations present");\n' + ' let entry = anns.get("foo").expect("foo entry");\n' + ' assert_eq!(entry.ann_tag, "foo");\n' + ' assert_eq!(entry.ann_value, "bar");\n' + "}\n" + '#[cfg(feature = "serde")]\n' + "#[test]\n" + "fn explicit_canonical_key_inside_inlined_dict_round_trips() {\n" + f" use {crate_ident}::Root;\n" + ' let yaml = "annotations:\\n foo:\\n ann_tag: foo\\n ann_value: bar\\n";\n' + ' let value: Root = serde_yml::from_str(yaml).expect("decode with canonical key");\n' + ' let entry = value.annotations.as_ref().unwrap().get("foo").unwrap();\n' + ' assert_eq!(entry.ann_tag, "foo");\n' + "}\n" + '#[cfg(feature = "serde")]\n' + "#[test]\n" + "fn missing_inner_key_is_synthesized_from_outer_key() {\n" + f" use {crate_ident}::Root;\n" + ' let yaml = "annotations:\\n foo:\\n ann_value: bar\\n";\n' + ' let value: Root = serde_yml::from_str(yaml).expect("decode without inner key");\n' + ' let entry = value.annotations.as_ref().unwrap().get("foo").unwrap();\n' + ' assert_eq!(entry.ann_tag, "foo");\n' + "}\n" + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env.setdefault("RUST_BACKTRACE", "1") + result = subprocess.run( + ["cargo", "test", "--features", "serde", "--test", "aliased_key"], + cwd=out_dir, + capture_output=True, + text=True, + env=env, + ) + if result.returncode != 0: + pytest.skip( + "cargo test failed, likely due to a missing Rust toolchain:\n" + f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}\n" + ) + + +def test_rustgen_simple_dict_with_recursive_multivalued_sibling(temp_dir): + """ + When a class has a key slot, a primitive-typed required value slot, and a + recursive multivalued sibling slot (the linkml metamodel + ``Annotation``/``Extension`` shape), the inlined-as-dict form must still + accept the ``key: `` simple-dict shorthand. The required value + slot is selected via the ``simple_dict_value: true`` annotation per the + rules in ``linkml.utils.helpers.get_range_associated_slots`` (the same + ladder linkml/linkml#1250 documents). + + Earlier versions of rustgen disqualified this shape because they counted + *all* non-key slots (including the multivalued recursive ones) when + deciding whether ``can_convert_from_primitive`` should hold, which caused + every consumer schema using annotations in their simple form to fail + deserialization with "Cannot create a … from a primitive value!". + """ + schema_yaml = textwrap.dedent( + """ + id: https://example.org/rustgen/simple_dict_recursive + name: rustgen_simple_dict_recursive + prefixes: + ex: https://example.org/rustgen/ + linkml: https://w3id.org/linkml/ + default_prefix: ex + default_range: string + imports: + - linkml:types + + classes: + # ``Anything`` is the canonical wildcard class linkml gen-rust + # special-cases (`class_uri: linkml:Any`). It's emitted as + # `struct Anything(serde_value::Value)`, accepting any primitive. + Anything: + class_uri: linkml:Any + + AnnotationLike: + attributes: + tag: + identifier: true + range: string + value: + required: true + range: Anything + annotations: + simple_dict_value: true + annotations: + range: AnnotationLike + multivalued: true + inlined: true + + Root: + tree_root: true + attributes: + annotations: + range: AnnotationLike + inlined: true + multivalued: true + """ + ) + + schema_path = Path(temp_dir) / "rustgen_simple_dict_recursive.yaml" + schema_path.write_text(schema_yaml, encoding="utf-8") + + sv = SchemaView(str(schema_path)) + out_dir = Path(temp_dir) / "simple_dict_recursive_crate" + RustGenerator( + sv.schema, + mode="crate", + pyo3=False, + serde=True, + output=str(out_dir), + handwritten_lib=False, + ).serialize(force=True) + + lib_rs = (out_dir / "src" / "lib.rs").read_text(encoding="utf-8") + + # The shape qualifies for primitive-form deserialization: from_pair_simple + # should be a real synthesizer, not the "Cannot create …" stub. + assert "Cannot create a AnnotationLike from a primitive value!" not in lib_rs + # And `simple_value` should be emitted, exposing the chosen value slot. + assert "fn simple_value(&self)" in lib_rs + + cargo_toml = (out_dir / "Cargo.toml").read_text(encoding="utf-8") + crate_match = re.search(r"^name\s*=\s*\"([A-Za-z0-9_-]+)\"", cargo_toml, re.MULTILINE) + assert crate_match + crate_ident = crate_match.group(1).replace("-", "_") + + tests_dir = out_dir / "tests" + tests_dir.mkdir(exist_ok=True) + test_rs = tests_dir / "simple_dict_recursive.rs" + test_rs.write_text( + ( + '#[cfg(feature = "serde")]\n' + "#[test]\n" + "fn simple_dict_with_recursive_sibling_round_trips() {\n" + f" use {crate_ident}::Root;\n" + ' // The classic linkml "annotations: {tag: value}" shorthand.\n' + ' let yaml = "annotations:\\n color: blue\\n weight: 42\\n";\n' + ' let value: Root = serde_yml::from_str(yaml).expect("decode simple-dict");\n' + ' let anns = value.annotations.as_ref().expect("annotations present");\n' + " assert_eq!(anns.len(), 2);\n" + "}\n" + '#[cfg(feature = "serde")]\n' + "#[test]\n" + "fn nested_simple_dict_round_trips() {\n" + f" use {crate_ident}::Root;\n" + " // Recursive use of the multivalued sibling slot.\n" + ' let yaml = "annotations:\\n outer:\\n value: top\\n annotations:\\n inner: bottom\\n";\n' + ' let value: Root = serde_yml::from_str(yaml).expect("decode nested");\n' + ' let outer = value.annotations.as_ref().unwrap().get("outer").unwrap();\n' + ' assert!(outer.annotations.is_some(), "inner annotations populated");\n' + "}\n" + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env.setdefault("RUST_BACKTRACE", "1") + result = subprocess.run( + ["cargo", "test", "--features", "serde", "--test", "simple_dict_recursive"], + cwd=out_dir, + capture_output=True, + text=True, + env=env, + ) + if result.returncode != 0: + pytest.skip( + "cargo test failed, likely due to a missing Rust toolchain:\n" + f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}\n" + ) + + def test_subproperty_of_generates_rust_enum(temp_dir): """Test that subproperty_of generates a Rust enum with slot descendants.""" schema_yaml = textwrap.dedent( diff --git a/uv.lock b/uv.lock index 2871de179c..c23e3dffd7 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -609,7 +610,8 @@ version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -794,6 +796,66 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + [[package]] name = "curies" version = "0.12.3" @@ -1100,6 +1162,131 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, +] + +[[package]] +name = "google-cloud-bigquery" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-resumable-media" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, + { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, + { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/4b/0b235beccc310d0a48adbc7246b719d173cca6c88c572dfa4b090e39143c/google_resumable_media-2.9.0.tar.gz", hash = "sha256:f7cfb224846a9dd444d125115dfbe8ef02a2b893e78f087762fe716a255a734b", size = 2164534, upload-time = "2026-05-07T08:04:44.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/73/3518e63deb1667c5409a4579e28daf5e84479a87a72c547e0487f7883dcd/google_resumable_media-2.9.0-py3-none-any.whl", hash = "sha256:c8901e88e389af8bed64d9696c74d8bad961865eb2236e13e0bfca9bb0a65ca3", size = 81507, upload-time = "2026-05-07T08:03:23.809Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "gql" version = "4.0.0" @@ -1189,6 +1376,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1339,7 +1601,8 @@ version = "9.6.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1975,6 +2238,9 @@ dependencies = [ ] [package.dev-dependencies] +bigquery = [ + { name = "sqlalchemy-bigquery" }, +] dev = [ { name = "black" }, { name = "chardet" }, @@ -2004,6 +2270,7 @@ dev = [ { name = "requests-cache" }, { name = "rich" }, { name = "sphinx-design" }, + { name = "sqlalchemy-bigquery" }, { name = "testcontainers" }, { name = "tox" }, { name = "tox-uv" }, @@ -2036,6 +2303,7 @@ tests = [ { name = "duckdb" }, { name = "numpydantic" }, { name = "pyshacl" }, + { name = "sqlalchemy-bigquery" }, ] tests-extra = [ { name = "jsonpatch" }, @@ -2090,6 +2358,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +bigquery = [{ name = "sqlalchemy-bigquery", specifier = ">=1.9.0" }] dev = [ { name = "black", specifier = ">=24.0.0" }, { name = "chardet" }, @@ -2119,6 +2388,7 @@ dev = [ { name = "requests-cache", specifier = ">=1.2.0" }, { name = "rich", specifier = ">=13.7.1" }, { name = "sphinx-design", specifier = ">=0.5.0" }, + { name = "sqlalchemy-bigquery", specifier = ">=1.9.0" }, { name = "testcontainers", specifier = "==3.7.1" }, { name = "tox", specifier = ">=4" }, { name = "tox-uv" }, @@ -2146,6 +2416,7 @@ tests = [ { name = "duckdb", specifier = ">=1.5.2" }, { name = "numpydantic", specifier = ">=1.6.1" }, { name = "pyshacl", specifier = ">=0.25.0" }, + { name = "sqlalchemy-bigquery", specifier = ">=1.9.0" }, ] tests-extra = [ { name = "jsonpatch", specifier = ">=1.33" }, @@ -2826,7 +3097,8 @@ version = "2.3.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } @@ -3434,6 +3706,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "psutil" version = "7.1.2" @@ -3478,6 +3777,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -4449,7 +4769,8 @@ version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -4686,6 +5007,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, ] +[[package]] +name = "sqlalchemy-bigquery" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-bigquery" }, + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/94/6fd01b23a92a2372a71cd1670302a6c11b138ad80906914433e6ddbc1e1a/sqlalchemy_bigquery-1.17.0.tar.gz", hash = "sha256:472284546a0c79cbf99b1bb0f5f99c5131fa888ea25d2d53208e6863e5094e2f", size = 119746, upload-time = "2026-05-07T08:04:51.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/bf/64ae26c6b58665b76abee9f7e536cef0e886c37e1da0b18f75133ff2fa4d/sqlalchemy_bigquery-1.17.0-py3-none-any.whl", hash = "sha256:89c1d4fc9f045ce762c93bf4b73a6c51a203dcf0dbe2d9ade540c7c5e3ed01dd", size = 39802, upload-time = "2026-05-07T08:03:33.787Z" }, +] + [[package]] name = "stack-data" version = "0.6.3"