From 7abaae7ecf4c469e2baeabeafc59bda68ead68db Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 17:22:31 +0200 Subject: [PATCH 1/4] fix(owlgen): warn on covering axiom edge cases for abstract classes Emit warnings for abstract class covering axiom edge cases: - Zero children: warn that no covering axiom will be generated - One child: warn that the covering axiom degenerates to an equivalence (Parent = Child), recommending --skip-abstract-class-as-unionof-subclasses Both axioms are still emitted when applicable (semantically correct per OWL 2), but warnings alert users who extend the ontology downstream. Tests verify warnings are logged, flag suppression works, the single-child covering axiom triple is correctly asserted, plus negative tests for multi-child and concrete class cases, and the mixin-only children edge case. Refs: linkml/linkml#3309, linkml/linkml#3219 Signed-off-by: jdsika --- .../linkml/src/linkml/generators/owlgen.py | 30 +++- tests/linkml/test_generators/test_owlgen.py | 170 ++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) 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/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. From 9ceb1c13d4afeea879689eac7125290fa70e51c8 Mon Sep 17 00:00:00 2001 From: Frank Dekervel Date: Tue, 9 Jun 2026 18:30:11 +0200 Subject: [PATCH 2/4] fix(rustgen): correctly parse annotations and extensions (#3541) * fix(rustgen): preserve explicit key in inlined-as-dict deserialization The `as_key_value.rs.jinja` template unconditionally inserted the inferred key into the deserialization map before handing it to serde. When the inner mapping already carried the key under the slot's alias (emitted on the struct field via `#[serde(alias = "...")]`), the unconditional insert produced a duplicate field that serde rejected at alias resolution. This bit any LinkML schema with a key/identifier slot that declares an `alias:`. The most visible example is the linkml metamodel itself: `extension_tag` (key of `extension`/`annotation`) declares `alias: tag`, so any inlined-dict carrying an explicit `tag:` inside the inner mapping failed to deserialize. The fix: * `AsKeyValue` template model now carries `key_property_aliases`, populated from the key slot's singular `alias` (matching what `property.rs.jinja` emits as a serde alias). * The template wraps the inferred insert in a guard that checks the canonical name and every accepted alias. Test covers all three cases against a regenerated crate: explicit alias in inner map, explicit canonical name in inner map, and missing inner key synthesized from the outer key. * fix(rustgen): honour SimpleDict normalization rules for inlined-dict simple form After PR #2930 tightened the `simple_dict_possible` gate to `len(non_key_attrs) == 1`, any class with a key slot plus a primitive value slot *and* a multivalued recursive sibling slot stopped qualifying for the `key: ` shorthand. The linkml metamodel's own `Extension`/`Annotation` classes fit that exact shape, so every consumer schema using annotations in their simple-dict form failed to deserialize with "Cannot create a Annotation from a primitive value!". The fix: * Reuse the authoritative `get_range_associated_slots` helper, which encodes the three SimpleDict rules linkml documents (linkml/linkml#1250) and shares them with the rest of the toolchain: one non-key slot, the slot annotated `simple_dict_value: true`, or the unique required non-key slot. * Add a Rust-specific guard so we only emit primitive-form deserialization when the chosen value slot's range is a primitive/type/enum or a `linkml:Any` wildcard. Non-Any class ranges either store an inlined struct (which can't be built from a primitive) or a reference string (which would mismatch `Self::Value`). Regression test exercises the `Annotation` shape end-to-end via cargo: key slot + required primitive value slot + recursive multivalued sibling, with both flat and nested simple-dict input. --------- Co-authored-by: Frank Dekervel --- .../src/linkml/generators/rustgen/rustgen.py | 41 ++- .../src/linkml/generators/rustgen/template.py | 1 + .../rustgen/templates/as_key_value.rs.jinja | 9 +- tests/linkml/test_generators/test_rustgen.py | 263 ++++++++++++++++++ 4 files changed, 305 insertions(+), 9 deletions(-) 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/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/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( From 84c48ff59dc284478deef3fb72e4ff1d0afbb0c8 Mon Sep 17 00:00:00 2001 From: Frank Dekervel Date: Tue, 9 Jun 2026 19:58:11 +0200 Subject: [PATCH 3/4] fix(rustgen): emit typed PyO3 conversions for Anything (#3550) `anything.rs.jinja` rendered both directions of the `Anything` <-> Python bridge with debug-stringifying fallbacks: * `IntoPyObject::into_pyobject` only handled `Unit`/`Bool`/`String`/ `Seq`/`Map`. Every other `serde_value::Value` variant fell into a catch-all that did `format!("{:?}", other)` and returned the result as a `PyString`. So `Value::U64(42)` surfaced in Python as the string `"U64(42)"`, `Value::Bytes(b"hi")` as `"Bytes([104, 105])"`, etc. * `FromPyObject::extract_bound` only extracted `&str` and `bool`, in that order. Python ints and floats fell through to the stringifying fallback at the bottom. The `&str`-before-`bool` order was also wrong: `True` extracts as the string `"True"` rather than `Value::Bool(true)`. Replace both fallbacks with explicit arms: * `value_to_py` now handles `U8`/`U16`/`U32`/`U64`/`I8`/`I16`/`I32`/ `I64`/`F32`/`F64`/`Char`/`Bytes`/`Option`/`Newtype` with the right PyO3 conversions. Strings/bool/sequences/maps unchanged. The match is now exhaustive over `serde_value::Value`, so a future variant addition will fail the build rather than silently stringify. * `py_to_value` now tries `bool`, then `i64`, then `f64`, then `&str`. `bool` first because Python `bool` is a subclass of `int`; `i64` before `f64` so integers don't widen to floats. Co-authored-by: Frank Dekervel --- .../rustgen/templates/anything.rs.jinja | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) 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()) - } } } From 1868cd0044d20f231696f2e0ced1893f5f1554ca Mon Sep 17 00:00:00 2001 From: Harshad Date: Tue, 9 Jun 2026 20:01:48 -0500 Subject: [PATCH 4/4] `BigQueryGenerator` with ARRAY, STRUCT, PARTITION BY, and CLUSTER BY support (#3585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add BigQueryGenerator — BQ DDL with native ARRAY, STRUCT, type mapping * add BigQueryGenerator — native ARRAY, STRUCT, PARTITION BY, CLUSTER BY DDL * uv.lock updated * pre-commit checks cleared * bump idna 3.11 → 3.17 in uv.lock to fix pre-existing CI failures * package not found error caught * attempt one to calm codecov down * attempt two to calm codecov down * Full feature test that includes most aspects of a typical BQ query * attempt three to calm codecov down * attempt four to calm codecov down * add BigQuery DDL generator docs and compliance test registration * register BigQuery in compliance test CORE_FRAMEWORKS and pytest markers * RST title underline too short in bigquery.rst * add sqlalchemy-bigquery to tests dependency group * uv lock --------- Co-authored-by: Kevin Schaper --- docs/generators/bigquery.rst | 59 ++ docs/generators/index.rst | 1 + packages/linkml/pyproject.toml | 7 +- .../src/linkml/generators/bigquerygen.py | 313 ++++++++ .../linkml/src/linkml/utils/deprecation.py | 9 +- pyproject.toml | 1 + tests/linkml/test_compliance/helper.py | 13 + .../linkml/test_compliance/test_compliance.py | 2 + .../input/bigquery_features.yaml | 201 +++++ .../test_generators/test_bigquerygen.py | 715 ++++++++++++++++++ uv.lock | 347 ++++++++- 11 files changed, 1659 insertions(+), 9 deletions(-) create mode 100644 docs/generators/bigquery.rst create mode 100644 packages/linkml/src/linkml/generators/bigquerygen.py create mode 100644 tests/linkml/test_generators/input/bigquery_features.yaml create mode 100644 tests/linkml/test_generators/test_bigquerygen.py 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/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/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"