From 46f87225cee415d66832bab2aa1424bd09cb97f0 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Tue, 10 Sep 2024 15:04:27 -0400 Subject: [PATCH 01/15] Addition of reference folder and schemapi reference files --- reference/altair_schemapi/__init__.py | 8 + reference/altair_schemapi/codegen.py | 380 +++++++ reference/altair_schemapi/schemapi.py | 1496 +++++++++++++++++++++++++ reference/altair_schemapi/utils.py | 902 +++++++++++++++ 4 files changed, 2786 insertions(+) create mode 100755 reference/altair_schemapi/__init__.py create mode 100755 reference/altair_schemapi/codegen.py create mode 100755 reference/altair_schemapi/schemapi.py create mode 100755 reference/altair_schemapi/utils.py diff --git a/reference/altair_schemapi/__init__.py b/reference/altair_schemapi/__init__.py new file mode 100755 index 00000000..023a9a2a --- /dev/null +++ b/reference/altair_schemapi/__init__.py @@ -0,0 +1,8 @@ +"""schemapi: tools for generating Python APIs from JSON schemas.""" + +from tools.schemapi import codegen, utils +from tools.schemapi.codegen import CodeSnippet +from tools.schemapi.schemapi import SchemaBase, Undefined +from tools.schemapi.utils import SchemaInfo + +__all__ = ["CodeSnippet", "SchemaBase", "SchemaInfo", "Undefined", "codegen", "utils"] diff --git a/reference/altair_schemapi/codegen.py b/reference/altair_schemapi/codegen.py new file mode 100755 index 00000000..cf8ea81b --- /dev/null +++ b/reference/altair_schemapi/codegen.py @@ -0,0 +1,380 @@ +"""Code generation utilities.""" + +from __future__ import annotations + +import re +import textwrap +from dataclasses import dataclass +from typing import Final + +from .utils import ( + SchemaInfo, + TypeAliasTracer, + flatten, + indent_docstring, + is_valid_identifier, + jsonschema_to_python_types, + spell_literal, +) + + +class CodeSnippet: + """Object whose repr() is a string of code.""" + + def __init__(self, code: str): + self.code = code + + def __repr__(self) -> str: + return self.code + + +@dataclass +class ArgInfo: + nonkeyword: bool + required: set[str] + kwds: set[str] + invalid_kwds: set[str] + additional: bool + + +def get_args(info: SchemaInfo) -> ArgInfo: + """Return the list of args & kwds for building the __init__ function.""" + # TODO: - set additional properties correctly + # - handle patternProperties etc. + required: set[str] = set() + kwds: set[str] = set() + invalid_kwds: set[str] = set() + + # TODO: specialize for anyOf/oneOf? + + if info.is_allOf(): + # recursively call function on all children + arginfo = [get_args(child) for child in info.allOf] + nonkeyword = all(args.nonkeyword for args in arginfo) + required = set.union(set(), *(args.required for args in arginfo)) + kwds = set.union(set(), *(args.kwds for args in arginfo)) + kwds -= required + invalid_kwds = set.union(set(), *(args.invalid_kwds for args in arginfo)) + additional = all(args.additional for args in arginfo) + elif info.is_empty() or info.is_compound(): + nonkeyword = True + additional = True + elif info.is_value(): + nonkeyword = True + additional = False + elif info.is_object(): + invalid_kwds = {p for p in info.required if not is_valid_identifier(p)} | { + p for p in info.properties if not is_valid_identifier(p) + } + required = {p for p in info.required if is_valid_identifier(p)} + kwds = {p for p in info.properties if is_valid_identifier(p)} + kwds -= required + nonkeyword = False + additional = True + # additional = info.additionalProperties or info.patternProperties + else: + msg = "Schema object not understood" + raise ValueError(msg) + + return ArgInfo( + nonkeyword=nonkeyword, + required=required, + kwds=kwds, + invalid_kwds=invalid_kwds, + additional=additional, + ) + + +class SchemaGenerator: + """ + Class that defines methods for generating code from schemas. + + Parameters + ---------- + classname : string + The name of the class to generate + schema : dict + The dictionary defining the schema class + rootschema : dict (optional) + The root schema for the class + basename : string or list of strings (default: "SchemaBase") + The name(s) of the base class(es) to use in the class definition + schemarepr : CodeSnippet or object, optional + An object whose repr will be used in the place of the explicit schema. + This can be useful, for example, when the generated code should reference + a predefined schema object. The user must ensure that the schema within + the evaluated code is identical to the schema used to generate the code. + rootschemarepr : CodeSnippet or object, optional + An object whose repr will be used in the place of the explicit root + schema. + **kwargs : dict + Additional keywords for derived classes. + """ + + schema_class_template = textwrap.dedent( + ''' + class {classname}({basename}): + """{docstring}""" + _schema = {schema!r} + _rootschema = {rootschema!r} + + {init_code} + ''' + ) + + init_template: Final = textwrap.dedent( + """ + def __init__({arglist}): + super({classname}, self).__init__({super_arglist}) + """ + ).lstrip() + + def _process_description(self, description: str): + return description + + def __init__( + self, + classname: str, + schema: dict, + rootschema: dict | None = None, + basename: str | list[str] = "SchemaBase", + schemarepr: object | None = None, + rootschemarepr: object | None = None, + nodefault: list[str] | None = None, + haspropsetters: bool = False, + **kwargs, + ) -> None: + self.classname = classname + self.schema = schema + self.rootschema = rootschema + self.basename = basename + self.schemarepr = schemarepr + self.rootschemarepr = rootschemarepr + self.nodefault = nodefault or () + self.haspropsetters = haspropsetters + self.kwargs = kwargs + + def subclasses(self) -> list[str]: + """Return a list of subclass names, if any.""" + info = SchemaInfo(self.schema, self.rootschema) + return [child.refname for child in info.anyOf if child.is_reference()] + + def schema_class(self) -> str: + """Generate code for a schema class.""" + rootschema: dict = ( + self.rootschema if self.rootschema is not None else self.schema + ) + schemarepr: object = ( + self.schemarepr if self.schemarepr is not None else self.schema + ) + rootschemarepr = self.rootschemarepr + if rootschemarepr is None: + if rootschema is self.schema: + rootschemarepr = CodeSnippet("_schema") + else: + rootschemarepr = rootschema + if isinstance(self.basename, str): + basename = self.basename + else: + basename = ", ".join(self.basename) + return self.schema_class_template.format( + classname=self.classname, + basename=basename, + schema=schemarepr, + rootschema=rootschemarepr, + docstring=self.docstring(indent=4), + init_code=self.init_code(indent=4), + method_code=self.method_code(indent=4), + **self.kwargs, + ) + + @property + def info(self) -> SchemaInfo: + return SchemaInfo(self.schema, self.rootschema) + + @property + def arg_info(self) -> ArgInfo: + return get_args(self.info) + + def docstring(self, indent: int = 0) -> str: + info = self.info + # https://numpydoc.readthedocs.io/en/latest/format.html#short-summary + doc = [f"{self.classname} schema wrapper"] + if info.description: + # https://numpydoc.readthedocs.io/en/latest/format.html#extended-summary + # Remove condition from description + desc: str = re.sub(r"\n\{\n(\n|.)*\n\}", "", info.description) + ext_summary: list[str] = self._process_description(desc).splitlines() + # Remove lines which contain the "raw-html" directive which cannot be processed + # by Sphinx at this level of the docstring. It works for descriptions + # of attributes which is why we do not do the same below. The removed + # lines are anyway non-descriptive for a user. + ext_summary = [line for line in ext_summary if ":raw-html:" not in line] + # Only add an extended summary if the above did not result in an empty list. + if ext_summary: + doc.append("") + doc.extend(ext_summary) + + if info.properties: + arg_info = self.arg_info + doc += ["", "Parameters", "----------", ""] + for prop in ( + sorted(arg_info.required) + + sorted(arg_info.kwds) + + sorted(arg_info.invalid_kwds) + ): + propinfo = info.properties[prop] + doc += [ + f"{prop} : {propinfo.get_python_type_representation()}", + f" {self._process_description(propinfo.deep_description)}", + ] + return indent_docstring(doc, indent_level=indent, width=100, lstrip=True) + + def init_code(self, indent: int = 0) -> str: + """Return code suitable for the __init__ function of a Schema class.""" + args, super_args = self.init_args() + + initfunc = self.init_template.format( + classname=self.classname, + arglist=", ".join(args), + super_arglist=", ".join(super_args), + ) + if indent: + initfunc = ("\n" + indent * " ").join(initfunc.splitlines()) + return initfunc + + def init_args( + self, additional_types: list[str] | None = None + ) -> tuple[list[str], list[str]]: + additional_types = additional_types or [] + info = self.info + arg_info = self.arg_info + + nodefault = set(self.nodefault) + arg_info.required -= nodefault + arg_info.kwds -= nodefault + + args: list[str] = ["self"] + super_args: list[str] = [] + + self.init_kwds = sorted(arg_info.kwds) + + if nodefault: + args.extend(sorted(nodefault)) + elif arg_info.nonkeyword: + args.append("*args") + super_args.append("*args") + + args.extend( + f"{p}: Optional[Union[" + + ", ".join( + [ + *additional_types, + *info.properties[p].get_python_type_representation( + for_type_hints=True, return_as_str=False + ), + ] + ) + + "]] = Undefined" + for p in sorted(arg_info.required) + sorted(arg_info.kwds) + ) + super_args.extend( + f"{p}={p}" + for p in sorted(nodefault) + + sorted(arg_info.required) + + sorted(arg_info.kwds) + ) + + if arg_info.additional: + args.append("**kwds") + super_args.append("**kwds") + return args, super_args + + def get_args(self, si: SchemaInfo) -> list[str]: + contents = ["self"] + prop_infos: dict[str, SchemaInfo] = {} + if si.is_anyOf(): + prop_infos = {} + for si_sub in si.anyOf: + prop_infos.update(si_sub.properties) + elif si.properties: + prop_infos = dict(si.properties.items()) + + if prop_infos: + contents.extend( + [ + f"{p}: " + + info.get_python_type_representation( + for_type_hints=True, additional_type_hints=["UndefinedType"] + ) + + " = Undefined" + for p, info in prop_infos.items() + ] + ) + elif si.type: + py_type = jsonschema_to_python_types[si.type] + if py_type == "list": + # Try to get a type hint like "List[str]" which is more specific + # then just "list" + item_vl_type = si.items.get("type", None) + if item_vl_type is not None: + item_type = jsonschema_to_python_types[item_vl_type] + else: + item_si = SchemaInfo(si.items, self.rootschema) + assert item_si.is_reference() + altair_class_name = item_si.title + item_type = f"core.{altair_class_name}" + py_type = f"List[{item_type}]" + elif si.is_literal(): + # If it's an enum, we can type hint it as a Literal which tells + # a type checker that only the values in enum are acceptable + py_type = TypeAliasTracer.add_literal( + si, spell_literal(si.literal), replace=True + ) + contents.append(f"_: {py_type}") + + contents.append("**kwds") + + return contents + + def get_signature( + self, attr: str, sub_si: SchemaInfo, indent: int, has_overload: bool = False + ) -> list[str]: + lines = [] + if has_overload: + lines.append("@overload") + args = ", ".join(self.get_args(sub_si)) + lines.extend( + (f"def {attr}({args}) -> '{self.classname}':", indent * " " + "...\n") + ) + return lines + + def setter_hint(self, attr: str, indent: int) -> list[str]: + si = SchemaInfo(self.schema, self.rootschema).properties[attr] + if si.is_anyOf(): + return self._get_signature_any_of(si, attr, indent) + else: + return self.get_signature(attr, si, indent, has_overload=True) + + def _get_signature_any_of( + self, si: SchemaInfo, attr: str, indent: int + ) -> list[str]: + signatures = [] + for sub_si in si.anyOf: + if sub_si.is_anyOf(): + # Recursively call method again to go a level deeper + signatures.extend(self._get_signature_any_of(sub_si, attr, indent)) + else: + signatures.extend( + self.get_signature(attr, sub_si, indent, has_overload=True) + ) + return list(flatten(signatures)) + + def method_code(self, indent: int = 0) -> str | None: + """Return code to assist setter methods.""" + if not self.haspropsetters: + return None + args = self.init_kwds + type_hints = [hint for a in args for hint in self.setter_hint(a, indent)] + + return ("\n" + indent * " ").join(type_hints) diff --git a/reference/altair_schemapi/schemapi.py b/reference/altair_schemapi/schemapi.py new file mode 100755 index 00000000..5140073a --- /dev/null +++ b/reference/altair_schemapi/schemapi.py @@ -0,0 +1,1496 @@ +from __future__ import annotations + +import contextlib +import copy +import inspect +import json +import sys +import textwrap +from collections import defaultdict +from functools import partial +from importlib.metadata import version as importlib_version +from itertools import chain, zip_longest +from math import ceil +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Final, + Iterable, + Iterator, + List, + Literal, + Sequence, + TypeVar, + Union, + cast, + overload, +) +from typing_extensions import TypeAlias + +import jsonschema +import jsonschema.exceptions +import jsonschema.validators +import narwhals.stable.v1 as nw +from packaging.version import Version + +# This leads to circular imports with the vegalite module. Currently, this works +# but be aware that when you access it in this script, the vegalite module might +# not yet be fully instantiated in case your code is being executed during import time +from altair import vegalite + +if TYPE_CHECKING: + from types import ModuleType + from typing import ClassVar + + from referencing import Registry + + from altair.typing import ChartType + + if sys.version_info >= (3, 13): + from typing import TypeIs + else: + from typing_extensions import TypeIs + + if sys.version_info >= (3, 11): + from typing import Never, Self + else: + from typing_extensions import Never, Self + _OptionalModule: TypeAlias = "ModuleType | None" + +ValidationErrorList: TypeAlias = List[jsonschema.exceptions.ValidationError] +GroupedValidationErrors: TypeAlias = Dict[str, ValidationErrorList] + +# This URI is arbitrary and could be anything else. It just cannot be an empty +# string as we need to reference the schema registered in +# the referencing.Registry. +_VEGA_LITE_ROOT_URI: Final = "urn:vega-lite-schema" + +# Ideally, jsonschema specification would be parsed from the current Vega-Lite +# schema instead of being hardcoded here as a default value. +# However, due to circular imports between this module and the altair.vegalite +# modules, this information is not yet available at this point as altair.vegalite +# is only partially loaded. The draft version which is used is unlikely to +# change often so it's ok to keep this. There is also a test which validates +# that this value is always the same as in the Vega-Lite schema. +_DEFAULT_JSON_SCHEMA_DRAFT_URL: Final = "http://json-schema.org/draft-07/schema#" + + +# If DEBUG_MODE is True, then schema objects are converted to dict and +# validated at creation time. This slows things down, particularly for +# larger specs, but leads to much more useful tracebacks for the user. +# Individual schema classes can override this by setting the +# class-level _class_is_valid_at_instantiation attribute to False +DEBUG_MODE: bool = True + +jsonschema_version_str = importlib_version("jsonschema") + + +def enable_debug_mode() -> None: + global DEBUG_MODE + DEBUG_MODE = True + + +def disable_debug_mode() -> None: + global DEBUG_MODE + DEBUG_MODE = False + + +@contextlib.contextmanager +def debug_mode(arg: bool) -> Iterator[None]: + global DEBUG_MODE + original = DEBUG_MODE + DEBUG_MODE = arg + try: + yield + finally: + DEBUG_MODE = original + + +@overload +def validate_jsonschema( + spec: Any, + schema: dict[str, Any], + rootschema: dict[str, Any] | None = ..., + *, + raise_error: Literal[True] = ..., +) -> Never: ... + + +@overload +def validate_jsonschema( + spec: Any, + schema: dict[str, Any], + rootschema: dict[str, Any] | None = ..., + *, + raise_error: Literal[False], +) -> jsonschema.exceptions.ValidationError | None: ... + + +def validate_jsonschema( + spec, + schema: dict[str, Any], + rootschema: dict[str, Any] | None = None, + *, + raise_error: bool = True, +) -> jsonschema.exceptions.ValidationError | None: + """ + Validates the passed in spec against the schema in the context of the rootschema. + + If any errors are found, they are deduplicated and prioritized + and only the most relevant errors are kept. Errors are then either raised + or returned, depending on the value of `raise_error`. + """ + errors = _get_errors_from_spec(spec, schema, rootschema=rootschema) + if errors: + leaf_errors = _get_leaves_of_error_tree(errors) + grouped_errors = _group_errors_by_json_path(leaf_errors) + grouped_errors = _subset_to_most_specific_json_paths(grouped_errors) + grouped_errors = _deduplicate_errors(grouped_errors) + + # Nothing special about this first error but we need to choose one + # which can be raised + main_error: Any = next(iter(grouped_errors.values()))[0] + # All errors are then attached as a new attribute to ValidationError so that + # they can be used in SchemaValidationError to craft a more helpful + # error message. Setting a new attribute like this is not ideal as + # it then no longer matches the type ValidationError. It would be better + # to refactor this function to never raise but only return errors. + main_error._all_errors = grouped_errors + if raise_error: + raise main_error + else: + return main_error + else: + return None + + +def _get_errors_from_spec( + spec: dict[str, Any], + schema: dict[str, Any], + rootschema: dict[str, Any] | None = None, +) -> ValidationErrorList: + """ + Uses the relevant jsonschema validator to validate the passed in spec against the schema using the rootschema to resolve references. + + The schema and rootschema themselves are not validated but instead considered as valid. + """ + # We don't use jsonschema.validate as this would validate the schema itself. + # Instead, we pass the schema directly to the validator class. This is done for + # two reasons: The schema comes from Vega-Lite and is not based on the user + # input, therefore there is no need to validate it in the first place. Furthermore, + # the "uri-reference" format checker fails for some of the references as URIs in + # "$ref" are not encoded, + # e.g. '#/definitions/ValueDefWithCondition' would be a valid $ref in a Vega-Lite schema but + # it is not a valid URI reference due to the characters such as '<'. + + json_schema_draft_url = _get_json_schema_draft_url(rootschema or schema) + validator_cls = jsonschema.validators.validator_for( + {"$schema": json_schema_draft_url} + ) + validator_kwargs: dict[str, Any] = {} + if hasattr(validator_cls, "FORMAT_CHECKER"): + validator_kwargs["format_checker"] = validator_cls.FORMAT_CHECKER + + if _use_referencing_library(): + schema = _prepare_references_in_schema(schema) + validator_kwargs["registry"] = _get_referencing_registry( + rootschema or schema, json_schema_draft_url + ) + + else: + # No resolver is necessary if the schema is already the full schema + validator_kwargs["resolver"] = ( + jsonschema.RefResolver.from_schema(rootschema) + if rootschema is not None + else None + ) + + validator = validator_cls(schema, **validator_kwargs) + errors = list(validator.iter_errors(spec)) + return errors + + +def _get_json_schema_draft_url(schema: dict[str, Any]) -> str: + return schema.get("$schema", _DEFAULT_JSON_SCHEMA_DRAFT_URL) + + +def _use_referencing_library() -> bool: + """In version 4.18.0, the jsonschema package deprecated RefResolver in favor of the referencing library.""" + return Version(jsonschema_version_str) >= Version("4.18") + + +def _prepare_references_in_schema(schema: dict[str, Any]) -> dict[str, Any]: + # Create a copy so that $ref is not modified in the original schema in case + # that it would still reference a dictionary which might be attached to + # an Altair class _schema attribute + schema = copy.deepcopy(schema) + + def _prepare_refs(d: dict[str, Any]) -> dict[str, Any]: + """ + Add _VEGA_LITE_ROOT_URI in front of all $ref values. + + This function recursively iterates through the whole dictionary. + + $ref values can only be nested in dictionaries or lists + as the passed in `d` dictionary comes from the Vega-Lite json schema + and in json we only have arrays (-> lists in Python) and objects + (-> dictionaries in Python) which we need to iterate through. + """ + for key, value in d.items(): + if key == "$ref": + d[key] = _VEGA_LITE_ROOT_URI + d[key] + elif isinstance(value, dict): + d[key] = _prepare_refs(value) + elif isinstance(value, list): + prepared_values = [] + for v in value: + if isinstance(v, dict): + v = _prepare_refs(v) + prepared_values.append(v) + d[key] = prepared_values + return d + + schema = _prepare_refs(schema) + return schema + + +# We do not annotate the return value here as the referencing library is not always +# available and this function is only executed in those cases. +def _get_referencing_registry( + rootschema: dict[str, Any], json_schema_draft_url: str | None = None +) -> Registry: + # Referencing is a dependency of newer jsonschema versions, starting with the + # version that is specified in _use_referencing_library and we therefore + # can expect that it is installed if the function returns True. + # We ignore 'import' mypy errors which happen when the referencing library + # is not installed. That's ok as in these cases this function is not called. + # We also have to ignore 'unused-ignore' errors as mypy raises those in case + # referencing is installed. + import referencing # type: ignore[import,unused-ignore] + import referencing.jsonschema # type: ignore[import,unused-ignore] + + if json_schema_draft_url is None: + json_schema_draft_url = _get_json_schema_draft_url(rootschema) + + specification = referencing.jsonschema.specification_with(json_schema_draft_url) + resource = specification.create_resource(rootschema) + return referencing.Registry().with_resource( + uri=_VEGA_LITE_ROOT_URI, resource=resource + ) + + +def _json_path(err: jsonschema.exceptions.ValidationError) -> str: + """ + Drop in replacement for the .json_path property of the jsonschema ValidationError class. + + This is not available as property for ValidationError with jsonschema<4.0.1. + + More info, see https://github.com/vega/altair/issues/3038. + """ + path = "$" + for elem in err.absolute_path: + if isinstance(elem, int): + path += "[" + str(elem) + "]" + else: + path += "." + elem + return path + + +def _group_errors_by_json_path( + errors: ValidationErrorList, +) -> GroupedValidationErrors: + """ + Groups errors by the `json_path` attribute of the jsonschema ValidationError class. + + This attribute contains the path to the offending element within + a chart specification and can therefore be considered as an identifier of an + 'issue' in the chart that needs to be fixed. + """ + errors_by_json_path = defaultdict(list) + for err in errors: + err_key = getattr(err, "json_path", _json_path(err)) + errors_by_json_path[err_key].append(err) + return dict(errors_by_json_path) + + +def _get_leaves_of_error_tree( + errors: ValidationErrorList, +) -> ValidationErrorList: + """ + For each error in `errors`, it traverses down the "error tree" that is generated by the jsonschema library to find and return all "leaf" errors. + + These are errors which have no further errors that caused it and so they are the most specific errors + with the most specific error messages. + """ + leaves: ValidationErrorList = [] + for err in errors: + if err.context: + # This means that the error `err` was caused by errors in subschemas. + # The list of errors from the subschemas are available in the property + # `context`. + leaves.extend(_get_leaves_of_error_tree(err.context)) + else: + leaves.append(err) + return leaves + + +def _subset_to_most_specific_json_paths( + errors_by_json_path: GroupedValidationErrors, +) -> GroupedValidationErrors: + """ + Removes key (json path), value (errors) pairs where the json path is fully contained in another json path. + + For example if `errors_by_json_path` has two keys, `$.encoding.X` and `$.encoding.X.tooltip`, + then the first one will be removed and only the second one is returned. + + This is done under the assumption that more specific json paths give more helpful error messages to the user. + """ + errors_by_json_path_specific: GroupedValidationErrors = {} + for json_path, errors in errors_by_json_path.items(): + if not _contained_at_start_of_one_of_other_values( + json_path, list(errors_by_json_path.keys()) + ): + errors_by_json_path_specific[json_path] = errors + return errors_by_json_path_specific + + +def _contained_at_start_of_one_of_other_values(x: str, values: Sequence[str]) -> bool: + # Does not count as "contained at start of other value" if the values are + # the same. These cases should be handled separately + return any(value.startswith(x) for value in values if x != value) + + +def _deduplicate_errors( + grouped_errors: GroupedValidationErrors, +) -> GroupedValidationErrors: + """ + Some errors have very similar error messages or are just in general not helpful for a user. + + This function removes as many of these cases as possible and + can be extended over time to handle new cases that come up. + """ + grouped_errors_deduplicated: GroupedValidationErrors = {} + for json_path, element_errors in grouped_errors.items(): + errors_by_validator = _group_errors_by_validator(element_errors) + + deduplication_functions = { + "enum": _deduplicate_enum_errors, + "additionalProperties": _deduplicate_additional_properties_errors, + } + deduplicated_errors: ValidationErrorList = [] + for validator, errors in errors_by_validator.items(): + deduplication_func = deduplication_functions.get(validator) + if deduplication_func is not None: + errors = deduplication_func(errors) + deduplicated_errors.extend(_deduplicate_by_message(errors)) + + # Removes any ValidationError "'value' is a required property" as these + # errors are unlikely to be the relevant ones for the user. They come from + # validation against a schema definition where the output of `alt.value` + # would be valid. However, if a user uses `alt.value`, the `value` keyword + # is included automatically from that function and so it's unlikely + # that this was what the user intended if the keyword is not present + # in the first place. + deduplicated_errors = [ + err for err in deduplicated_errors if not _is_required_value_error(err) + ] + + grouped_errors_deduplicated[json_path] = deduplicated_errors + return grouped_errors_deduplicated + + +def _is_required_value_error(err: jsonschema.exceptions.ValidationError) -> bool: + return err.validator == "required" and err.validator_value == ["value"] + + +def _group_errors_by_validator(errors: ValidationErrorList) -> GroupedValidationErrors: + """ + Groups the errors by the json schema "validator" that casued the error. + + For example if the error is that a value is not one of an enumeration in the json schema + then the "validator" is `"enum"`, if the error is due to an unknown property that + was set although no additional properties are allowed then "validator" is + `"additionalProperties`, etc. + """ + errors_by_validator: defaultdict[str, ValidationErrorList] = defaultdict(list) + for err in errors: + # Ignore mypy error as err.validator as it wrongly sees err.validator + # as of type Optional[Validator] instead of str which it is according + # to the documentation and all tested cases + errors_by_validator[err.validator].append(err) # type: ignore[index] + return dict(errors_by_validator) + + +def _deduplicate_enum_errors(errors: ValidationErrorList) -> ValidationErrorList: + """ + Deduplicate enum errors by removing the errors where the allowed values are a subset of another error. + + For example, if `enum` contains two errors and one has `validator_value` (i.e. accepted values) ["A", "B"] and the + other one ["A", "B", "C"] then the first one is removed and the final + `enum` list only contains the error with ["A", "B", "C"]. + """ + if len(errors) > 1: + # Values (and therefore `validator_value`) of an enum are always arrays, + # see https://json-schema.org/understanding-json-schema/reference/generic.html#enumerated-values + # which is why we can use join below + value_strings = [",".join(err.validator_value) for err in errors] # type: ignore + longest_enums: ValidationErrorList = [] + for value_str, err in zip(value_strings, errors): + if not _contained_at_start_of_one_of_other_values(value_str, value_strings): + longest_enums.append(err) + errors = longest_enums + return errors + + +def _deduplicate_additional_properties_errors( + errors: ValidationErrorList, +) -> ValidationErrorList: + """ + If there are multiple additional property errors it usually means that the offending element was validated against multiple schemas and its parent is a common anyOf validator. + + The error messages produced from these cases are usually + very similar and we just take the shortest one. For example, + the following 3 errors are raised for the `unknown` channel option in + `alt.X("variety", unknown=2)`: + - "Additional properties are not allowed ('unknown' was unexpected)" + - "Additional properties are not allowed ('field', 'unknown' were unexpected)" + - "Additional properties are not allowed ('field', 'type', 'unknown' were unexpected)". + """ + if len(errors) > 1: + # Test if all parent errors are the same anyOf error and only do + # the prioritization in these cases. Can't think of a chart spec where this + # would not be the case but still allow for it below to not break anything. + parent = errors[0].parent + if ( + parent is not None + and parent.validator == "anyOf" + # Use [1:] as don't have to check for first error as it was used + # above to define `parent` + and all(err.parent is parent for err in errors[1:]) + ): + errors = [min(errors, key=lambda x: len(x.message))] + return errors + + +def _deduplicate_by_message(errors: ValidationErrorList) -> ValidationErrorList: + """Deduplicate errors by message. This keeps the original order in case it was chosen intentionally.""" + return list({e.message: e for e in errors}.values()) + + +def _subclasses(cls: type[Any]) -> Iterator[type[Any]]: + """Breadth-first sequence of all classes which inherit from cls.""" + seen = set() + current_set = {cls} + while current_set: + seen |= current_set + current_set = set.union(*(set(cls.__subclasses__()) for cls in current_set)) + for cls in current_set - seen: + yield cls + + +def _from_array_like(obj: Iterable[Any], /) -> list[Any]: + try: + ser = nw.from_native(obj, strict=True, series_only=True) + return ser.to_list() + except TypeError: + return list(obj) + + +def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any) -> Any: # noqa: C901 + """Convert an object to a dict representation.""" + if np_opt is not None: + np = np_opt + if isinstance(obj, np.ndarray): + return [_todict(v, context, np_opt, pd_opt) for v in obj] + elif isinstance(obj, np.number): + return float(obj) + elif isinstance(obj, np.datetime64): + result = str(obj) + if "T" not in result: + # See https://github.com/vega/altair/issues/1027 for why this is necessary. + result += "T00:00:00" + return result + if isinstance(obj, SchemaBase): + return obj.to_dict(validate=False, context=context) + elif isinstance(obj, (list, tuple)): + return [_todict(v, context, np_opt, pd_opt) for v in obj] + elif isinstance(obj, dict): + return { + k: _todict(v, context, np_opt, pd_opt) + for k, v in obj.items() + if v is not Undefined + } + elif ( + hasattr(obj, "to_dict") + and (module_name := obj.__module__) + and module_name.startswith("altair") + ): + return obj.to_dict() + elif pd_opt is not None and isinstance(obj, pd_opt.Timestamp): + return pd_opt.Timestamp(obj).isoformat() + elif _is_iterable(obj, exclude=(str, bytes)): + return _todict(_from_array_like(obj), context, np_opt, pd_opt) + else: + return obj + + +def _resolve_references( + schema: dict[str, Any], rootschema: dict[str, Any] | None = None +) -> dict[str, Any]: + """Resolve schema references until there is no $ref anymore in the top-level of the dictionary.""" + if _use_referencing_library(): + registry = _get_referencing_registry(rootschema or schema) + # Using a different variable name to show that this is not the + # jsonschema.RefResolver but instead a Resolver from the referencing + # library + referencing_resolver = registry.resolver() + while "$ref" in schema: + schema = referencing_resolver.lookup( + _VEGA_LITE_ROOT_URI + schema["$ref"] + ).contents + else: + resolver = jsonschema.RefResolver.from_schema(rootschema or schema) + while "$ref" in schema: + with resolver.resolving(schema["$ref"]) as resolved: + schema = resolved + return schema + + +class SchemaValidationError(jsonschema.ValidationError): + def __init__(self, obj: SchemaBase, err: jsonschema.ValidationError) -> None: + """ + A wrapper for ``jsonschema.ValidationError`` with friendlier traceback. + + Parameters + ---------- + obj + The instance that failed ``self.validate(...)``. + err + The original ``ValidationError``. + + Notes + ----- + We do not raise `from err` as else the resulting traceback is very long + as it contains part of the Vega-Lite schema. + + It would also first show the less helpful `ValidationError` instead of + the more user friendly `SchemaValidationError`. + """ + super().__init__(**err._contents()) + self.obj = obj + self._errors: GroupedValidationErrors = getattr( + err, "_all_errors", {getattr(err, "json_path", _json_path(err)): [err]} + ) + # This is the message from err + self._original_message = self.message + self.message = self._get_message() + + def __str__(self) -> str: + return self.message + + def _get_message(self) -> str: + def indent_second_line_onwards(message: str, indent: int = 4) -> str: + modified_lines: list[str] = [] + for idx, line in enumerate(message.split("\n")): + if idx > 0 and len(line) > 0: + line = " " * indent + line + modified_lines.append(line) + return "\n".join(modified_lines) + + error_messages: list[str] = [] + # Only show a maximum of 3 errors as else the final message returned by this + # method could get very long. + for errors in list(self._errors.values())[:3]: + error_messages.append(self._get_message_for_errors_group(errors)) + + message = "" + if len(error_messages) > 1: + error_messages = [ + indent_second_line_onwards(f"Error {error_id}: {m}") + for error_id, m in enumerate(error_messages, start=1) + ] + message += "Multiple errors were found.\n\n" + message += "\n\n".join(error_messages) + return message + + def _get_message_for_errors_group( + self, + errors: ValidationErrorList, + ) -> str: + if errors[0].validator == "additionalProperties": + # During development, we only found cases where an additionalProperties + # error was raised if that was the only error for the offending instance + # as identifiable by the json path. Therefore, we just check here the first + # error. However, other constellations might exist in which case + # this should be adapted so that other error messages are shown as well. + message = self._get_additional_properties_error_message(errors[0]) + else: + message = self._get_default_error_message(errors=errors) + + return message.strip() + + def _get_additional_properties_error_message( + self, + error: jsonschema.exceptions.ValidationError, + ) -> str: + """Output all existing parameters when an unknown parameter is specified.""" + altair_cls = self._get_altair_class_for_error(error) + param_dict_keys = inspect.signature(altair_cls).parameters.keys() + param_names_table = self._format_params_as_table(param_dict_keys) + + # Error messages for these errors look like this: + # "Additional properties are not allowed ('unknown' was unexpected)" + # Line below extracts "unknown" from this string + parameter_name = error.message.split("('")[-1].split("'")[0] + message = f"""\ +`{altair_cls.__name__}` has no parameter named '{parameter_name}' + +Existing parameter names are: +{param_names_table} +See the help for `{altair_cls.__name__}` to read the full description of these parameters""" + return message + + def _get_altair_class_for_error( + self, error: jsonschema.exceptions.ValidationError + ) -> type[SchemaBase]: + """ + Try to get the lowest class possible in the chart hierarchy so it can be displayed in the error message. + + This should lead to more informative error messages pointing the user closer to the source of the issue. + """ + for prop_name in reversed(error.absolute_path): + # Check if str as e.g. first item can be a 0 + if isinstance(prop_name, str): + potential_class_name = prop_name[0].upper() + prop_name[1:] + cls = getattr(vegalite, potential_class_name, None) + if cls is not None: + break + else: + # Did not find a suitable class based on traversing the path so we fall + # back on the class of the top-level object which created + # the SchemaValidationError + cls = self.obj.__class__ + return cls + + @staticmethod + def _format_params_as_table(param_dict_keys: Iterable[str]) -> str: + """Format param names into a table so that they are easier to read.""" + param_names: tuple[str, ...] + name_lengths: tuple[int, ...] + param_names, name_lengths = zip( + *[ + (name, len(name)) + for name in param_dict_keys + if name not in {"kwds", "self"} + ] + ) + # Worst case scenario with the same longest param name in the same + # row for all columns + max_name_length = max(name_lengths) + max_column_width = 80 + # Output a square table if not too big (since it is easier to read) + num_param_names = len(param_names) + square_columns = int(ceil(num_param_names**0.5)) + columns = min(max_column_width // max_name_length, square_columns) + + # Compute roughly equal column heights to evenly divide the param names + def split_into_equal_parts(n: int, p: int) -> list[int]: + return [n // p + 1] * (n % p) + [n // p] * (p - n % p) + + column_heights = split_into_equal_parts(num_param_names, columns) + + # Section the param names into columns and compute their widths + param_names_columns: list[tuple[str, ...]] = [] + column_max_widths: list[int] = [] + last_end_idx: int = 0 + for ch in column_heights: + param_names_columns.append(param_names[last_end_idx : last_end_idx + ch]) + column_max_widths.append( + max(len(param_name) for param_name in param_names_columns[-1]) + ) + last_end_idx = ch + last_end_idx + + # Transpose the param name columns into rows to facilitate looping + param_names_rows: list[tuple[str, ...]] = [] + for li in zip_longest(*param_names_columns, fillvalue=""): + param_names_rows.append(li) + # Build the table as a string by iterating over and formatting the rows + param_names_table: str = "" + for param_names_row in param_names_rows: + for num, param_name in enumerate(param_names_row): + # Set column width based on the longest param in the column + max_name_length_column = column_max_widths[num] + column_pad = 3 + param_names_table += "{:<{}}".format( + param_name, max_name_length_column + column_pad + ) + # Insert newlines and spacing after the last element in each row + if num == (len(param_names_row) - 1): + param_names_table += "\n" + return param_names_table + + def _get_default_error_message( + self, + errors: ValidationErrorList, + ) -> str: + bullet_points: list[str] = [] + errors_by_validator = _group_errors_by_validator(errors) + if "enum" in errors_by_validator: + for error in errors_by_validator["enum"]: + bullet_points.append(f"one of {error.validator_value}") + + if "type" in errors_by_validator: + types = [f"'{err.validator_value}'" for err in errors_by_validator["type"]] + point = "of type " + if len(types) == 1: + point += types[0] + elif len(types) == 2: + point += f"{types[0]} or {types[1]}" + else: + point += ", ".join(types[:-1]) + f", or {types[-1]}" + bullet_points.append(point) + + # It should not matter which error is specifically used as they are all + # about the same offending instance (i.e. invalid value), so we can just + # take the first one + error = errors[0] + # Add a summary line when parameters are passed an invalid value + # For example: "'asdf' is an invalid value for `stack` + message = f"'{error.instance}' is an invalid value" + if error.absolute_path: + message += f" for `{error.absolute_path[-1]}`" + + # Add bullet points + if len(bullet_points) == 0: + message += ".\n\n" + elif len(bullet_points) == 1: + message += f". Valid values are {bullet_points[0]}.\n\n" + else: + # We don't use .capitalize below to make the first letter uppercase + # as that makes the rest of the message lowercase + bullet_points = [point[0].upper() + point[1:] for point in bullet_points] + message += ". Valid values are:\n\n" + message += "\n".join([f"- {point}" for point in bullet_points]) + message += "\n\n" + + # Add unformatted messages of any remaining errors which were not + # considered so far. This is not expected to be used but more exists + # as a fallback for cases which were not known during development. + it = ( + "\n".join(e.message for e in errors) + for validator, errors in errors_by_validator.items() + if validator not in {"enum", "type"} + ) + message += "".join(it) + return message + + +class UndefinedType: + """A singleton object for marking undefined parameters.""" + + __instance = None + + def __new__(cls, *args, **kwargs) -> Self: + if not isinstance(cls.__instance, cls): + cls.__instance = object.__new__(cls, *args, **kwargs) + return cls.__instance + + def __repr__(self) -> str: + return "Undefined" + + +Undefined = UndefinedType() +T = TypeVar("T") +Optional: TypeAlias = Union[T, UndefinedType] +"""One of ``T`` specified type(s), or the ``Undefined`` singleton. + +Examples +-------- +The parameters ``short``, ``long`` accept the same range of types:: + + # ruff: noqa: UP006, UP007 + from altair.typing import Optional + + def func_1( + short: Optional[str | bool | float | dict[str, Any] | SchemaBase] = Undefined, + long: Union[ + str, bool, float, Dict[str, Any], SchemaBase, UndefinedType + ] = Undefined, + ): ... + +This is distinct from `typing.Optional `__. + +``altair.typing.Optional`` treats ``None`` like any other type:: + + # ruff: noqa: UP006, UP007 + from altair.typing import Optional + + def func_2( + short: Optional[str | float | dict[str, Any] | None | SchemaBase] = Undefined, + long: Union[ + str, float, Dict[str, Any], None, SchemaBase, UndefinedType + ] = Undefined, + ): ... +""" + + +def is_undefined(obj: Any) -> TypeIs[UndefinedType]: + """ + Type-safe singleton check for `UndefinedType`. + + Notes + ----- + - Using `obj is Undefined` does not narrow from `UndefinedType` in a union. + - Due to the assumption that other `UndefinedType`'s could exist. + - Current [typing spec advises](https://typing.readthedocs.io/en/latest/spec/concepts.html#support-for-singleton-types-in-unions) using an `Enum`. + - Otherwise, requires an explicit guard to inform the type checker. + """ + return obj is Undefined + + +@overload +def _shallow_copy(obj: _CopyImpl) -> _CopyImpl: ... +@overload +def _shallow_copy(obj: Any) -> Any: ... +def _shallow_copy(obj: _CopyImpl | Any) -> _CopyImpl | Any: + if isinstance(obj, SchemaBase): + return obj.copy(deep=False) + elif isinstance(obj, (list, dict)): + return obj.copy() + else: + return obj + + +@overload +def _deep_copy(obj: _CopyImpl, by_ref: set[str]) -> _CopyImpl: ... +@overload +def _deep_copy(obj: Any, by_ref: set[str]) -> Any: ... +def _deep_copy(obj: _CopyImpl | Any, by_ref: set[str]) -> _CopyImpl | Any: + copy = partial(_deep_copy, by_ref=by_ref) + if isinstance(obj, SchemaBase): + if copier := getattr(obj, "__deepcopy__", None): + with debug_mode(False): + return copier(obj) + args = (copy(arg) for arg in obj._args) + kwds = {k: (copy(v) if k not in by_ref else v) for k, v in obj._kwds.items()} + with debug_mode(False): + return obj.__class__(*args, **kwds) + elif isinstance(obj, list): + return [copy(v) for v in obj] + elif isinstance(obj, dict): + return {k: (copy(v) if k not in by_ref else v) for k, v in obj.items()} + else: + return obj + + +class SchemaBase: + """ + Base class for schema wrappers. + + Each derived class should set the _schema class attribute (and optionally + the _rootschema class attribute) which is used for validation. + """ + + _schema: ClassVar[dict[str, Any] | Any] = None + _rootschema: ClassVar[dict[str, Any] | None] = None + _class_is_valid_at_instantiation: ClassVar[bool] = True + + def __init__(self, *args: Any, **kwds: Any) -> None: + # Two valid options for initialization, which should be handled by + # derived classes: + # - a single arg with no kwds, for, e.g. {'type': 'string'} + # - zero args with zero or more kwds for {'type': 'object'} + if self._schema is None: + msg = ( + f"Cannot instantiate object of type {self.__class__}: " + "_schema class attribute is not defined." + "" + ) + raise ValueError(msg) + + if kwds: + assert len(args) == 0 + else: + assert len(args) in {0, 1} + + # use object.__setattr__ because we override setattr below. + object.__setattr__(self, "_args", args) + object.__setattr__(self, "_kwds", kwds) + + if DEBUG_MODE and self._class_is_valid_at_instantiation: + self.to_dict(validate=True) + + def copy( + self, deep: bool | Iterable[Any] = True, ignore: list[str] | None = None + ) -> Self: + """ + Return a copy of the object. + + Parameters + ---------- + deep : boolean or list, optional + If True (default) then return a deep copy of all dict, list, and + SchemaBase objects within the object structure. + If False, then only copy the top object. + If a list or iterable, then only copy the listed attributes. + ignore : list, optional + A list of keys for which the contents should not be copied, but + only stored by reference. + """ + if deep is True: + return cast("Self", _deep_copy(self, set(ignore) if ignore else set())) + with debug_mode(False): + copy = self.__class__(*self._args, **self._kwds) + if _is_iterable(deep): + for attr in deep: + copy[attr] = _shallow_copy(copy._get(attr)) + return copy + + def _get(self, attr, default=Undefined): + """Get an attribute, returning default if not present.""" + attr = self._kwds.get(attr, Undefined) + if attr is Undefined: + attr = default + return attr + + def __getattr__(self, attr): + # reminder: getattr is called after the normal lookups + if attr == "_kwds": + raise AttributeError() + if attr in self._kwds: + return self._kwds[attr] + else: + try: + _getattr = super().__getattr__ # pyright: ignore[reportAttributeAccessIssue] + except AttributeError: + _getattr = super().__getattribute__ + return _getattr(attr) + + def __setattr__(self, item, val) -> None: + self._kwds[item] = val + + def __getitem__(self, item): + return self._kwds[item] + + def __setitem__(self, item, val) -> None: + self._kwds[item] = val + + def __repr__(self) -> str: + name = type(self).__name__ + if kwds := self._kwds: + it = (f"{k}: {v!r}" for k, v in sorted(kwds.items()) if v is not Undefined) + args = ",\n".join(it).replace("\n", "\n ") + LB, RB = "{", "}" + return f"{name}({LB}\n {args}\n{RB})" + else: + return f"{name}({self._args[0]!r})" + + def __eq__(self, other: Any) -> bool: + return ( + type(self) is type(other) + and self._args == other._args + and self._kwds == other._kwds + ) + + def to_dict( + self, + validate: bool = True, + *, + ignore: list[str] | None = None, + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """ + Return a dictionary representation of the object. + + Parameters + ---------- + validate : bool, optional + If True (default), then validate the result against the schema. + ignore : list[str], optional + A list of keys to ignore. + context : dict[str, Any], optional + A context dictionary. + + Raises + ------ + SchemaValidationError : + If ``validate`` and the result does not conform to the schema. + + Notes + ----- + - ``ignore``, ``context`` are usually not needed to be specified as a user. + - *Technical*: ``ignore`` will **not** be passed to child :meth:`.to_dict()`. + """ + context = context or {} + ignore = ignore or [] + opts = _get_optional_modules(np_opt="numpy", pd_opt="pandas") + + if self._args and not self._kwds: + kwds = self._args[0] + elif not self._args: + kwds = self._kwds.copy() + exclude = {*ignore, "shorthand"} + if parsed := context.pop("parsed_shorthand", None): + kwds = _replace_parsed_shorthand(parsed, kwds) + kwds = {k: v for k, v in kwds.items() if k not in exclude} + if (mark := kwds.get("mark")) and isinstance(mark, str): + kwds["mark"] = {"type": mark} + else: + msg = f"{type(self)} instance has both a value and properties : cannot serialize to dict" + raise ValueError(msg) + result = _todict(kwds, context=context, **opts) + if validate: + # NOTE: Don't raise `from err`, see `SchemaValidationError` doc + try: + self.validate(result) + except jsonschema.ValidationError as err: + raise SchemaValidationError(self, err) from None + return result + + def to_json( + self, + validate: bool = True, + indent: int | str | None = 2, + sort_keys: bool = True, + *, + ignore: list[str] | None = None, + context: dict[str, Any] | None = None, + **kwargs, + ) -> str: + """ + Emit the JSON representation for this object as a string. + + Parameters + ---------- + validate : bool, optional + If True (default), then validate the result against the schema. + indent : int, optional + The number of spaces of indentation to use. The default is 2. + sort_keys : bool, optional + If True (default), sort keys in the output. + ignore : list[str], optional + A list of keys to ignore. + context : dict[str, Any], optional + A context dictionary. + **kwargs + Additional keyword arguments are passed to ``json.dumps()`` + + Raises + ------ + SchemaValidationError : + If ``validate`` and the result does not conform to the schema. + + Notes + ----- + - ``ignore``, ``context`` are usually not needed to be specified as a user. + - *Technical*: ``ignore`` will **not** be passed to child :meth:`.to_dict()`. + """ + if ignore is None: + ignore = [] + if context is None: + context = {} + dct = self.to_dict(validate=validate, ignore=ignore, context=context) + return json.dumps(dct, indent=indent, sort_keys=sort_keys, **kwargs) + + @classmethod + def _default_wrapper_classes(cls) -> Iterator[type[SchemaBase]]: + """Return the set of classes used within cls.from_dict().""" + return _subclasses(SchemaBase) + + @classmethod + def from_dict( + cls: type[TSchemaBase], dct: dict[str, Any], validate: bool = True + ) -> TSchemaBase: + """ + Construct class from a dictionary representation. + + Parameters + ---------- + dct : dictionary + The dict from which to construct the class + validate : boolean + If True (default), then validate the input against the schema. + + Raises + ------ + jsonschema.ValidationError : + If ``validate`` and ``dct`` does not conform to the schema + """ + if validate: + cls.validate(dct) + converter = _FromDict(cls._default_wrapper_classes()) + return converter.from_dict(dct, cls) + + @classmethod + def from_json( + cls, + json_string: str, + validate: bool = True, + **kwargs: Any, + # Type hints for this method would get rather complicated + # if we want to provide a more specific return type + ) -> ChartType: + """ + Instantiate the object from a valid JSON string. + + Parameters + ---------- + json_string : string + The string containing a valid JSON chart specification. + validate : boolean + If True (default), then validate the input against the schema. + **kwargs : + Additional keyword arguments are passed to json.loads + + Returns + ------- + chart : Chart object + The altair Chart object built from the specification. + """ + dct: dict[str, Any] = json.loads(json_string, **kwargs) + return cls.from_dict(dct, validate=validate) # type: ignore[return-value] + + @classmethod + def validate( + cls, instance: dict[str, Any], schema: dict[str, Any] | None = None + ) -> None: + """Validate the instance against the class schema in the context of the rootschema.""" + if schema is None: + schema = cls._schema + # For the benefit of mypy + assert schema is not None + validate_jsonschema(instance, schema, rootschema=cls._rootschema or cls._schema) + + @classmethod + def resolve_references(cls, schema: dict[str, Any] | None = None) -> dict[str, Any]: + """Resolve references in the context of this object's schema or root schema.""" + schema_to_pass = schema or cls._schema + # For the benefit of mypy + assert schema_to_pass is not None + return _resolve_references( + schema=schema_to_pass, + rootschema=(cls._rootschema or cls._schema or schema), + ) + + @classmethod + def validate_property( + cls, name: str, value: Any, schema: dict[str, Any] | None = None + ) -> None: + """Validate a property against property schema in the context of the rootschema.""" + opts = _get_optional_modules(np_opt="numpy", pd_opt="pandas") + value = _todict(value, context={}, **opts) + props = cls.resolve_references(schema or cls._schema).get("properties", {}) + validate_jsonschema( + value, props.get(name, {}), rootschema=cls._rootschema or cls._schema + ) + + def __dir__(self) -> list[str]: + return sorted(chain(super().__dir__(), self._kwds)) + + +def _get_optional_modules(**modules: str) -> dict[str, _OptionalModule]: + """ + Returns packages only if they have already been imported - otherwise they return `None`. + + This is useful for `isinstance` checks. + + For example, if `pandas` has not been imported, then an object is + definitely not a `pandas.Timestamp`. + + Parameters + ---------- + **modules + Keyword-only binding from `{alias: module_name}`. + + Examples + -------- + >>> import pandas as pd # doctest: +SKIP + >>> import polars as pl # doctest: +SKIP + >>> from altair.utils.schemapi import _get_optional_modules # doctest: +SKIP + >>> + >>> _get_optional_modules(pd="pandas", pl="polars", ibis="ibis") # doctest: +SKIP + { + "pd": , + "pl": , + "ibis": None, + } + + If the user later imports ``ibis``, it would appear in subsequent calls. + + >>> import ibis # doctest: +SKIP + >>> + >>> _get_optional_modules(ibis="ibis") # doctest: +SKIP + { + "ibis": , + } + """ + return {k: sys.modules.get(v) for k, v in modules.items()} + + +def _replace_parsed_shorthand( + parsed_shorthand: dict[str, Any], kwds: dict[str, Any] +) -> dict[str, Any]: + """ + `parsed_shorthand` is added by `FieldChannelMixin`. + + It's used below to replace shorthand with its long form equivalent + `parsed_shorthand` is removed from `context` if it exists so that it is + not passed to child `to_dict` function calls. + """ + # Prevent that pandas categorical data is automatically sorted + # when a non-ordinal data type is specifed manually + # or if the encoding channel does not support sorting + if "sort" in parsed_shorthand and ( + "sort" not in kwds or kwds["type"] not in {"ordinal", Undefined} + ): + parsed_shorthand.pop("sort") + + kwds.update( + (k, v) + for k, v in parsed_shorthand.items() + if kwds.get(k, Undefined) is Undefined + ) + return kwds + + +TSchemaBase = TypeVar("TSchemaBase", bound=SchemaBase) + +_CopyImpl = TypeVar("_CopyImpl", SchemaBase, Dict[Any, Any], List[Any]) +""" +Types which have an implementation in ``SchemaBase.copy()``. + +All other types are returned **by reference**. +""" + + +def _is_dict(obj: Any | dict[Any, Any]) -> TypeIs[dict[Any, Any]]: + return isinstance(obj, dict) + + +def _is_list(obj: Any | list[Any]) -> TypeIs[list[Any]]: + return isinstance(obj, list) + + +def _is_iterable( + obj: Any, *, exclude: type | tuple[type, ...] = (str, bytes) +) -> TypeIs[Iterable[Any]]: + return not isinstance(obj, exclude) and isinstance(obj, Iterable) + + +def _passthrough(*args: Any, **kwds: Any) -> Any | dict[str, Any]: + return args[0] if args else kwds + + +class _FromDict: + """ + Class used to construct SchemaBase class hierarchies from a dict. + + The primary purpose of using this class is to be able to build a hash table + that maps schemas to their wrapper classes. The candidate classes are + specified in the ``wrapper_classes`` positional-only argument to the constructor. + """ + + _hash_exclude_keys = ("definitions", "title", "description", "$schema", "id") + + def __init__(self, wrapper_classes: Iterable[type[SchemaBase]], /) -> None: + # Create a mapping of a schema hash to a list of matching classes + # This lets us quickly determine the correct class to construct + self.class_dict: dict[int, list[type[SchemaBase]]] = defaultdict(list) + for tp in wrapper_classes: + if tp._schema is not None: + self.class_dict[self.hash_schema(tp._schema)].append(tp) + + @classmethod + def hash_schema(cls, schema: dict[str, Any], use_json: bool = True) -> int: + """ + Compute a python hash for a nested dictionary which properly handles dicts, lists, sets, and tuples. + + At the top level, the function excludes from the hashed schema all keys + listed in `exclude_keys`. + + This implements two methods: one based on conversion to JSON, and one based + on recursive conversions of unhashable to hashable types; the former seems + to be slightly faster in several benchmarks. + """ + if cls._hash_exclude_keys and isinstance(schema, dict): + schema = { + key: val + for key, val in schema.items() + if key not in cls._hash_exclude_keys + } + if use_json: + s = json.dumps(schema, sort_keys=True) + return hash(s) + else: + + def _freeze(val): + if isinstance(val, dict): + return frozenset((k, _freeze(v)) for k, v in val.items()) + elif isinstance(val, set): + return frozenset(map(_freeze, val)) + elif isinstance(val, (list, tuple)): + return tuple(map(_freeze, val)) + else: + return val + + return hash(_freeze(schema)) + + @overload + def from_dict( + self, + dct: TSchemaBase, + tp: None = ..., + schema: None = ..., + rootschema: None = ..., + default_class: Any = ..., + ) -> TSchemaBase: ... + @overload + def from_dict( + self, + dct: dict[str, Any] | list[dict[str, Any]], + tp: Any = ..., + schema: Any = ..., + rootschema: Any = ..., + default_class: type[TSchemaBase] = ..., # pyright: ignore[reportInvalidTypeVarUse] + ) -> TSchemaBase: ... + @overload + def from_dict( + self, + dct: dict[str, Any], + tp: None = ..., + schema: dict[str, Any] = ..., + rootschema: None = ..., + default_class: Any = ..., + ) -> SchemaBase: ... + @overload + def from_dict( + self, + dct: dict[str, Any], + tp: type[TSchemaBase], + schema: None = ..., + rootschema: None = ..., + default_class: Any = ..., + ) -> TSchemaBase: ... + @overload + def from_dict( + self, + dct: dict[str, Any] | list[dict[str, Any]], + tp: type[TSchemaBase], + schema: dict[str, Any], + rootschema: dict[str, Any] | None = ..., + default_class: Any = ..., + ) -> Never: ... + def from_dict( + self, + dct: dict[str, Any] | list[dict[str, Any]] | TSchemaBase, + tp: type[TSchemaBase] | None = None, + schema: dict[str, Any] | None = None, + rootschema: dict[str, Any] | None = None, + default_class: Any = _passthrough, + ) -> TSchemaBase | SchemaBase: + """Construct an object from a dict representation.""" + target_tp: Any + current_schema: dict[str, Any] + if isinstance(dct, SchemaBase): + return dct + elif tp is not None: + current_schema = tp._schema + root_schema: dict[str, Any] = rootschema or tp._rootschema or current_schema + target_tp = tp + elif schema is not None: + # If there are multiple matches, we use the first one in the dict. + # Our class dict is constructed breadth-first from top to bottom, + # so the first class that matches is the most general match. + current_schema = schema + root_schema = rootschema or current_schema + matches = self.class_dict[self.hash_schema(current_schema)] + target_tp = matches[0] if matches else default_class + else: + msg = "Must provide either `tp` or `schema`, but not both." + raise ValueError(msg) + + from_dict = partial(self.from_dict, rootschema=root_schema) + # Can also return a list? + resolved = _resolve_references(current_schema, root_schema) + if "anyOf" in resolved or "oneOf" in resolved: + schemas = resolved.get("anyOf", []) + resolved.get("oneOf", []) + for possible in schemas: + try: + validate_jsonschema(dct, possible, rootschema=root_schema) + except jsonschema.ValidationError: + continue + else: + return from_dict(dct, schema=possible, default_class=target_tp) + + if _is_dict(dct): + # TODO: handle schemas for additionalProperties/patternProperties + props: dict[str, Any] = resolved.get("properties", {}) + kwds = { + k: (from_dict(v, schema=props[k]) if k in props else v) + for k, v in dct.items() + } + return target_tp(**kwds) + elif _is_list(dct): + item_schema: dict[str, Any] = resolved.get("items", {}) + return target_tp([from_dict(k, schema=item_schema) for k in dct]) + else: + # NOTE: Unsure what is valid here + return target_tp(dct) + + +class _PropertySetter: + def __init__(self, prop: str, schema: dict[str, Any]) -> None: + self.prop = prop + self.schema = schema + + def __get__(self, obj, cls): + self.obj = obj + self.cls = cls + # The docs from the encoding class parameter (e.g. `bin` in X, Color, + # etc); this provides a general description of the parameter. + self.__doc__ = self.schema["description"].replace("__", "**") + property_name = f"{self.prop}"[0].upper() + f"{self.prop}"[1:] + if hasattr(vegalite, property_name): + altair_prop = getattr(vegalite, property_name) + # Add the docstring from the helper class (e.g. `BinParams`) so + # that all the parameter names of the helper class are included in + # the final docstring + parameter_index = altair_prop.__doc__.find("Parameters\n") + if parameter_index > -1: + self.__doc__ = ( + altair_prop.__doc__[:parameter_index].replace(" ", "") + + self.__doc__ + + textwrap.dedent( + f"\n\n {altair_prop.__doc__[parameter_index:]}" + ) + ) + # For short docstrings such as Aggregate, Stack, et + else: + self.__doc__ = ( + altair_prop.__doc__.replace(" ", "") + "\n" + self.__doc__ + ) + # Add signatures and tab completion for the method and parameter names + self.__signature__ = inspect.signature(altair_prop) + self.__wrapped__ = inspect.getfullargspec(altair_prop) + self.__name__ = altair_prop.__name__ + else: + # It seems like bandPosition is the only parameter that doesn't + # have a helper class. + pass + return self + + def __call__(self, *args: Any, **kwargs: Any): + obj = self.obj.copy() + # TODO: use schema to validate + obj[self.prop] = args[0] if args else kwargs + return obj + + +def with_property_setters(cls: type[TSchemaBase]) -> type[TSchemaBase]: + """Decorator to add property setters to a Schema class.""" + schema = cls.resolve_references() + for prop, propschema in schema.get("properties", {}).items(): + setattr(cls, prop, _PropertySetter(prop, propschema)) + return cls diff --git a/reference/altair_schemapi/utils.py b/reference/altair_schemapi/utils.py new file mode 100755 index 00000000..3fa30492 --- /dev/null +++ b/reference/altair_schemapi/utils.py @@ -0,0 +1,902 @@ +"""Utilities for working with schemas.""" + +from __future__ import annotations + +import keyword +import re +import subprocess +import textwrap +import urllib +from html import unescape +from itertools import chain +from operator import itemgetter +from typing import ( + TYPE_CHECKING, + Any, + Final, + Iterable, + Iterator, + Literal, + Sequence, + overload, +) + +import mistune +from mistune.renderers.rst import RSTRenderer as _RSTRenderer + +from tools.schemapi.schemapi import _resolve_references as resolve_references + +if TYPE_CHECKING: + from pathlib import Path + from typing_extensions import LiteralString + + from mistune import BlockState + +EXCLUDE_KEYS: Final = ("definitions", "title", "description", "$schema", "id") + +jsonschema_to_python_types = { + "string": "str", + "number": "float", + "integer": "int", + "object": "Map", + "boolean": "bool", + "array": "list", + "null": "None", +} + + +class _TypeAliasTracer: + """ + Recording all `enum` -> `Literal` translations. + + Rewrites as `TypeAlias` to be reused anywhere, and not clog up method definitions. + + Parameters + ---------- + fmt + A format specifier to produce the `TypeAlias` name. + + Will be provided a `SchemaInfo.title` as a single positional argument. + *ruff_check + Optional [ruff rule codes](https://docs.astral.sh/ruff/rules/), + each prefixed with `--select ` and follow a `ruff check --fix ` call. + + If not provided, uses `[tool.ruff.lint.select]` from `pyproject.toml`. + ruff_format + Optional argument list supplied to [ruff format](https://docs.astral.sh/ruff/formatter/#ruff-format) + + Attributes + ---------- + _literals: dict[str, str] + `{alias_name: literal_statement}` + _literals_invert: dict[str, str] + `{literal_statement: alias_name}` + aliases: list[tuple[str, str]] + `_literals` sorted by `alias_name` + _imports: Sequence[str] + Prefined import statements to appear at beginning of module. + """ + + def __init__( + self, + fmt: str = "{}_T", + *ruff_check: str, + ruff_format: Sequence[str] | None = None, + ) -> None: + self.fmt: str = fmt + self._literals: dict[str, str] = {} + self._literals_invert: dict[str, str] = {} + self._aliases: dict[str, str] = {} + self._imports: Sequence[str] = ( + "from __future__ import annotations\n", + "from typing import Any, Literal, Mapping, TypeVar, Sequence, Union", + "from typing_extensions import TypeAlias, TypeAliasType", + ) + self._cmd_check: list[str] = ["--fix"] + self._cmd_format: Sequence[str] = ruff_format or () + for c in ruff_check: + self._cmd_check.extend(("--extend-select", c)) + + def _update_literals(self, name: str, tp: str, /) -> None: + """Produces an inverted index, to reuse a `Literal` when `SchemaInfo.title` is empty.""" + self._literals[name] = tp + self._literals_invert[tp] = name + + def add_literal( + self, info: SchemaInfo, tp: str, /, *, replace: bool = False + ) -> str: + """ + `replace=True` returns the eventual alias name. + + - Doing so will mean that the `_typing` module must be written first, before the source of `info`. + - Otherwise, `ruff` will raise an error during `check`/`format`, as the import will be invalid. + - Where a `title` is not found, an attempt will be made to find an existing alias definition that had one. + """ + if info.title: + alias = self.fmt.format(info.title) + if alias not in self._literals: + self._update_literals(alias, tp) + if replace: + tp = alias + elif (alias := self._literals_invert.get(tp)) and replace: + tp = alias + elif replace and info.is_union_literal(): + # Handles one very specific edge case `WindowFieldDef` + # - Has an anonymous enum union + # - One of the members is declared afterwards + # - SchemaBase needs to be first, as the union wont be internally sorted + it = ( + self.add_literal(el, spell_literal(el.literal), replace=True) + for el in info.anyOf + ) + tp = f"Union[SchemaBase, {', '.join(it)}]" + return tp + + def update_aliases(self, *name_statement: tuple[str, str]) -> None: + """ + Adds `(name, statement)` pairs to the definitions. + + These types should support annotations in generated code, but + are not required to be derived from the schema itself. + + Each tuple will appear in the generated module as:: + + name: TypeAlias = statement + + All aliases will be written in runtime-scope, therefore + externally dependent types should be declared as regular imports. + """ + self._aliases.update(name_statement) + + def generate_aliases(self) -> Iterator[str]: + """Represents a line per `TypeAlias` declaration.""" + for name, statement in self._aliases.items(): + yield f"{name}: TypeAlias = {statement}" + + def is_cached(self, tp: str, /) -> bool: + """ + Applies to both docstring and type hints. + + Currently used as a sort key, to place literals/aliases last. + """ + return tp in self._literals_invert or tp in self._literals or tp in self._aliases # fmt: skip + + def write_module( + self, fp: Path, *extra_all: str, header: LiteralString, extra: LiteralString + ) -> None: + """ + Write all collected `TypeAlias`'s to `fp`. + + Parameters + ---------- + fp + Path to new module. + *extra_all + Any manually spelled types to be exported. + header + `tools.generate_schema_wrapper.HEADER`. + extra + `tools.generate_schema_wrapper.TYPING_EXTRA`. + """ + ruff_format = ["ruff", "format", fp] + if self._cmd_format: + ruff_format.extend(self._cmd_format) + commands = (["ruff", "check", fp, *self._cmd_check], ruff_format) + static = (header, "\n", *self._imports, "\n\n") + self.update_aliases(*sorted(self._literals.items(), key=itemgetter(0))) + all_ = [*iter(self._aliases), *extra_all] + it = chain( + static, + [f"__all__ = {all_}", "\n\n", extra], + self.generate_aliases(), + ) + fp.write_text("\n".join(it), encoding="utf-8") + for cmd in commands: + r = subprocess.run(cmd, check=True) + r.check_returncode() + + @property + def n_entries(self) -> int: + """Number of unique `TypeAlias` defintions collected.""" + return len(self._literals) + + +TypeAliasTracer: _TypeAliasTracer = _TypeAliasTracer("{}_T", "I001", "I002") +"""An instance of `_TypeAliasTracer`. + +Collects a cache of unique `Literal` types used globally. + +These are then converted to `TypeAlias` statements, written to another module. + +Allows for a single definition to be reused multiple times, +rather than repeating long literals in every method definition. +""" + + +def get_valid_identifier( + prop: str, + replacement_character: str = "", + allow_unicode: bool = False, + url_decode: bool = True, +) -> str: + """ + Given a string property, generate a valid Python identifier. + + Parameters + ---------- + prop: string + Name of property to decode. + replacement_character: string, default '' + The character to replace invalid characters with. + allow_unicode: boolean, default False + If True, then allow Python 3-style unicode identifiers. + url_decode: boolean, default True + If True, decode URL characters in identifier names. + + Examples + -------- + >>> get_valid_identifier("my-var") + 'myvar' + + >>> get_valid_identifier("if") + 'if_' + + >>> get_valid_identifier("$schema", "_") + '_schema' + + >>> get_valid_identifier("$*#$") + '_' + + >>> get_valid_identifier("Name%3Cstring%3E") + 'Namestring' + """ + # Decode URL characters. + if url_decode: + prop = urllib.parse.unquote(prop) + + # Deal with [] + prop = prop.replace("[]", "Array") + + # First substitute-out all non-valid characters. + flags = re.UNICODE if allow_unicode else re.ASCII + valid = re.sub(r"\W", replacement_character, prop, flags=flags) + + # If nothing is left, use just an underscore + if not valid: + valid = "_" + + # first character must be a non-digit. Prefix with an underscore + # if needed + if re.match(r"^[\d\W]", valid): + valid = "_" + valid + + # if the result is a reserved keyword, then add an underscore at the end + if keyword.iskeyword(valid): + valid += "_" + return valid + + +def is_valid_identifier(var: str, allow_unicode: bool = False): + """ + Return true if var contains a valid Python identifier. + + Parameters + ---------- + val : string + identifier to check + allow_unicode : bool (default: False) + if True, then allow Python 3 style unicode identifiers. + """ + flags = re.UNICODE if allow_unicode else re.ASCII + is_valid = re.match(r"^[^\d\W]\w*\Z", var, flags) + return is_valid and not keyword.iskeyword(var) + + +class SchemaProperties: + """A wrapper for properties within a schema.""" + + def __init__( + self, + properties: dict[str, Any], + schema: dict, + rootschema: dict | None = None, + ) -> None: + self._properties = properties + self._schema = schema + self._rootschema = rootschema or schema + + def __bool__(self) -> bool: + return bool(self._properties) + + def __dir__(self) -> list[str]: + return list(self._properties.keys()) + + def __getattr__(self, attr): + try: + return self[attr] + except KeyError: + return super().__getattr__(attr) + + def __getitem__(self, attr): + dct = self._properties[attr] + if "definitions" in self._schema and "definitions" not in dct: + dct = dict(definitions=self._schema["definitions"], **dct) + return SchemaInfo(dct, self._rootschema) + + def __iter__(self): + return iter(self._properties) + + def items(self): + return ((key, self[key]) for key in self) + + def keys(self): + return self._properties.keys() + + def values(self): + return (self[key] for key in self) + + +class SchemaInfo: + """A wrapper for inspecting a JSON schema.""" + + def __init__( + self, schema: dict[str, Any], rootschema: dict[str, Any] | None = None + ) -> None: + if not rootschema: + rootschema = schema + self.raw_schema = schema + self.rootschema = rootschema + self.schema = resolve_references(schema, rootschema) + + def child(self, schema: dict) -> SchemaInfo: + return self.__class__(schema, rootschema=self.rootschema) + + def __repr__(self) -> str: + keys = [] + for key in sorted(self.schema.keys()): + val = self.schema[key] + rval = repr(val).replace("\n", "") + if len(rval) > 30: + rval = rval[:30] + "..." + if key == "definitions": + rval = "{...}" + elif key == "properties": + rval = "{\n " + "\n ".join(sorted(map(repr, val))) + "\n }" + keys.append(f'"{key}": {rval}') + return "SchemaInfo({\n " + "\n ".join(keys) + "\n})" + + @property + def title(self) -> str: + if self.is_reference(): + return get_valid_identifier(self.refname) + else: + return "" + + @overload + def get_python_type_representation( + self, + for_type_hints: bool = ..., + return_as_str: Literal[True] = ..., + additional_type_hints: list[str] | None = ..., + ) -> str: ... + @overload + def get_python_type_representation( + self, + for_type_hints: bool = ..., + return_as_str: Literal[False] = ..., + additional_type_hints: list[str] | None = ..., + ) -> list[str]: ... + def get_python_type_representation( # noqa: C901 + self, + for_type_hints: bool = False, + return_as_str: bool = True, + additional_type_hints: list[str] | None = None, + ) -> str | list[str]: + type_representations: list[str] = [] + """ + All types which can be used for the current `SchemaInfo`. + Including `altair` classes, standard `python` types, etc. + """ + + if self.title: + if for_type_hints: + # To keep type hints simple, we only use the SchemaBase class + # as the type hint for all classes which inherit from it. + class_names = ["SchemaBase"] + if self.title in {"ExprRef", "ParameterExtent"}: + class_names.append("Parameter") + # In these cases, a value parameter is also always accepted. + # It would be quite complex to further differentiate + # between a value and a selection parameter based on + # the type system (one could + # try to check for the type of the Parameter.param attribute + # but then we would need to write some overload signatures for + # api.param). + + type_representations.extend(class_names) + else: + # use RST syntax for generated sphinx docs + type_representations.append(rst_syntax_for_class(self.title)) + + if self.is_empty(): + type_representations.append("Any") + elif self.is_literal(): + tp_str = spell_literal(self.literal) + if for_type_hints: + tp_str = TypeAliasTracer.add_literal(self, tp_str, replace=True) + type_representations.append(tp_str) + elif for_type_hints and self.is_union_literal(): + it = chain.from_iterable(el.literal for el in self.anyOf) + tp_str = TypeAliasTracer.add_literal(self, spell_literal(it), replace=True) + type_representations.append(tp_str) + elif self.is_anyOf(): + it = ( + s.get_python_type_representation( + for_type_hints=for_type_hints, return_as_str=False + ) + for s in self.anyOf + ) + type_representations.extend(maybe_rewrap_literal(chain.from_iterable(it))) + elif isinstance(self.type, list): + options = [] + subschema = SchemaInfo(dict(**self.schema)) + for typ_ in self.type: + subschema.schema["type"] = typ_ + # We always use title if possible for nested objects + options.append( + subschema.get_python_type_representation( + for_type_hints=for_type_hints + ) + ) + type_representations.extend(options) + elif self.is_object() and not for_type_hints: + type_representations.append("dict") + elif self.is_array(): + # A list is invariant in its type parameter. This means that e.g. + # List[str] is not a subtype of List[Union[core.FieldName, str]] + # and hence we would need to explicitly write out the combinations, + # so in this case: + # List[core.FieldName], List[str], List[core.FieldName, str] + # However, this can easily explode to too many combinations. + # Furthermore, we would also need to add additional entries + # for e.g. int wherever a float is accepted which would lead to very + # long code. + # As suggested in the mypy docs, + # https://mypy.readthedocs.io/en/stable/common_issues.html#variance, + # we revert to using Sequence which works as well for lists and also + # includes tuples which are also supported by the SchemaBase.to_dict + # method. However, it is not entirely accurate as some sequences + # such as e.g. a range are not supported by SchemaBase.to_dict but + # this tradeoff seems worth it. + s = self.child(self.items).get_python_type_representation( + for_type_hints=for_type_hints + ) + type_representations.append(f"Sequence[{s}]") + elif self.type in jsonschema_to_python_types: + type_representations.append(jsonschema_to_python_types[self.type]) + else: + msg = "No Python type representation available for this schema" + raise ValueError(msg) + + # Shorter types are usually the more relevant ones, e.g. `str` instead + # of `SchemaBase`. Output order from set is non-deterministic -> If + # types have same length names, order would be non-deterministic as it is + # returned from sort. Hence, we sort as well by type name as a tie-breaker, + # see https://docs.python.org/3.10/howto/sorting.html#sort-stability-and-complex-sorts + # for more infos. + # Using lower as we don't want to prefer uppercase such as "None" over + it = sorted(set(flatten(type_representations)), key=str.lower) # Tertiary sort + it = sorted(it, key=len) # Secondary sort + type_representations = sorted(it, key=TypeAliasTracer.is_cached) # Primary sort + if additional_type_hints: + type_representations.extend(additional_type_hints) + + if return_as_str: + type_representations_str = ", ".join(type_representations) + # If it's not for_type_hints but instead for the docstrings, we don't want + # to include Union as it just clutters the docstrings. + if len(type_representations) > 1 and for_type_hints: + # Use parameterised `TypeAlias` instead of exposing `UndefinedType` + # `Union` is collapsed by `ruff` later + if type_representations_str.endswith(", UndefinedType"): + s = type_representations_str.replace(", UndefinedType", "") + s = f"Optional[Union[{s}]]" + else: + s = f"Union[{type_representations_str}]" + return s + return type_representations_str + else: + return type_representations + + @property + def properties(self) -> SchemaProperties: + return SchemaProperties( + self.schema.get("properties", {}), self.schema, self.rootschema + ) + + @property + def definitions(self) -> SchemaProperties: + return SchemaProperties( + self.schema.get("definitions", {}), self.schema, self.rootschema + ) + + @property + def required(self) -> list: + return self.schema.get("required", []) + + @property + def patternProperties(self) -> dict: + return self.schema.get("patternProperties", {}) + + @property + def additionalProperties(self) -> bool: + return self.schema.get("additionalProperties", True) + + @property + def type(self) -> str | list[Any] | None: + return self.schema.get("type", None) + + @property + def anyOf(self) -> list[SchemaInfo]: + return [self.child(s) for s in self.schema.get("anyOf", [])] + + @property + def oneOf(self) -> list[SchemaInfo]: + return [self.child(s) for s in self.schema.get("oneOf", [])] + + @property + def allOf(self) -> list[SchemaInfo]: + return [self.child(s) for s in self.schema.get("allOf", [])] + + @property + def not_(self) -> SchemaInfo: + return self.child(self.schema.get("not", {})) + + @property + def items(self) -> dict: + return self.schema.get("items", {}) + + @property + def enum(self) -> list[str]: + return self.schema.get("enum", []) + + @property + def const(self) -> str: + return self.schema.get("const", "") + + @property + def literal(self) -> list[str]: + return self.schema.get("enum", [self.const]) + + @property + def refname(self) -> str: + return self.raw_schema.get("$ref", "#/").split("/")[-1] + + @property + def ref(self) -> str | None: + return self.raw_schema.get("$ref", None) + + @property + def description(self) -> str: + return self._get_description(include_sublevels=False) + + @property + def deep_description(self) -> str: + return self._get_description(include_sublevels=True) + + def _get_description(self, include_sublevels: bool = False) -> str: + desc = self.raw_schema.get("description", self.schema.get("description", "")) + if not desc and include_sublevels: + for item in self.anyOf: + sub_desc = item._get_description(include_sublevels=False) + if desc and sub_desc: + raise ValueError( + "There are multiple potential descriptions which could" + + " be used for the currently inspected schema. You'll need to" + + " clarify which one is the correct one.\n" + + str(self.schema) + ) + if sub_desc: + desc = sub_desc + return desc + + def is_reference(self) -> bool: + return "$ref" in self.raw_schema + + def is_enum(self) -> bool: + return "enum" in self.schema + + def is_const(self) -> bool: + return "const" in self.schema + + def is_literal(self) -> bool: + return not ({"enum", "const"}.isdisjoint(self.schema)) + + def is_empty(self) -> bool: + return not (set(self.schema.keys()) - set(EXCLUDE_KEYS)) + + def is_compound(self) -> bool: + return any(key in self.schema for key in ["anyOf", "allOf", "oneOf"]) + + def is_anyOf(self) -> bool: + return "anyOf" in self.schema + + def is_allOf(self) -> bool: + return "allOf" in self.schema + + def is_oneOf(self) -> bool: + return "oneOf" in self.schema + + def is_not(self) -> bool: + return "not" in self.schema + + def is_object(self) -> bool: + if self.type == "object": + return True + elif self.type is not None: + return False + elif ( + self.properties + or self.required + or self.patternProperties + or self.additionalProperties + ): + return True + else: + msg = "Unclear whether schema.is_object() is True" + raise ValueError(msg) + + def is_value(self) -> bool: + return not self.is_object() + + def is_array(self) -> bool: + return self.type == "array" + + def is_union(self) -> bool: + """ + Candidate for ``Union`` type alias. + + Not a real class. + """ + return self.is_anyOf() and self.type is None + + def is_union_literal(self) -> bool: + """ + Candidate for reducing to a single ``Literal`` alias. + + E.g. `BinnedTimeUnit` + """ + return self.is_union() and all(el.is_literal() for el in self.anyOf) + + +class RSTRenderer(_RSTRenderer): + def __init__(self) -> None: + super().__init__() + + def inline_html(self, token: dict[str, Any], state: BlockState) -> str: + html = token["raw"] + return rf"\ :raw-html:`{html}`\ " + + +class RSTParse(mistune.Markdown): + def __init__( + self, + renderer: mistune.BaseRenderer, + block: mistune.BlockParser | None = None, + inline: mistune.InlineParser | None = None, + plugins=None, + ) -> None: + super().__init__(renderer, block, inline, plugins) + + def __call__(self, s: str) -> str: + s = super().__call__(s) + return unescape(s).replace(r"\ ,", ",").replace(r"\ ", " ") + + +rst_parse: RSTParse = RSTParse(RSTRenderer()) + + +def indent_docstring( # noqa: C901 + lines: list[str], indent_level: int, width: int = 100, lstrip=True +) -> str: + """Indent a docstring for use in generated code.""" + final_lines = [] + if len(lines) > 1: + lines += [""] + + for i, line in enumerate(lines): + stripped = line.lstrip() + if stripped: + leading_space = len(line) - len(stripped) + indent = indent_level + leading_space + wrapper = textwrap.TextWrapper( + width=width - indent, + initial_indent=indent * " ", + subsequent_indent=indent * " ", + break_long_words=False, + break_on_hyphens=False, + drop_whitespace=True, + ) + list_wrapper = textwrap.TextWrapper( + width=width - indent, + initial_indent=indent * " " + "* ", + subsequent_indent=indent * " " + " ", + break_long_words=False, + break_on_hyphens=False, + drop_whitespace=True, + ) + for line in stripped.split("\n"): + line_stripped = line.lstrip() + line_stripped = fix_docstring_issues(line_stripped) + if line_stripped == "": + final_lines.append("") + elif line_stripped.startswith("* "): + final_lines.extend(list_wrapper.wrap(line_stripped[2:])) + # Matches lines where an attribute is mentioned followed by the accepted + # types (lines starting with a character sequence that + # does not contain white spaces or '*' followed by ' : '). + # It therefore matches 'condition : anyOf(...' but not '**Notes** : ...' + # These lines should not be wrapped at all but appear on one line + elif re.match(r"[^\s*]+ : ", line_stripped): + final_lines.append(indent * " " + line_stripped) + else: + final_lines.extend(wrapper.wrap(line_stripped)) + + # If this is the last line, put in an indent + elif i + 1 == len(lines): + final_lines.append(indent_level * " ") + # If it's not the last line, this is a blank line that should not indent. + else: + final_lines.append("") + # Remove any trailing whitespaces on the right side + stripped_lines = [] + for i, line in enumerate(final_lines): + if i + 1 == len(final_lines): + stripped_lines.append(line) + else: + stripped_lines.append(line.rstrip()) + # Join it all together + wrapped = "\n".join(stripped_lines) + if lstrip: + wrapped = wrapped.lstrip() + return wrapped + + +def fix_docstring_issues(docstring: str) -> str: + # All lists should start with '*' followed by a whitespace. Fixes the ones + # which either do not have a whitespace or/and start with '-' by first replacing + # "-" with "*" and then adding a whitespace where necessary + docstring = re.sub( + r"^-(?=[ `\"a-z])", + "*", + docstring, + flags=re.MULTILINE, + ) + # Now add a whitespace where an asterisk is followed by one of the characters + # in the square brackets of the regex pattern + docstring = re.sub( + r"^\*(?=[`\"a-z])", + "* ", + docstring, + flags=re.MULTILINE, + ) + + # Links to the vega-lite documentation cannot be relative but instead need to + # contain the full URL. + docstring = docstring.replace( + "types#datetime", "https://vega.github.io/vega-lite/docs/datetime.html" + ) + return docstring + + +def rst_syntax_for_class(class_name: str) -> str: + return f":class:`{class_name}`" + + +def flatten(container: Iterable) -> Iterable: + """ + Flatten arbitrarily flattened list. + + From https://stackoverflow.com/a/10824420 + """ + for i in container: + if isinstance(i, (list, tuple)): + yield from flatten(i) + else: + yield i + + +def spell_literal(it: Iterable[str], /, *, quote: bool = True) -> str: + """ + Combine individual ``str`` type reprs into a single ``Literal``. + + Parameters + ---------- + it + Type representations. + quote + Call ``repr()`` on each element in ``it``. + + .. note:: + Set to ``False`` if performing a second pass. + """ + it_el: Iterable[str] = (f"{s!r}" for s in it) if quote else it + return f"Literal[{', '.join(it_el)}]" + + +def maybe_rewrap_literal(it: Iterable[str], /) -> Iterator[str]: + """ + Where `it` may contain one or more `"enum"`, `"const"`, flatten to a single `Literal[...]`. + + All other type representations are yielded unchanged. + """ + seen: set[str] = set() + for s in it: + if s.startswith("Literal["): + seen.add(unwrap_literal(s)) + else: + yield s + if seen: + yield spell_literal(sorted(seen), quote=False) + + +def unwrap_literal(tp: str, /) -> str: + """`"Literal['value']"` -> `"value"`.""" + return re.sub(r"Literal\[(.+)\]", r"\g<1>", tp) + + +def ruff_format_str(code: str | list[str]) -> str: + if isinstance(code, list): + code = "\n".join(code) + + r = subprocess.run( + # Name of the file does not seem to matter but ruff requires one + ["ruff", "format", "--stdin-filename", "placeholder.py"], + input=code.encode(), + check=True, + capture_output=True, + ) + return r.stdout.decode() + + +def ruff_format_py(fp: Path, /, *extra_args: str) -> None: + """ + Format an existing file. + + Running on `win32` after writing lines will ensure "lf" is used before: + ```bash + ruff format --diff --check . + ``` + """ + cmd = ["ruff", "format", fp] + if extra_args: + cmd.extend(extra_args) + r = subprocess.run(cmd, check=True) + r.check_returncode() + + +def ruff_write_lint_format_str( + fp: Path, code: str | Iterable[str], /, *, encoding: str = "utf-8" +) -> None: + """ + Combined steps of writing, `ruff check`, `ruff format`. + + Notes + ----- + - `fp` is written to first, as the size before formatting will be the smallest + - Better utilizes `ruff` performance, rather than `python` str and io + - `code` is no longer bound to `list` + - Encoding set as default + - `I001/2` are `isort` rules, to sort imports. + """ + commands = ( + ["ruff", "check", fp, "--fix"], + ["ruff", "check", fp, "--fix", "--select", "I001", "--select", "I002"], + ) + if not isinstance(code, str): + code = "\n".join(code) + fp.write_text(code, encoding=encoding) + for cmd in commands: + r = subprocess.run(cmd, check=True) + r.check_returncode() + ruff_format_py(fp) From eb2682ea265fd87c0cfc69dfee8937b35850e5c5 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Sat, 14 Sep 2024 18:50:22 -0400 Subject: [PATCH 02/15] Created example-api-usage file --- reference/altair_schemapi/example-api-usage.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 reference/altair_schemapi/example-api-usage.py diff --git a/reference/altair_schemapi/example-api-usage.py b/reference/altair_schemapi/example-api-usage.py new file mode 100644 index 00000000..dab9a982 --- /dev/null +++ b/reference/altair_schemapi/example-api-usage.py @@ -0,0 +1,3 @@ +import python_api + + From de5fb5b6dbc796770239803d35f9dced8df43058 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Sat, 14 Sep 2024 21:00:05 -0400 Subject: [PATCH 03/15] Moved example-api-usgae --- reference/{altair_schemapi => }/example-api-usage.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename reference/{altair_schemapi => }/example-api-usage.py (100%) diff --git a/reference/altair_schemapi/example-api-usage.py b/reference/example-api-usage.py similarity index 100% rename from reference/altair_schemapi/example-api-usage.py rename to reference/example-api-usage.py From 21acddab375b5131d439ee0ecfc2085eb1d60e2b Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Sat, 14 Sep 2024 21:16:45 -0400 Subject: [PATCH 04/15] Initial comments --- .../generate_schema_wrapper_commented.py | 972 ++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 reference/generate_schema_wrapper_commented.py diff --git a/reference/generate_schema_wrapper_commented.py b/reference/generate_schema_wrapper_commented.py new file mode 100644 index 00000000..12dd5fb6 --- /dev/null +++ b/reference/generate_schema_wrapper_commented.py @@ -0,0 +1,972 @@ +"""Generate a schema wrapper from a schema.""" + +from __future__ import annotations + +import argparse +import copy +import json +import re +import sys +import textwrap +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +from typing import Final, Iterable, Iterator, Literal +from urllib import request + +import vl_convert as vlc + +sys.path.insert(0, str(Path.cwd())) +from tools.schemapi import CodeSnippet, SchemaInfo, codegen +from tools.schemapi.utils import ( + TypeAliasTracer, + get_valid_identifier, + indent_docstring, + resolve_references, + rst_parse, + rst_syntax_for_class, + ruff_format_py, + ruff_write_lint_format_str, + spell_literal, +) + +SCHEMA_VERSION: Final = "v5.20.1" + +reLink = re.compile(r"(?<=\[)([^\]]+)(?=\]\([^\)]+\))", re.MULTILINE) +reSpecial = re.compile(r"[*_]{2,3}|`", re.MULTILINE) + +HEADER: Final = """\ +# The contents of this file are automatically written by +# tools/generate_schema_wrapper.py. Do not modify directly. +""" + +SCHEMA_URL_TEMPLATE: Final = "https://vega.github.io/schema/{library}/{version}.json" + +CHANNEL_MYPY_IGNORE_STATEMENTS: Final = """\ +# These errors need to be ignored as they come from the overload methods +# which trigger two kind of errors in mypy: +# * all of them do not have an implementation in this file +# * some of them are the only overload methods -> overloads usually only make +# sense if there are multiple ones +# However, we need these overloads due to how the propertysetter works +# mypy: disable-error-code="no-overload-impl, empty-body, misc" +""" + +BASE_SCHEMA: Final = """ +class {basename}(SchemaBase): + _rootschema = load_schema() + @classmethod + def _default_wrapper_classes(cls) -> Iterator[type[Any]]: + return _subclasses({basename}) +""" + +LOAD_SCHEMA: Final = ''' +def load_schema() -> dict: + """Load the json schema associated with this module's functions""" + schema_bytes = pkgutil.get_data(__name__, "{schemafile}") + if schema_bytes is None: + raise ValueError("Unable to load {schemafile}") + return json.loads( + schema_bytes.decode("utf-8") + ) +''' + + +CHANNEL_MIXINS: Final = """ +class FieldChannelMixin: + _encoding_name: str + def to_dict( + self, + validate: bool = True, + ignore: list[str] | None = None, + context: dict[str, Any] | None = None, + ) -> dict | list[dict]: + context = context or {} + ignore = ignore or [] + shorthand = self._get("shorthand") # type: ignore[attr-defined] + field = self._get("field") # type: ignore[attr-defined] + + if shorthand is not Undefined and field is not Undefined: + msg = f"{self.__class__.__name__} specifies both shorthand={shorthand} and field={field}. " + raise ValueError(msg) + + if isinstance(shorthand, (tuple, list)): + # If given a list of shorthands, then transform it to a list of classes + kwds = self._kwds.copy() # type: ignore[attr-defined] + kwds.pop("shorthand") + return [ + self.__class__(sh, **kwds).to_dict( # type: ignore[call-arg] + validate=validate, ignore=ignore, context=context + ) + for sh in shorthand + ] + + if shorthand is Undefined: + parsed = {} + elif isinstance(shorthand, str): + data: nw.DataFrame | Any = context.get("data", None) + parsed = parse_shorthand(shorthand, data=data) + type_required = "type" in self._kwds # type: ignore[attr-defined] + type_in_shorthand = "type" in parsed + type_defined_explicitly = self._get("type") is not Undefined # type: ignore[attr-defined] + if not type_required: + # Secondary field names don't require a type argument in VegaLite 3+. + # We still parse it out of the shorthand, but drop it here. + parsed.pop("type", None) + elif not (type_in_shorthand or type_defined_explicitly): + if isinstance(data, nw.DataFrame): + msg = ( + f'Unable to determine data type for the field "{shorthand}";' + " verify that the field name is not misspelled." + " If you are referencing a field from a transform," + " also confirm that the data type is specified correctly." + ) + raise ValueError(msg) + else: + msg = ( + f"{shorthand} encoding field is specified without a type; " + "the type cannot be automatically inferred because " + "the data is not specified as a pandas.DataFrame." + ) + raise ValueError(msg) + else: + # Shorthand is not a string; we pass the definition to field, + # and do not do any parsing. + parsed = {"field": shorthand} + context["parsed_shorthand"] = parsed + + return super(FieldChannelMixin, self).to_dict( + validate=validate, ignore=ignore, context=context + ) + + +class ValueChannelMixin: + _encoding_name: str + def to_dict( + self, + validate: bool = True, + ignore: list[str] | None = None, + context: dict[str, Any] | None = None, + ) -> dict: + context = context or {} + ignore = ignore or [] + condition = self._get("condition", Undefined) # type: ignore[attr-defined] + copy = self # don't copy unless we need to + if condition is not Undefined: + if isinstance(condition, core.SchemaBase): + pass + elif "field" in condition and "type" not in condition: + kwds = parse_shorthand(condition["field"], context.get("data", None)) + copy = self.copy(deep=["condition"]) # type: ignore[attr-defined] + copy["condition"].update(kwds) # type: ignore[index] + return super(ValueChannelMixin, copy).to_dict( + validate=validate, ignore=ignore, context=context + ) + + +class DatumChannelMixin: + _encoding_name: str + def to_dict( + self, + validate: bool = True, + ignore: list[str] | None = None, + context: dict[str, Any] | None = None, + ) -> dict: + context = context or {} + ignore = ignore or [] + datum = self._get("datum", Undefined) # type: ignore[attr-defined] # noqa + copy = self # don't copy unless we need to + return super(DatumChannelMixin, copy).to_dict( + validate=validate, ignore=ignore, context=context + ) +""" + +MARK_METHOD: Final = ''' +def mark_{mark}({def_arglist}) -> Self: + """Set the chart's mark to '{mark}' (see :class:`{mark_def}`) + """ + kwds = dict({dict_arglist}) + copy = self.copy(deep=False) # type: ignore[attr-defined] + if any(val is not Undefined for val in kwds.values()): + copy.mark = core.{mark_def}(type="{mark}", **kwds) + else: + copy.mark = "{mark}" + return copy +''' + +CONFIG_METHOD: Final = """ +@use_signature(core.{classname}) +def {method}(self, *args, **kwargs) -> Self: + copy = self.copy(deep=False) # type: ignore[attr-defined] + copy.config = core.{classname}(*args, **kwargs) + return copy +""" + +CONFIG_PROP_METHOD: Final = """ +@use_signature(core.{classname}) +def configure_{prop}(self, *args, **kwargs) -> Self: + copy = self.copy(deep=['config']) # type: ignore[attr-defined] + if copy.config is Undefined: + copy.config = core.Config() + copy.config["{prop}"] = core.{classname}(*args, **kwargs) + return copy +""" + +ENCODE_METHOD: Final = ''' +class _EncodingMixin: + def encode({method_args}) -> Self: + """Map properties of the data to visual properties of the chart (see :class:`FacetedEncoding`) + {docstring}""" + # Compat prep for `infer_encoding_types` signature + kwargs = locals() + kwargs.pop("self") + args = kwargs.pop("args") + if args: + kwargs = {{k: v for k, v in kwargs.items() if v is not Undefined}} + + # Convert args to kwargs based on their types. + kwargs = _infer_encoding_types(args, kwargs) + # get a copy of the dict representation of the previous encoding + # ignore type as copy method comes from SchemaBase + copy = self.copy(deep=['encoding']) # type: ignore[attr-defined] + encoding = copy._get('encoding', {{}}) + if isinstance(encoding, core.VegaLiteSchema): + encoding = {{k: v for k, v in encoding._kwds.items() if v is not Undefined}} + # update with the new encodings, and apply them to the copy + encoding.update(kwargs) + copy.encoding = core.FacetedEncoding(**encoding) + return copy +''' + +ENCODE_TYPED_DICT: Final = ''' +class EncodeKwds(TypedDict, total=False): + """Encoding channels map properties of the data to visual properties of the chart. + {docstring}""" + {channels} + +''' + +# NOTE: Not yet reasonable to generalize `TypeAliasType`, `TypeVar` +# Revisit if this starts to become more common +TYPING_EXTRA: Final = ''' +T = TypeVar("T") +OneOrSeq = TypeAliasType("OneOrSeq", Union[T, Sequence[T]], type_params=(T,)) +"""One of ``T`` specified type(s), or a `Sequence` of such. + +Examples +-------- +The parameters ``short``, ``long`` accept the same range of types:: + + # ruff: noqa: UP006, UP007 + + def func( + short: OneOrSeq[str | bool | float], + long: Union[str, bool, float, Sequence[Union[str, bool, float]], + ): ... +""" +''' + + +class SchemaGenerator(codegen.SchemaGenerator): + schema_class_template = textwrap.dedent( + ''' + class {classname}({basename}): + """{docstring}""" + _schema = {schema!r} + + {init_code} + ''' + ) + + @staticmethod + def _process_description(description: str) -> str: + return process_description(description) + + +def process_description(description: str) -> str: + # remove formatting from links + description = "".join( + [ + reSpecial.sub("", d) if i % 2 else d + for i, d in enumerate(reLink.split(description)) + ] + ) + description = rst_parse(description) + # Some entries in the Vega-Lite schema miss the second occurence of '__' + description = description.replace("__Default value: ", "__Default value:__ ") + # Fixing ambiguous unicode, RUF001 produces RUF002 in docs + description = description.replace("’", "'") # noqa: RUF001 [RIGHT SINGLE QUOTATION MARK] + description = description.replace("–", "-") # noqa: RUF001 [EN DASH] + description = description.replace(" ", " ") # noqa: RUF001 [NO-BREAK SPACE] + return description.strip() + + +class FieldSchemaGenerator(SchemaGenerator): + schema_class_template = textwrap.dedent( + ''' + @with_property_setters + class {classname}(FieldChannelMixin, core.{basename}): + """{docstring}""" + _class_is_valid_at_instantiation = False + _encoding_name = "{encodingname}" + + {method_code} + + {init_code} + ''' + ) + + +class ValueSchemaGenerator(SchemaGenerator): + schema_class_template = textwrap.dedent( + ''' + @with_property_setters + class {classname}(ValueChannelMixin, core.{basename}): + """{docstring}""" + _class_is_valid_at_instantiation = False + _encoding_name = "{encodingname}" + + {method_code} + + {init_code} + ''' + ) + + +class DatumSchemaGenerator(SchemaGenerator): + schema_class_template = textwrap.dedent( + ''' + @with_property_setters + class {classname}(DatumChannelMixin, core.{basename}): + """{docstring}""" + _class_is_valid_at_instantiation = False + _encoding_name = "{encodingname}" + + {method_code} + + {init_code} + ''' + ) + + +def schema_class(*args, **kwargs) -> str: + return SchemaGenerator(*args, **kwargs).schema_class() + + +def schema_url(version: str = SCHEMA_VERSION) -> str: + return SCHEMA_URL_TEMPLATE.format(library="vega-lite", version=version) + + +def download_schemafile( + version: str, schemapath: Path, skip_download: bool = False +) -> Path: + url = schema_url(version=version) + schemadir = Path(schemapath) + schemadir.mkdir(parents=True, exist_ok=True) + fp = schemadir / "vega-lite-schema.json" + if not skip_download: + request.urlretrieve(url, fp) + elif not fp.exists(): + msg = f"Cannot skip download: {fp!s} does not exist" + raise ValueError(msg) + return fp + + +def update_vega_themes(fp: Path, /, indent: str | int | None = 2) -> None: + themes = vlc.get_themes() + data = json.dumps(themes, indent=indent, sort_keys=True) + fp.write_text(data, encoding="utf8") + + theme_names = sorted(iter(themes)) + TypeAliasTracer.update_aliases(("VegaThemes", spell_literal(theme_names))) + + +def load_schema_with_shorthand_properties(schemapath: Path) -> dict: + with schemapath.open(encoding="utf8") as f: + schema = json.load(f) + + schema = _add_shorthand_property_to_field_encodings(schema) + return schema + + +def _add_shorthand_property_to_field_encodings(schema: dict) -> dict: + encoding_def = "FacetedEncoding" + + encoding = SchemaInfo(schema["definitions"][encoding_def], rootschema=schema) + + for _, propschema in encoding.properties.items(): + def_dict = get_field_datum_value_defs(propschema, schema) + + field_ref = def_dict.get("field") + if field_ref is not None: + defschema = {"$ref": field_ref} + defschema = copy.deepcopy(resolve_references(defschema, schema)) + # For Encoding field definitions, we patch the schema by adding the + # shorthand property. + defschema["properties"]["shorthand"] = { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + {"$ref": "#/definitions/RepeatRef"}, + ], + "description": "shorthand for field, aggregate, and type", + } + if "required" not in defschema: + defschema["required"] = ["shorthand"] + elif "shorthand" not in defschema["required"]: + defschema["required"].append("shorthand") + schema["definitions"][field_ref.split("/")[-1]] = defschema + return schema + + +def copy_schemapi_util() -> None: + """Copy the schemapi utility into altair/utils/ and its test file to tests/utils/.""" + # copy the schemapi utility file + source_fp = Path(__file__).parent / "schemapi" / "schemapi.py" + destination_fp = Path(__file__).parent / ".." / "altair" / "utils" / "schemapi.py" + + print(f"Copying\n {source_fp!s}\n -> {destination_fp!s}") + with source_fp.open(encoding="utf8") as source, destination_fp.open( + "w", encoding="utf8" + ) as dest: + dest.write(HEADER) + dest.writelines(source.readlines()) + if sys.platform == "win32": + ruff_format_py(destination_fp) + + +def recursive_dict_update(schema: dict, root: dict, def_dict: dict) -> None: + if "$ref" in schema: + next_schema = resolve_references(schema, root) + if "properties" in next_schema: + definition = schema["$ref"] + properties = next_schema["properties"] + for k in def_dict: + if k in properties: + def_dict[k] = definition + else: + recursive_dict_update(next_schema, root, def_dict) + elif "anyOf" in schema: + for sub_schema in schema["anyOf"]: + recursive_dict_update(sub_schema, root, def_dict) + + +def get_field_datum_value_defs(propschema: SchemaInfo, root: dict) -> dict[str, str]: + def_dict: dict[str, str | None] = dict.fromkeys(("field", "datum", "value")) + schema = propschema.schema + if propschema.is_reference() and "properties" in schema: + if "field" in schema["properties"]: + def_dict["field"] = propschema.ref + else: + msg = "Unexpected schema structure" + raise ValueError(msg) + else: + recursive_dict_update(schema, root, def_dict) + + return {i: j for i, j in def_dict.items() if j} + + +def toposort(graph: dict[str, list[str]]) -> list[str]: + """ + Topological sort of a directed acyclic graph. + + Parameters + ---------- + graph : dict of lists + Mapping of node labels to list of child node labels. + This is assumed to represent a graph with no cycles. + + Returns + ------- + order : list + topological order of input graph. + """ + # Once we drop support for Python 3.8, this can potentially be replaced + # with graphlib.TopologicalSorter from the standard library. + stack: list[str] = [] + visited: dict[str, Literal[True]] = {} + + def visit(nodes): + for node in sorted(nodes, reverse=True): + if not visited.get(node): + visited[node] = True + visit(graph.get(node, [])) + stack.insert(0, node) + + visit(graph) + return stack + + +def generate_vegalite_schema_wrapper(schema_file: Path) -> str: + """Generate a schema wrapper at the given path.""" + # TODO: generate simple tests for each wrapper + basename = "VegaLiteSchema" + + rootschema = load_schema_with_shorthand_properties(schema_file) + + definitions: dict[str, SchemaGenerator] = {} + + for name in rootschema["definitions"]: + defschema = {"$ref": "#/definitions/" + name} + defschema_repr = {"$ref": "#/definitions/" + name} + name = get_valid_identifier(name) + definitions[name] = SchemaGenerator( + name, + schema=defschema, + schemarepr=defschema_repr, + rootschema=rootschema, + basename=basename, + rootschemarepr=CodeSnippet(f"{basename}._rootschema"), + ) + + graph: dict[str, list[str]] = {} + + for name, schema in definitions.items(): + graph[name] = [] + for child_name in schema.subclasses(): + child_name = get_valid_identifier(child_name) + graph[name].append(child_name) + child: SchemaGenerator = definitions[child_name] + if child.basename == basename: + child.basename = [name] + else: + assert isinstance(child.basename, list) + child.basename.append(name) + + # Specify __all__ explicitly so that we can exclude the ones from the list + # of exported classes which are also defined in the channels or api modules which takes + # precedent in the generated __init__.py files one and two levels up. + # Importing these classes from multiple modules confuses type checkers. + EXCLUDE = {"Color", "Text", "LookupData", "Dict", "FacetMapping"} + it = (c for c in definitions.keys() - EXCLUDE if not c.startswith("_")) + all_ = [*sorted(it), "Root", "VegaLiteSchema", "SchemaBase", "load_schema"] + + contents = [ + HEADER, + "from __future__ import annotations\n" + "from typing import Any, Literal, Union, Protocol, Sequence, List, Iterator, TYPE_CHECKING", + "import pkgutil", + "import json\n", + "from narwhals.dependencies import is_pandas_dataframe as _is_pandas_dataframe", + "from altair.utils.schemapi import SchemaBase, Undefined, UndefinedType, _subclasses # noqa: F401\n", + _type_checking_only_imports( + "from altair import Parameter", + "from altair.typing import Optional", + "from ._typing import * # noqa: F403", + ), + "\n" f"__all__ = {all_}\n", + LOAD_SCHEMA.format(schemafile="vega-lite-schema.json"), + BASE_SCHEMA.format(basename=basename), + schema_class( + "Root", + schema=rootschema, + basename=basename, + schemarepr=CodeSnippet(f"{basename}._rootschema"), + ), + ] + + for name in toposort(graph): + contents.append(definitions[name].schema_class()) + + contents.append("") # end with newline + return "\n".join(contents) + + +def _type_checking_only_imports(*imports: str) -> str: + return ( + "\n# ruff: noqa: F405\nif TYPE_CHECKING:\n" + + "\n".join(f" {s}" for s in imports) + + "\n" + ) + + +@dataclass +class ChannelInfo: + supports_arrays: bool + deep_description: str + field_class_name: str + datum_class_name: str | None = None + value_class_name: str | None = None + + @property + def is_field_only(self) -> bool: + return not (self.datum_class_name or self.value_class_name) + + @property + def all_names(self) -> Iterator[str]: + """All channels are expected to have a field class.""" + yield self.field_class_name + yield from self.non_field_names + + @property + def non_field_names(self) -> Iterator[str]: + if self.is_field_only: + yield from () + else: + if self.datum_class_name: + yield self.datum_class_name + if self.value_class_name: + yield self.value_class_name + + +def generate_vegalite_channel_wrappers( + schemafile: Path, version: str, imports: list[str] | None = None +) -> str: + schema = load_schema_with_shorthand_properties(schemafile) + + encoding_def = "FacetedEncoding" + + encoding = SchemaInfo(schema["definitions"][encoding_def], rootschema=schema) + + channel_infos: dict[str, ChannelInfo] = {} + + class_defs = [] + + for prop, propschema in encoding.properties.items(): + def_dict = get_field_datum_value_defs(propschema, schema) + + supports_arrays = any( + schema_info.is_array() for schema_info in propschema.anyOf + ) + classname: str = prop[0].upper() + prop[1:] + channel_info = ChannelInfo( + supports_arrays=supports_arrays, + deep_description=propschema.deep_description, + field_class_name=classname, + ) + + for encoding_spec, definition in def_dict.items(): + basename = definition.rsplit("/", maxsplit=1)[-1] + basename = get_valid_identifier(basename) + + gen: SchemaGenerator + defschema = {"$ref": definition} + kwds = { + "basename": basename, + "schema": defschema, + "rootschema": schema, + "encodingname": prop, + "haspropsetters": True, + } + if encoding_spec == "field": + gen = FieldSchemaGenerator(classname, nodefault=[], **kwds) + elif encoding_spec == "datum": + temp_name = f"{classname}Datum" + channel_info.datum_class_name = temp_name + gen = DatumSchemaGenerator(temp_name, nodefault=["datum"], **kwds) + elif encoding_spec == "value": + temp_name = f"{classname}Value" + channel_info.value_class_name = temp_name + gen = ValueSchemaGenerator(temp_name, nodefault=["value"], **kwds) + + class_defs.append(gen.schema_class()) + + channel_infos[prop] = channel_info + + # NOTE: See https://github.com/vega/altair/pull/3482#issuecomment-2241577342 + COMPAT_EXPORTS = ( + "DatumChannelMixin", + "FieldChannelMixin", + "ValueChannelMixin", + "with_property_setters", + ) + + it = chain.from_iterable(info.all_names for info in channel_infos.values()) + all_ = list(chain(it, COMPAT_EXPORTS)) + + imports = imports or [ + "from __future__ import annotations\n", + "from typing import Any, overload, Sequence, List, Literal, Union, TYPE_CHECKING, TypedDict", + "from typing_extensions import TypeAlias", + "import narwhals.stable.v1 as nw", + "from altair.utils.schemapi import Undefined, with_property_setters", + "from altair.utils import infer_encoding_types as _infer_encoding_types", + "from altair.utils import parse_shorthand", + "from . import core", + "from ._typing import * # noqa: F403", + ] + contents = [ + HEADER, + CHANNEL_MYPY_IGNORE_STATEMENTS, + *imports, + _type_checking_only_imports( + "from altair import Parameter, SchemaBase", + "from altair.typing import Optional", + "from typing_extensions import Self", + ), + "\n" f"__all__ = {sorted(all_)}\n", + CHANNEL_MIXINS, + *class_defs, + *generate_encoding_artifacts(channel_infos, ENCODE_METHOD, ENCODE_TYPED_DICT), + ] + return "\n".join(contents) + + +def generate_vegalite_mark_mixin( + schemafile: Path, markdefs: dict[str, str] +) -> tuple[list[str], str]: + with schemafile.open(encoding="utf8") as f: + schema = json.load(f) + + class_name = "MarkMethodMixin" + + imports = [ + "from typing import Any, Sequence, List, Literal, Union", + "", + "from altair.utils.schemapi import Undefined, UndefinedType", + "from . import core", + ] + + code = [ + f"class {class_name}:", + ' """A mixin class that defines mark methods"""', + ] + + for mark_enum, mark_def in markdefs.items(): + if "enum" in schema["definitions"][mark_enum]: + marks = schema["definitions"][mark_enum]["enum"] + else: + marks = [schema["definitions"][mark_enum]["const"]] + info = SchemaInfo({"$ref": f"#/definitions/{mark_def}"}, rootschema=schema) + + # adapted from SchemaInfo.init_code + arg_info = codegen.get_args(info) + arg_info.required -= {"type"} + arg_info.kwds -= {"type"} + + def_args = ["self"] + [ + f"{p}: " + + info.properties[p].get_python_type_representation( + for_type_hints=True, + additional_type_hints=["UndefinedType"], + ) + + " = Undefined" + for p in (sorted(arg_info.required) + sorted(arg_info.kwds)) + ] + dict_args = [ + f"{p}={p}" for p in (sorted(arg_info.required) + sorted(arg_info.kwds)) + ] + + if arg_info.additional or arg_info.invalid_kwds: + def_args.append("**kwds") + dict_args.append("**kwds") + + for mark in marks: + # TODO: only include args relevant to given type? + mark_method = MARK_METHOD.format( + mark=mark, + mark_def=mark_def, + def_arglist=", ".join(def_args), + dict_arglist=", ".join(dict_args), + ) + code.append("\n ".join(mark_method.splitlines())) + + return imports, "\n".join(code) + + +def generate_vegalite_config_mixin(schemafile: Path) -> tuple[list[str], str]: + imports = [ + "from . import core", + "from altair.utils import use_signature", + ] + + class_name = "ConfigMethodMixin" + + code = [ + f"class {class_name}:", + ' """A mixin class that defines config methods"""', + ] + with schemafile.open(encoding="utf8") as f: + schema = json.load(f) + info = SchemaInfo({"$ref": "#/definitions/Config"}, rootschema=schema) + + # configure() method + method = CONFIG_METHOD.format(classname="Config", method="configure") + code.append("\n ".join(method.splitlines())) + + # configure_prop() methods + for prop, prop_info in info.properties.items(): + classname = prop_info.refname + if classname and classname.endswith("Config"): + method = CONFIG_PROP_METHOD.format(classname=classname, prop=prop) + code.append("\n ".join(method.splitlines())) + return imports, "\n".join(code) + + +def vegalite_main(skip_download: bool = False) -> None: + version = SCHEMA_VERSION + ###(H) Below just gets the path to vegalite main file + vn = version.split(".")[0] + fp = (Path(__file__).parent / ".." / "altair" / "vegalite" / vn).resolve() + schemapath = fp / "schema" + schemafile = download_schemafile( + version=version, + schemapath=schemapath, + skip_download=skip_download, + ) + + fp_themes = schemapath / "vega-themes.json" + print(f"Updating themes\n {schemafile!s}\n ->{fp_themes!s}") + update_vega_themes(fp_themes) + + # Generate __init__.py file + outfile = schemapath / "__init__.py" + print(f"Writing {outfile!s}") + content = [ + "# ruff: noqa\n", + "from .core import *\nfrom .channels import *\n", + f"SCHEMA_VERSION = '{version}'\n", + f"SCHEMA_URL = {schema_url(version)!r}\n", + ] + ruff_write_lint_format_str(outfile, content) + + TypeAliasTracer.update_aliases(("Map", "Mapping[str, Any]")) + + files: dict[Path, str | Iterable[str]] = {} + + # Generate the core schema wrappers + fp_core = schemapath / "core.py" + print(f"Generating\n {schemafile!s}\n ->{fp_core!s}") + files[fp_core] = generate_vegalite_schema_wrapper(schemafile) + + # Generate the channel wrappers + fp_channels = schemapath / "channels.py" + print(f"Generating\n {schemafile!s}\n ->{fp_channels!s}") + files[fp_channels] = generate_vegalite_channel_wrappers(schemafile, version=version) + + # generate the mark mixin + markdefs = {k: f"{k}Def" for k in ["Mark", "BoxPlot", "ErrorBar", "ErrorBand"]} + fp_mixins = schemapath / "mixins.py" + print(f"Generating\n {schemafile!s}\n ->{fp_mixins!s}") + mark_imports, mark_mixin = generate_vegalite_mark_mixin(schemafile, markdefs) + config_imports, config_mixin = generate_vegalite_config_mixin(schemafile) + try_except_imports = [ + "if sys.version_info >= (3, 11):", + " from typing import Self", + "else:", + " from typing_extensions import Self", + ] + stdlib_imports = ["from __future__ import annotations\n", "import sys"] + content_mixins = [ + HEADER, + "\n".join(stdlib_imports), + "\n\n", + "\n".join(sorted({*mark_imports, *config_imports})), + "\n\n", + "\n".join(try_except_imports), + "\n\n", + _type_checking_only_imports( + "from altair import Parameter, SchemaBase", + "from altair.typing import Optional", + "from ._typing import * # noqa: F403", + ), + "\n\n\n", + mark_mixin, + "\n\n\n", + config_mixin, + ] + files[fp_mixins] = content_mixins + + # Write `_typing.py` TypeAlias, for import in generated modules + fp_typing = schemapath / "_typing.py" + msg = ( + f"Generating\n {schemafile!s}\n ->{fp_typing!s}\n" + f"Tracer cache collected {TypeAliasTracer.n_entries!r} entries." + ) + print(msg) + TypeAliasTracer.write_module( + fp_typing, "OneOrSeq", header=HEADER, extra=TYPING_EXTRA + ) + # Write the pre-generated modules + for fp, contents in files.items(): + print(f"Writing\n {schemafile!s}\n ->{fp!s}") + ruff_write_lint_format_str(fp, contents) + + +def generate_encoding_artifacts( + channel_infos: dict[str, ChannelInfo], fmt_method: str, fmt_typed_dict: str +) -> Iterator[str]: + """ + Generate ``Chart.encode()`` and related typing structures. + + - `TypeAlias`(s) for each parameter to ``Chart.encode()`` + - Mixin class that provides the ``Chart.encode()`` method + - `TypedDict`, utilising/describing these structures as part of https://github.com/pola-rs/polars/pull/17995. + + Notes + ----- + - `Map`/`Dict` stands for the return types of `alt.(datum|value)`, and any encoding channel class. + - See discussions in https://github.com/vega/altair/pull/3208 + - We could be more specific about what types are accepted in the `List` + - but this translates poorly to an IDE + - `info.supports_arrays` + """ + signature_args: list[str] = ["self", "*args: Any"] + type_aliases: list[str] = [] + typed_dict_args: list[str] = [] + signature_doc_params: list[str] = ["", "Parameters", "----------"] + typed_dict_doc_params: list[str] = ["", "Parameters", "----------"] + + for channel, info in channel_infos.items(): + alias_name: str = f"Channel{channel[0].upper()}{channel[1:]}" + + it: Iterator[str] = info.all_names + it_rst_names: Iterator[str] = (rst_syntax_for_class(c) for c in info.all_names) + + docstring_types: list[str] = ["str", next(it_rst_names), "Dict"] + tp_inner: str = ", ".join(chain(("str", next(it), "Map"), it)) + tp_inner = f"Union[{tp_inner}]" + + if info.supports_arrays: + docstring_types.append("List") + tp_inner = f"OneOrSeq[{tp_inner}]" + + doc_types_flat: str = ", ".join(chain(docstring_types, it_rst_names)) + + type_aliases.append(f"{alias_name}: TypeAlias = {tp_inner}") + # We use the full type hints instead of the alias in the signatures below + # as IDEs such as VS Code would else show the name of the alias instead + # of the expanded full type hints. The later are more useful to users. + typed_dict_args.append(f"{channel}: {tp_inner}") + signature_args.append(f"{channel}: Optional[{tp_inner}] = Undefined") + + description: str = f" {process_description(info.deep_description)}" + + signature_doc_params.extend((f"{channel} : {doc_types_flat}", description)) + typed_dict_doc_params.extend((f"{channel}", description)) + + method: str = fmt_method.format( + method_args=", ".join(signature_args), + docstring=indent_docstring(signature_doc_params, indent_level=8, lstrip=False), + ) + typed_dict: str = fmt_typed_dict.format( + channels="\n ".join(typed_dict_args), + docstring=indent_docstring(typed_dict_doc_params, indent_level=4, lstrip=False), + ) + artifacts: Iterable[str] = *type_aliases, method, typed_dict + yield from artifacts + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="generate_schema_wrapper.py", description="Generate the Altair package." + ) + parser.add_argument( + "--skip-download", action="store_true", help="skip downloading schema files" + ) + ###(H) I've used this library before. The below just does the actual arg parsing + args = parser.parse_args() + ###(H) Copies the schemapi.py file from schemapi to ../altair/utils + copy_schemapi_util() + vegalite_main(args.skip_download) + + # The modules below are imported after the generation of the new schema files + # as these modules import Altair. This allows them to use the new changes + from tools import generate_api_docs, update_init_file + + generate_api_docs.write_api_file() + update_init_file.update__all__variable() + + +if __name__ == "__main__": + main() From e5fb1c755a30a2391ce58802bbcd453858ce2938 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Sun, 15 Sep 2024 10:06:49 -0400 Subject: [PATCH 05/15] reference/generate_schema_wrapper_commented.py --- reference/generate_schema_wrapper_commented.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/reference/generate_schema_wrapper_commented.py b/reference/generate_schema_wrapper_commented.py index 12dd5fb6..d17f33eb 100644 --- a/reference/generate_schema_wrapper_commented.py +++ b/reference/generate_schema_wrapper_commented.py @@ -17,6 +17,8 @@ import vl_convert as vlc sys.path.insert(0, str(Path.cwd())) +###(H) SchemaInfo class imported from altair/tools/schemapi/utils.py +### It's a wrapper for inspecting JSON schema from tools.schemapi import CodeSnippet, SchemaInfo, codegen from tools.schemapi.utils import ( TypeAliasTracer, @@ -799,6 +801,7 @@ def vegalite_main(skip_download: bool = False) -> None: vn = version.split(".")[0] fp = (Path(__file__).parent / ".." / "altair" / "vegalite" / vn).resolve() schemapath = fp / "schema" + ###(H) They download the schema, eg: altair/altair/vegalite/v5/schema/vega-lite-schema.json schemafile = download_schemafile( version=version, schemapath=schemapath, @@ -818,10 +821,15 @@ def vegalite_main(skip_download: bool = False) -> None: f"SCHEMA_VERSION = '{version}'\n", f"SCHEMA_URL = {schema_url(version)!r}\n", ] + ###(H)ruff is a python 'linter' written in Rust, which is essentially + ###syntax formatting and checking. + ###The function below is a combination of writing, ruff checking and formatting ruff_write_lint_format_str(outfile, content) TypeAliasTracer.update_aliases(("Map", "Mapping[str, Any]")) + ###(H) Note: Path is a type imported from pathlib. Every Path added to the files + ### dictionary is eventually written to and formatted using ruff files: dict[Path, str | Iterable[str]] = {} # Generate the core schema wrappers From 03ef6cf0fdb52c9429f346a27bf44670a717d635 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Sun, 15 Sep 2024 10:06:49 -0400 Subject: [PATCH 06/15] Added first round of comments --- reference/generate_schema_wrapper_commented.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/reference/generate_schema_wrapper_commented.py b/reference/generate_schema_wrapper_commented.py index 12dd5fb6..d17f33eb 100644 --- a/reference/generate_schema_wrapper_commented.py +++ b/reference/generate_schema_wrapper_commented.py @@ -17,6 +17,8 @@ import vl_convert as vlc sys.path.insert(0, str(Path.cwd())) +###(H) SchemaInfo class imported from altair/tools/schemapi/utils.py +### It's a wrapper for inspecting JSON schema from tools.schemapi import CodeSnippet, SchemaInfo, codegen from tools.schemapi.utils import ( TypeAliasTracer, @@ -799,6 +801,7 @@ def vegalite_main(skip_download: bool = False) -> None: vn = version.split(".")[0] fp = (Path(__file__).parent / ".." / "altair" / "vegalite" / vn).resolve() schemapath = fp / "schema" + ###(H) They download the schema, eg: altair/altair/vegalite/v5/schema/vega-lite-schema.json schemafile = download_schemafile( version=version, schemapath=schemapath, @@ -818,10 +821,15 @@ def vegalite_main(skip_download: bool = False) -> None: f"SCHEMA_VERSION = '{version}'\n", f"SCHEMA_URL = {schema_url(version)!r}\n", ] + ###(H)ruff is a python 'linter' written in Rust, which is essentially + ###syntax formatting and checking. + ###The function below is a combination of writing, ruff checking and formatting ruff_write_lint_format_str(outfile, content) TypeAliasTracer.update_aliases(("Map", "Mapping[str, Any]")) + ###(H) Note: Path is a type imported from pathlib. Every Path added to the files + ### dictionary is eventually written to and formatted using ruff files: dict[Path, str | Iterable[str]] = {} # Generate the core schema wrappers From 589140a5054c2737258e9ad40105fa9d0c7c37ca Mon Sep 17 00:00:00 2001 From: Xinyue-Yang <86961126+Xinyue-Yang@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:32:50 -0400 Subject: [PATCH 07/15] add comments --- .../generate_schema_wrapper_commented.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/reference/generate_schema_wrapper_commented.py b/reference/generate_schema_wrapper_commented.py index d17f33eb..9b3ebde5 100644 --- a/reference/generate_schema_wrapper_commented.py +++ b/reference/generate_schema_wrapper_commented.py @@ -1,5 +1,39 @@ """Generate a schema wrapper from a schema.""" +""" +(X) The file is organized into several key sections: + +1. **Constants**: + - This section defines constants that are used throughout the module for configuration and encoding methods. + +2. **Schema Generation Functions**: + - `generate_vegalite_schema_wrapper`: This function generates a schema wrapper for Vega-Lite based on the provided schema file. + - `load_schema_with_shorthand_properties`: Loads the schema and incorporates shorthand properties for easier usage. + - `_add_shorthand_property_to_field_encodings`: Adds shorthand properties to field encodings within the schema. + +3. **Utility Functions**: + - `copy_schemapi_util`: Copies the schemapi utility into the altair/utils directory for reuse. + - `recursive_dict_update`: Recursively updates a dictionary schema with new definitions, ensuring that references are resolved. + - `get_field_datum_value_defs`: Retrieves definitions for fields, datum, and values from a given property schema. + - `toposort`: Performs a topological sort on a directed acyclic graph, which is useful for managing dependencies between schema definitions. + +4. **Channel Wrapper Generation**: + - `generate_vegalite_channel_wrappers`: Generates channel wrappers for the Vega-Lite schema, allowing for the mapping of data properties to visual properties. + +5. **Mixin Generation**: + - `generate_vegalite_mark_mixin`: Creates a mixin class that defines methods for different types of marks in Vega-Lite. + - `generate_vegalite_config_mixin`: Generates a mixin class that provides configuration methods for the schema. + +6. **Main Execution Function**: + - `vegalite_main`: The main function that orchestrates the schema generation process, handling the loading of schemas and the creation of wrapper files. + +7. **Encoding Artifacts Generation**: + - `generate_encoding_artifacts`: Generates artifacts related to encoding, including type aliases and mixin classes for encoding methods. + +8. **Main Entry Point**: + - `main`: The entry point for the script, which processes command-line arguments and initiates the schema generation workflow. +""" + from __future__ import annotations import argparse @@ -498,7 +532,7 @@ def visit(nodes): visit(graph) return stack - +### (X) Function to generate a schema wrapper for Vega-Lite. def generate_vegalite_schema_wrapper(schema_file: Path) -> str: """Generate a schema wrapper at the given path.""" # TODO: generate simple tests for each wrapper @@ -508,6 +542,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: definitions: dict[str, SchemaGenerator] = {} + ### (X) Loop through the definitions in the rootschema and create a SchemaGenerator for each one. for name in rootschema["definitions"]: defschema = {"$ref": "#/definitions/" + name} defschema_repr = {"$ref": "#/definitions/" + name} @@ -521,6 +556,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: rootschemarepr=CodeSnippet(f"{basename}._rootschema"), ) + ### (X) Create a DAG of the definitions. graph: dict[str, list[str]] = {} for name, schema in definitions.items(): @@ -567,6 +603,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: ), ] + ### (X) Append the schema classes in topological order to the contents. for name in toposort(graph): contents.append(definitions[name].schema_class()) From aacac75ab03f840ef61e45a4fe4c3eae608e40d0 Mon Sep 17 00:00:00 2001 From: Xinyue Yang <86961126+Xinyue-Yang@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:47:54 -0400 Subject: [PATCH 08/15] add comments (#1) --- .../generate_schema_wrapper_commented.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/reference/generate_schema_wrapper_commented.py b/reference/generate_schema_wrapper_commented.py index d17f33eb..9b3ebde5 100644 --- a/reference/generate_schema_wrapper_commented.py +++ b/reference/generate_schema_wrapper_commented.py @@ -1,5 +1,39 @@ """Generate a schema wrapper from a schema.""" +""" +(X) The file is organized into several key sections: + +1. **Constants**: + - This section defines constants that are used throughout the module for configuration and encoding methods. + +2. **Schema Generation Functions**: + - `generate_vegalite_schema_wrapper`: This function generates a schema wrapper for Vega-Lite based on the provided schema file. + - `load_schema_with_shorthand_properties`: Loads the schema and incorporates shorthand properties for easier usage. + - `_add_shorthand_property_to_field_encodings`: Adds shorthand properties to field encodings within the schema. + +3. **Utility Functions**: + - `copy_schemapi_util`: Copies the schemapi utility into the altair/utils directory for reuse. + - `recursive_dict_update`: Recursively updates a dictionary schema with new definitions, ensuring that references are resolved. + - `get_field_datum_value_defs`: Retrieves definitions for fields, datum, and values from a given property schema. + - `toposort`: Performs a topological sort on a directed acyclic graph, which is useful for managing dependencies between schema definitions. + +4. **Channel Wrapper Generation**: + - `generate_vegalite_channel_wrappers`: Generates channel wrappers for the Vega-Lite schema, allowing for the mapping of data properties to visual properties. + +5. **Mixin Generation**: + - `generate_vegalite_mark_mixin`: Creates a mixin class that defines methods for different types of marks in Vega-Lite. + - `generate_vegalite_config_mixin`: Generates a mixin class that provides configuration methods for the schema. + +6. **Main Execution Function**: + - `vegalite_main`: The main function that orchestrates the schema generation process, handling the loading of schemas and the creation of wrapper files. + +7. **Encoding Artifacts Generation**: + - `generate_encoding_artifacts`: Generates artifacts related to encoding, including type aliases and mixin classes for encoding methods. + +8. **Main Entry Point**: + - `main`: The entry point for the script, which processes command-line arguments and initiates the schema generation workflow. +""" + from __future__ import annotations import argparse @@ -498,7 +532,7 @@ def visit(nodes): visit(graph) return stack - +### (X) Function to generate a schema wrapper for Vega-Lite. def generate_vegalite_schema_wrapper(schema_file: Path) -> str: """Generate a schema wrapper at the given path.""" # TODO: generate simple tests for each wrapper @@ -508,6 +542,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: definitions: dict[str, SchemaGenerator] = {} + ### (X) Loop through the definitions in the rootschema and create a SchemaGenerator for each one. for name in rootschema["definitions"]: defschema = {"$ref": "#/definitions/" + name} defschema_repr = {"$ref": "#/definitions/" + name} @@ -521,6 +556,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: rootschemarepr=CodeSnippet(f"{basename}._rootschema"), ) + ### (X) Create a DAG of the definitions. graph: dict[str, list[str]] = {} for name, schema in definitions.items(): @@ -567,6 +603,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: ), ] + ### (X) Append the schema classes in topological order to the contents. for name in toposort(graph): contents.append(definitions[name].schema_class()) From 4d8a8390102dafc737c630aea3dca9a7ef8c0ae4 Mon Sep 17 00:00:00 2001 From: Mia Li Date: Fri, 20 Sep 2024 11:39:04 -0400 Subject: [PATCH 09/15] Adding generate_mosaic_schema_wrapper.py --- reference/generate_mosaic_schema_wrapper.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 reference/generate_mosaic_schema_wrapper.py diff --git a/reference/generate_mosaic_schema_wrapper.py b/reference/generate_mosaic_schema_wrapper.py new file mode 100644 index 00000000..e69de29b From 248abdf8cd780a7e236d3f9e6d5975b9db3ad108 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Tue, 24 Sep 2024 14:38:50 -0400 Subject: [PATCH 10/15] More comments --- .../generate_schema_wrapper_commented.py | 25 +++++++++++++++++-- schema_generator/our_schema_generator.py | 0 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 schema_generator/our_schema_generator.py diff --git a/reference/generate_schema_wrapper_commented.py b/reference/generate_schema_wrapper_commented.py index 9b3ebde5..7328d880 100644 --- a/reference/generate_schema_wrapper_commented.py +++ b/reference/generate_schema_wrapper_commented.py @@ -1,3 +1,4 @@ +from __future__ import annotations """Generate a schema wrapper from a schema.""" """ @@ -34,8 +35,7 @@ - `main`: The entry point for the script, which processes command-line arguments and initiates the schema generation workflow. """ -from __future__ import annotations - +import yaml import argparse import copy import json @@ -421,6 +421,8 @@ def load_schema_with_shorthand_properties(schemapath: Path) -> dict: with schemapath.open(encoding="utf8") as f: schema = json.load(f) + # At this point, schema is a python Dict + # Not sure what the below function does. It uses a lot of JSON logic schema = _add_shorthand_property_to_field_encodings(schema) return schema @@ -430,6 +432,7 @@ def _add_shorthand_property_to_field_encodings(schema: dict) -> dict: encoding = SchemaInfo(schema["definitions"][encoding_def], rootschema=schema) + #print(yaml.dump(schema, default_flow_style=False)) for _, propschema in encoding.properties.items(): def_dict = get_field_datum_value_defs(propschema, schema) @@ -538,11 +541,14 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: # TODO: generate simple tests for each wrapper basename = "VegaLiteSchema" + # Not sure what the below function does. It uses a lot of JSON logic + # I'm thinkking of it as just loading the schema rootschema = load_schema_with_shorthand_properties(schema_file) definitions: dict[str, SchemaGenerator] = {} ### (X) Loop through the definitions in the rootschema and create a SchemaGenerator for each one. + # There is a schema generator object for every single lowest level key in the JSON object for name in rootschema["definitions"]: defschema = {"$ref": "#/definitions/" + name} defschema_repr = {"$ref": "#/definitions/" + name} @@ -556,7 +562,12 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: rootschemarepr=CodeSnippet(f"{basename}._rootschema"), ) + #print(definitions) + #print("\n\n\n") + ### (X) Create a DAG of the definitions. + # The DAG consists of each lowest level key corresponding to an array of each in-document $ref + # reference in a dictionary graph: dict[str, list[str]] = {} for name, schema in definitions.items(): @@ -571,6 +582,8 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: assert isinstance(child.basename, list) child.basename.append(name) + #print(graph) + # Specify __all__ explicitly so that we can exclude the ones from the list # of exported classes which are also defined in the channels or api modules which takes # precedent in the generated __init__.py files one and two levels up. @@ -604,6 +617,7 @@ def generate_vegalite_schema_wrapper(schema_file: Path) -> str: ] ### (X) Append the schema classes in topological order to the contents. + # This sort puts the edges at the start of the reference chain first for name in toposort(graph): contents.append(definitions[name].schema_class()) @@ -852,6 +866,7 @@ def vegalite_main(skip_download: bool = False) -> None: # Generate __init__.py file outfile = schemapath / "__init__.py" print(f"Writing {outfile!s}") + # The content is written word for word as seen content = [ "# ruff: noqa\n", "from .core import *\nfrom .channels import *\n", @@ -863,6 +878,7 @@ def vegalite_main(skip_download: bool = False) -> None: ###The function below is a combination of writing, ruff checking and formatting ruff_write_lint_format_str(outfile, content) + # TypeAliasTracer is imported from utils.py and keeps track of all aliases for literals TypeAliasTracer.update_aliases(("Map", "Mapping[str, Any]")) ###(H) Note: Path is a type imported from pathlib. Every Path added to the files @@ -872,6 +888,7 @@ def vegalite_main(skip_download: bool = False) -> None: # Generate the core schema wrappers fp_core = schemapath / "core.py" print(f"Generating\n {schemafile!s}\n ->{fp_core!s}") + # Reminder: the schemafile here is the downloaded reference schemafile files[fp_core] = generate_vegalite_schema_wrapper(schemafile) # Generate the channel wrappers @@ -880,9 +897,12 @@ def vegalite_main(skip_download: bool = False) -> None: files[fp_channels] = generate_vegalite_channel_wrappers(schemafile, version=version) # generate the mark mixin + # A mixin class is one which provides functionality to other classes as a standalone class markdefs = {k: f"{k}Def" for k in ["Mark", "BoxPlot", "ErrorBar", "ErrorBand"]} fp_mixins = schemapath / "mixins.py" print(f"Generating\n {schemafile!s}\n ->{fp_mixins!s}") + + # The following function dynamically creates a mixin class that can be used for 'marks' (eg. bars on bar chart, dot on scatter) mark_imports, mark_mixin = generate_vegalite_mark_mixin(schemafile, markdefs) config_imports, config_mixin = generate_vegalite_config_mixin(schemafile) try_except_imports = [ @@ -1003,6 +1023,7 @@ def main() -> None: args = parser.parse_args() ###(H) Copies the schemapi.py file from schemapi to ../altair/utils copy_schemapi_util() + vegalite_main(args.skip_download) # The modules below are imported after the generation of the new schema files diff --git a/schema_generator/our_schema_generator.py b/schema_generator/our_schema_generator.py new file mode 100644 index 00000000..e69de29b From 3c2503d0f573fbfef9c0f5ed734461666b6cbc03 Mon Sep 17 00:00:00 2001 From: Hamza Pereira Date: Tue, 24 Sep 2024 20:20:50 -0400 Subject: [PATCH 11/15] Initial draft of the schema generator --- schema_generator/our_schema_generator.py | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/schema_generator/our_schema_generator.py b/schema_generator/our_schema_generator.py index e69de29b..cfadc15c 100644 --- a/schema_generator/our_schema_generator.py +++ b/schema_generator/our_schema_generator.py @@ -0,0 +1,58 @@ +import argparse +from typing import Final +from urllib import request +from pathlib import Path + +SCHEMA_VERSION: Final = "v0.10.0" + +SCHEMA_URL_TEMPLATE: Final = "https://github.com/uwdata/mosaic/blob/main/docs/public/schema/{version}.json" + +def schema_url(version: str = SCHEMA_VERSION) -> str: + return SCHEMA_URL_TEMPLATE.format(version=version) + +def download_schemafile( + version: str, schemapath: Path, skip_download: bool = False +) -> Path: + url = schema_url(version=version) + schemadir = Path(schemapath) + schemadir.mkdir(parents=True, exist_ok=True) + fp = schemadir / "mosaic-schema.json" + if not skip_download: + request.urlretrieve(url, fp) + elif not fp.exists(): + msg = f"Cannot skip download: {fp!s} does not exist" + raise ValueError(msg) + return fp + +def mosaic_main(skip_download: bool = False) -> None: + version = SCHEMA_VERSION + vn = '.'.join(version.split(".")[:1]) + fp = (Path(__file__).parent / ".." / template_schemas / vn).resolve() + schemapath = fp / "schema" + schemafile = download_schemafile( + version=version, + schemapath=schemapath, + skip_download=skip_download, + ) + +def main() -> None: + parser = argparse.ArgumentParser( + prog="our_schema_generator", description="Generate the JSON schema for mosaic apps" + ) + parser.add_argument( + "--skip-download", action="store_true", help="skip downloading schema files" + ) + args = parser.parse_args() + copy_schemapi_util() + mosaic_main(args.skip_download) + + # The modules below are imported after the generation of the new schema files + # as these modules import Altair. This allows them to use the new changes + from tools import generate_api_docs, update_init_file + + generate_api_docs.write_api_file() + update_init_file.update__all__variable() + + + if __name__ == "__main__": + main() From 64704b46be20b0496108b1a130108273865ec5de Mon Sep 17 00:00:00 2001 From: mhli1260 <118139796+mhli1260@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:19:49 -0400 Subject: [PATCH 12/15] adding recursive_dict_update and get_field_datum_value_defs --- schema_generator/our_schema_generator.py | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/schema_generator/our_schema_generator.py b/schema_generator/our_schema_generator.py index cfadc15c..64842a97 100644 --- a/schema_generator/our_schema_generator.py +++ b/schema_generator/our_schema_generator.py @@ -3,6 +3,20 @@ from urllib import request from pathlib import Path +sys.path.insert(0, str(Path.cwd())) +from reference.altair_schema_api import CodeSnippet, SchemaInfo, codegen +from reference.altair_schema_api.utils import ( + TypeAliasTracer, + get_valid_identifier, + indent_docstring, + resolve_references, + rst_parse, + rst_syntax_for_class, + ruff_format_py, + ruff_write_lint_format_str, + spell_literal, +) + SCHEMA_VERSION: Final = "v0.10.0" SCHEMA_URL_TEMPLATE: Final = "https://github.com/uwdata/mosaic/blob/main/docs/public/schema/{version}.json" @@ -34,7 +48,37 @@ def mosaic_main(skip_download: bool = False) -> None: schemapath=schemapath, skip_download=skip_download, ) + +def recursive_dict_update(schema: dict, root: dict, def_dict: dict) -> None: + if "$ref" in schema: + next_schema = resolve_references(schema, root) + if "properties" in next_schema: + definition = schema["$ref"] + properties = next_schema["properties"] + for k in def_dict: + if k in properties: + def_dict[k] = definition + else: + recursive_dict_update(next_schema, root, def_dict) + elif "anyOf" in schema: + for sub_schema in schema["anyOf"]: + recursive_dict_update(sub_schema, root, def_dict) + + +def get_field_datum_value_defs(propschema: SchemaInfo, root: dict) -> dict[str, str]: + def_dict: dict[str, str | None] = dict.fromkeys(("field", "datum", "value")) + schema = propschema.schema + if propschema.is_reference() and "properties" in schema: + if "field" in schema["properties"]: + def_dict["field"] = propschema.ref + else: + msg = "Unexpected schema structure" + raise ValueError(msg) + else: + recursive_dict_update(schema, root, def_dict) + return {i: j for i, j in def_dict.items() if j} + def main() -> None: parser = argparse.ArgumentParser( prog="our_schema_generator", description="Generate the JSON schema for mosaic apps" From e481186315b21e365b6bb145f403776232e28f2d Mon Sep 17 00:00:00 2001 From: Xinyue-Yang <86961126+Xinyue-Yang@users.noreply.github.com> Date: Thu, 26 Sep 2024 17:07:22 -0400 Subject: [PATCH 13/15] simple schema --- reference/generate_schema_wrapper.py | 56 ++++++++++++++++++++++++++++ reference/testingSchema.json | 22 +++++++++++ 2 files changed, 78 insertions(+) create mode 100644 reference/generate_schema_wrapper.py create mode 100644 reference/testingSchema.json diff --git a/reference/generate_schema_wrapper.py b/reference/generate_schema_wrapper.py new file mode 100644 index 00000000..b457ad3c --- /dev/null +++ b/reference/generate_schema_wrapper.py @@ -0,0 +1,56 @@ +import json +from typing import Any, Dict + +def generate_schema_wrapper(schema_file: str) -> str: + with open(schema_file, 'r') as f: + schema = json.load(f) + + definitions = schema.get('definitions', {}) + + # Generate classes for each definition + classes = [] + for class_name, class_schema in definitions.items(): + classes.append(generate_class(class_name, class_schema)) + + #Combine all generated classes into a single string + return "\n\n".join(classes) + +def generate_class(class_name: str, class_schema: Dict[str, Any]) -> str: + # Extract properties and required fields + properties = class_schema.get('properties', {}) + required = class_schema.get('required', []) + + # Generate class definition + class_def = f"class {class_name}:\n" + class_def += f" def __init__(self" + + # Generate __init__ method parameters + for prop, prop_schema in properties.items(): + default = 'None' if prop not in required else '' + class_def += f", {prop}: {get_type_hint(prop_schema)} = {default}" + + class_def += "):\n" + + # Generate attribute assignments in __init__ + for prop in properties: + class_def += f" self.{prop} = {prop}\n" + + return class_def + +def get_type_hint(prop_schema: Dict[str, Any]) -> str: + # Determine the appropriate type hint based on the property schema + if 'type' in prop_schema: + if prop_schema['type'] == 'string': + return 'str' + elif prop_schema['type'] == 'boolean': + return 'bool' + elif prop_schema['type'] == 'object': + return 'Dict[str, Any]' + elif '$ref' in prop_schema: + return prop_schema['$ref'].split('/')[-1] + return 'Any' + +if __name__ == "__main__": + schema_file = "reference/testingSchema.json" + generated_code = generate_schema_wrapper(schema_file) + print(generated_code) \ No newline at end of file diff --git a/reference/testingSchema.json b/reference/testingSchema.json new file mode 100644 index 00000000..48679404 --- /dev/null +++ b/reference/testingSchema.json @@ -0,0 +1,22 @@ +{ + "$ref": "#/definitions/Spec", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AggregateExpression": { + "additionalProperties": false, + "description": "A custom SQL aggregate expression.", + "properties": { + "agg": { + "description": "A SQL expression string to calculate an aggregate value. Embedded Param references, such as `SUM($param + 1)`, are supported. For expressions without aggregate functions, use *sql* instead.", + "type": "string" + }, + "label": { + "description": "A label for this expression, for example to label a plot axis.", + "type": "string" + } + }, + "required": ["agg"], + "type": "object" + } + } +} From 3ede84d5f72c97b8d131412e554b7116b220d94d Mon Sep 17 00:00:00 2001 From: Mia Li Date: Tue, 1 Oct 2024 23:52:58 -0400 Subject: [PATCH 14/15] anyOf and ref handles --- reference/generate_schema_wrapper.py | 56 ---- reference/testingSchema.json | 22 -- .../generated_classes.cpython-311.pyc | Bin 0 -> 3455 bytes {reference => tools}/example-api-usage.py | 0 .../generate_mosaic_schema_wrapper.py | 0 tools/generate_schema_wrapper.py | 130 +++++++++ .../generate_schema_wrapper_commented.py | 0 tools/generated_classes.py | 36 +++ .../schemapi}/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 654 bytes .../__pycache__/codegen.cpython-311.pyc | Bin 0 -> 20267 bytes .../__pycache__/schemapi.cpython-311.pyc | Bin 0 -> 70371 bytes .../__pycache__/utils.cpython-311.pyc | Bin 0 -> 43402 bytes .../schemapi}/codegen.py | 0 .../schemapi}/schemapi.py | 0 .../schemapi}/utils.py | 0 tools/test.py | 11 + tools/testingSchema.json | 259 ++++++++++++++++++ 18 files changed, 436 insertions(+), 78 deletions(-) delete mode 100644 reference/generate_schema_wrapper.py delete mode 100644 reference/testingSchema.json create mode 100644 tools/__pycache__/generated_classes.cpython-311.pyc rename {reference => tools}/example-api-usage.py (100%) rename {reference => tools}/generate_mosaic_schema_wrapper.py (100%) create mode 100644 tools/generate_schema_wrapper.py rename {reference => tools}/generate_schema_wrapper_commented.py (100%) create mode 100644 tools/generated_classes.py rename {reference/altair_schemapi => tools/schemapi}/__init__.py (100%) create mode 100644 tools/schemapi/__pycache__/__init__.cpython-311.pyc create mode 100644 tools/schemapi/__pycache__/codegen.cpython-311.pyc create mode 100644 tools/schemapi/__pycache__/schemapi.cpython-311.pyc create mode 100644 tools/schemapi/__pycache__/utils.cpython-311.pyc rename {reference/altair_schemapi => tools/schemapi}/codegen.py (100%) rename {reference/altair_schemapi => tools/schemapi}/schemapi.py (100%) rename {reference/altair_schemapi => tools/schemapi}/utils.py (100%) create mode 100644 tools/test.py create mode 100644 tools/testingSchema.json diff --git a/reference/generate_schema_wrapper.py b/reference/generate_schema_wrapper.py deleted file mode 100644 index b457ad3c..00000000 --- a/reference/generate_schema_wrapper.py +++ /dev/null @@ -1,56 +0,0 @@ -import json -from typing import Any, Dict - -def generate_schema_wrapper(schema_file: str) -> str: - with open(schema_file, 'r') as f: - schema = json.load(f) - - definitions = schema.get('definitions', {}) - - # Generate classes for each definition - classes = [] - for class_name, class_schema in definitions.items(): - classes.append(generate_class(class_name, class_schema)) - - #Combine all generated classes into a single string - return "\n\n".join(classes) - -def generate_class(class_name: str, class_schema: Dict[str, Any]) -> str: - # Extract properties and required fields - properties = class_schema.get('properties', {}) - required = class_schema.get('required', []) - - # Generate class definition - class_def = f"class {class_name}:\n" - class_def += f" def __init__(self" - - # Generate __init__ method parameters - for prop, prop_schema in properties.items(): - default = 'None' if prop not in required else '' - class_def += f", {prop}: {get_type_hint(prop_schema)} = {default}" - - class_def += "):\n" - - # Generate attribute assignments in __init__ - for prop in properties: - class_def += f" self.{prop} = {prop}\n" - - return class_def - -def get_type_hint(prop_schema: Dict[str, Any]) -> str: - # Determine the appropriate type hint based on the property schema - if 'type' in prop_schema: - if prop_schema['type'] == 'string': - return 'str' - elif prop_schema['type'] == 'boolean': - return 'bool' - elif prop_schema['type'] == 'object': - return 'Dict[str, Any]' - elif '$ref' in prop_schema: - return prop_schema['$ref'].split('/')[-1] - return 'Any' - -if __name__ == "__main__": - schema_file = "reference/testingSchema.json" - generated_code = generate_schema_wrapper(schema_file) - print(generated_code) \ No newline at end of file diff --git a/reference/testingSchema.json b/reference/testingSchema.json deleted file mode 100644 index 48679404..00000000 --- a/reference/testingSchema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$ref": "#/definitions/Spec", - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AggregateExpression": { - "additionalProperties": false, - "description": "A custom SQL aggregate expression.", - "properties": { - "agg": { - "description": "A SQL expression string to calculate an aggregate value. Embedded Param references, such as `SUM($param + 1)`, are supported. For expressions without aggregate functions, use *sql* instead.", - "type": "string" - }, - "label": { - "description": "A label for this expression, for example to label a plot axis.", - "type": "string" - } - }, - "required": ["agg"], - "type": "object" - } - } -} diff --git a/tools/__pycache__/generated_classes.cpython-311.pyc b/tools/__pycache__/generated_classes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ffff5c6b87c8958e744cdc228c79202cf197cd8 GIT binary patch literal 3455 zcmd5;&2Jk;6rbJMU2i^1nzl(vTTFpUzQjVJrHBGbleRz`6A0#>a)HJMHKIds-o6a5O*V`T;%NcGh6yvHJ7r{lxv6e(Z{7a&8G zC3(4cz>#Pyold{VWmhB@GFu7!a*$o%@?li1Wf!Z`4+{7E)oigU9&+YYG$Y#~QMFo@ z*%A-Afc-PCQ1+$da<;avT+a)_Ao9G&7mhgKY*&hZFV6rxCC$lmTh_O3^X$t&hPU9; z20js(OBovthJJ;6o*MRG8?0W&=TXo5y6%@d5-KAh#z4P8#5k@UMR2-+&Xex~JWW0= z)3j$=1Yl=glV?YDj3oj%(jDEh^geSLOPm6pcnM(w0hepj zJq|jHJOtc=d<~%2L54lnB7+dG$5n?Mfk(m7z5>we2uObOycQu8O-45@C;|&yW2^Dc zg&#`DvC8d$mzlEH{BoT`>YfH&y|qHp3V@STLG10rRgieFVF>)ghN*q1pCztL_j+>b z@INR1M7OumS>VrujEue7u=~Agf#Q*_okjryX0f~qaM-moJAC)`t9zHOp^G6AkAc|P zXy;#W_UWV`le25*WG0j|CrTCnQA~pnn%o++q4{b(j1*l20bK;HeLD~`Qr0~m&{^ax z@Iz%SRvA~RB>|;bh?M(j-4COn%$2hoF~--_P)wJqHAR=}73JRd1uQIp20Q`#AB0%U zfJ?*$^zA@oX9ka%L{}HZMO=$*A}224BNkljXx%o_I7L8}fwlpG+Tkri-k7KRguJo< zZR42OX+dlktsM#=?gK}=2GCP2L!HY4>VwZJM^cY@ z;GBFbZBV$QP+yM?3ufJxK{jldVJXEj2WH*d^q?9gU`Djx1b`-R?gCs(BhBeFkg?{}B_N~CnJZh?o;%&?5Y02w zz#ZCxkB)c4&f#bb^|uQ=v9ea-_7u8v=q`b+^vE89LDG@JX4w7(ptpxO_W2e8*pZq% zW19SY>$ln6{Da+(AM7oBp*g$JI;86p<0BKuT{DN|iIFoQd+Ww0Fw=%0HVh@){1VAm zrif<(^*mE1dN=7ENO|%hy^Ca#uEetytcIKPKFqosJL)Me2RzvSsvIuEhC1aCgvVys zf0_Dou)||F)=m8rl}<|iAWTvpd#b>9r8rbK@aa8-zAlL_69~E!e}FUn=A~Vk8B$KP zUJJsKz?6@Z9bYo^m9Fna-C=chYb$-`U${5oSH1Ve0xUD&C#L{g)G&-Dxww1Ow`EcT zuF^qxj=cQ?fomG@YBLDWk+**!06lz1901e!l2+*+F=Yy!`{g=|L5}tY0r# Hp str: + #Hard coded this for now...? + imports = "from typing import Any, Union\n" + + + # Define a list of primitive types + # primitive_types = ['string', 'number', 'integer', 'boolean'] + + # Check if the schema defines a simple type (like string, number) without properties + if 'type' in class_schema and 'properties' not in class_schema: + return f"class {class_name}:\n def __init__(self):\n pass\n" + + + + + # Check for '$ref' and handle it + if '$ref' in class_schema: + ref_class_name = class_schema['$ref'].split('/')[-1] + return f"{imports}\nclass {class_name}:\n pass # This is a reference to {ref_class_name}\n" + + if 'anyOf' in class_schema: + return generate_any_of_class(class_name, class_schema['anyOf']) + + # Extract properties and required fields + properties = class_schema.get('properties', {}) + required = class_schema.get('required', []) + + class_def = f"{imports}class {class_name}:\n" + class_def += " def __init__(self" + + # Generate __init__ method parameters + for prop, prop_schema in properties.items(): + type_hint = get_type_hint(prop_schema) + if prop in required: + # Required parameters should not have default values + class_def += f", {prop}: {type_hint}" + else: + # Optional parameters should have a default value of None + class_def += f", {prop}: {type_hint} = None" + + class_def += "):\n" + + # Generate attribute assignments in __init__ + for prop in properties: + class_def += f" self.{prop} = {prop}\n" + + return class_def + + +def generate_any_of_class(class_name: str, any_of_schemas: List[Dict[str, Any]]) -> str: + types = [get_type_hint(schema) for schema in any_of_schemas] + type_union = "Union[" + ", ".join(f'"{t}"' for t in types) + "]" # Add quotes + + class_def = f"class {class_name}:\n" + class_def += f" def __init__(self, value: {type_union}):\n" + class_def += " self.value = value\n" + + return class_def + + + +def get_type_hint(prop_schema: Dict[str, Any]) -> str: + """Get type hint for a property schema.""" + if 'type' in prop_schema: + if prop_schema['type'] == 'string': + return 'str' + elif prop_schema['type'] == 'boolean': + return 'bool' + elif prop_schema['type'] == 'object': + return 'Dict[str, Any]' + elif 'anyOf' in prop_schema: + types = [get_type_hint(option) for option in prop_schema['anyOf']] + return f'Union[{", ".join(types)}]' + elif '$ref' in prop_schema: + return prop_schema['$ref'].split('/')[-1] # Get the definition name + return 'Any' + +def load_schema(schema_path: Path) -> dict: + """Load a JSON schema from the specified path.""" + with schema_path.open(encoding="utf8") as f: + return json.load(f) + +def generate_schema_wrapper(schema_file: Path, output_file: Path) -> str: + """Generate a schema wrapper for the given schema file.""" + rootschema = load_schema(schema_file) + + definitions: Dict[str, str] = {} + + # Loop through the definitions and generate classes + for name, schema in rootschema.get("definitions", {}).items(): + class_code = generate_class(name, schema) + definitions[name] = class_code + + generated_classes = "\n\n".join(definitions.values()) + + with open(output_file, 'w') as f: + f.write(generated_classes) + +# Main execution +if __name__ == "__main__": + schema_file = "tools/testingSchema.json" # Update this path as needed + output_file = Path("tools/generated_classes.py") + generate_schema_wrapper(Path(schema_file), output_file) diff --git a/reference/generate_schema_wrapper_commented.py b/tools/generate_schema_wrapper_commented.py similarity index 100% rename from reference/generate_schema_wrapper_commented.py rename to tools/generate_schema_wrapper_commented.py diff --git a/tools/generated_classes.py b/tools/generated_classes.py new file mode 100644 index 00000000..64f32673 --- /dev/null +++ b/tools/generated_classes.py @@ -0,0 +1,36 @@ +from typing import Any, Union +class AggregateExpression: + def __init__(self, agg: str, label: str = None): + self.agg = agg + self.label = label +class ParamRef: + def __init__(self): + pass + +class TransformField: + def __init__(self, value: Union["str", "ParamRef"]): + self.value = value + +class AggregateTransform: + def __init__(self, value: Union["Argmax", "Argmin", "Avg", "Count", "Max", "Min", "First", "Last", "Median", "Mode", "Product", "Quantile", "Stddev", "StddevPop", "Sum", "Variance", "VarPop"]): + self.value = value + +class Argmax: + def __init__(self, argmax: Any, distinct: bool = None, orderby: Union[TransformField, Any] = None, partitionby: Union[TransformField, Any] = None, range: Union[Any, ParamRef] = None, rows: Union[Any, ParamRef] = None): + self.argmax = argmax + self.distinct = distinct + self.orderby = orderby + self.partitionby = partitionby + self.range = range + self.rows = rows + +class Argmin: + def __init__(self, argmin: Any, distinct: bool = None, orderby: Union[TransformField, Any] = None, partitionby: Union[TransformField, Any] = None, range: Union[Any, ParamRef] = None, rows: Union[Any, ParamRef] = None): + self.argmin = argmin + self.distinct = distinct + self.orderby = orderby + self.partitionby = partitionby + self.range = range + self.rows = rows + + diff --git a/reference/altair_schemapi/__init__.py b/tools/schemapi/__init__.py similarity index 100% rename from reference/altair_schemapi/__init__.py rename to tools/schemapi/__init__.py diff --git a/tools/schemapi/__pycache__/__init__.cpython-311.pyc b/tools/schemapi/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..244c9bd40776617378b5275b6c8567ef96be2813 GIT binary patch literal 654 zcmZuvziZqu6n?VLXU9oODWzGxnu0yIq=ojfB(&GSkq#yx+>`Diq94X`hIA+y`ag8< z67t`8JTwJ5bt_~G87irF4HqIkKEJ2;h_DKgi$gvTQ5AWAz=tBPVv$sdk0ZVz(kewT2QvJLs!bA4AWy!&(bE$u z>W00}I;FVIW~$BRRMNKYn4D*a=iP~t*}KDin`)KF4kn+**}o=T{C2wXz{FEUsO>V* zS;x4}k=Y#ClZj+aLpxiLPCU)~T2r%eBnh1{Nr|mnCidk_<-<0zD%@eP-SgF6SGB4IM#ZsD|ZFU|c|Mz=R$7h~DDDgM)dK`PrImTCTV=Liy zTpnrK>Qb(@r(zN2O~v{ZM}??OUEwf`vaQmmfdAToIQTGs!xPi{|_`*%gYM zar(A>X2;u~0JOC5CqxJ>VAR9t8uI=zTR_pT+X7zokJ$oV_Um??e(t?p#BR4wE#+U? C%)wg# literal 0 HcmV?d00001 diff --git a/tools/schemapi/__pycache__/codegen.cpython-311.pyc b/tools/schemapi/__pycache__/codegen.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f96747053a86c99867f3bd5da732bc3b06f0cf8 GIT binary patch literal 20267 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 literal 0 HcmV?d00001 diff --git a/tools/schemapi/__pycache__/utils.cpython-311.pyc b/tools/schemapi/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f61e2d06a1ec21ed2668da1f163978192bccaa5 GIT binary patch literal 43402 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 Date: Wed, 2 Oct 2024 11:24:21 -0400 Subject: [PATCH 15/15] deleting extra comments --- tools/generate_schema_wrapper.py | 24 ++++-------------------- tools/generated_classes.py | 3 ++- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py index 6ec20bff..e51f4b92 100644 --- a/tools/generate_schema_wrapper.py +++ b/tools/generate_schema_wrapper.py @@ -26,21 +26,12 @@ ) def generate_class(class_name: str, class_schema: Dict[str, Any]) -> str: - #Hard coded this for now...? - imports = "from typing import Any, Union\n" - - # Define a list of primitive types - # primitive_types = ['string', 'number', 'integer', 'boolean'] + imports = "from typing import Any, Union\n" - # Check if the schema defines a simple type (like string, number) without properties if 'type' in class_schema and 'properties' not in class_schema: return f"class {class_name}:\n def __init__(self):\n pass\n" - - - - # Check for '$ref' and handle it if '$ref' in class_schema: ref_class_name = class_schema['$ref'].split('/')[-1] return f"{imports}\nclass {class_name}:\n pass # This is a reference to {ref_class_name}\n" @@ -48,26 +39,21 @@ def generate_class(class_name: str, class_schema: Dict[str, Any]) -> str: if 'anyOf' in class_schema: return generate_any_of_class(class_name, class_schema['anyOf']) - # Extract properties and required fields properties = class_schema.get('properties', {}) required = class_schema.get('required', []) class_def = f"{imports}class {class_name}:\n" class_def += " def __init__(self" - # Generate __init__ method parameters for prop, prop_schema in properties.items(): type_hint = get_type_hint(prop_schema) if prop in required: - # Required parameters should not have default values class_def += f", {prop}: {type_hint}" else: - # Optional parameters should have a default value of None class_def += f", {prop}: {type_hint} = None" class_def += "):\n" - # Generate attribute assignments in __init__ for prop in properties: class_def += f" self.{prop} = {prop}\n" @@ -76,7 +62,7 @@ def generate_class(class_name: str, class_schema: Dict[str, Any]) -> str: def generate_any_of_class(class_name: str, any_of_schemas: List[Dict[str, Any]]) -> str: types = [get_type_hint(schema) for schema in any_of_schemas] - type_union = "Union[" + ", ".join(f'"{t}"' for t in types) + "]" # Add quotes + type_union = "Union[" + ", ".join(f'"{t}"' for t in types) + "]" class_def = f"class {class_name}:\n" class_def += f" def __init__(self, value: {type_union}):\n" @@ -99,7 +85,7 @@ def get_type_hint(prop_schema: Dict[str, Any]) -> str: types = [get_type_hint(option) for option in prop_schema['anyOf']] return f'Union[{", ".join(types)}]' elif '$ref' in prop_schema: - return prop_schema['$ref'].split('/')[-1] # Get the definition name + return prop_schema['$ref'].split('/')[-1] return 'Any' def load_schema(schema_path: Path) -> dict: @@ -113,7 +99,6 @@ def generate_schema_wrapper(schema_file: Path, output_file: Path) -> str: definitions: Dict[str, str] = {} - # Loop through the definitions and generate classes for name, schema in rootschema.get("definitions", {}).items(): class_code = generate_class(name, schema) definitions[name] = class_code @@ -123,8 +108,7 @@ def generate_schema_wrapper(schema_file: Path, output_file: Path) -> str: with open(output_file, 'w') as f: f.write(generated_classes) -# Main execution if __name__ == "__main__": - schema_file = "tools/testingSchema.json" # Update this path as needed + schema_file = "tools/testingSchema.json" output_file = Path("tools/generated_classes.py") generate_schema_wrapper(Path(schema_file), output_file) diff --git a/tools/generated_classes.py b/tools/generated_classes.py index 64f32673..437d65d4 100644 --- a/tools/generated_classes.py +++ b/tools/generated_classes.py @@ -3,10 +3,11 @@ class AggregateExpression: def __init__(self, agg: str, label: str = None): self.agg = agg self.label = label + class ParamRef: def __init__(self): pass - + class TransformField: def __init__(self, value: Union["str", "ParamRef"]): self.value = value