diff --git a/.github/workflows/hygiene.yaml b/.github/workflows/hygiene.yaml index 9604276..cfb96da 100644 --- a/.github/workflows/hygiene.yaml +++ b/.github/workflows/hygiene.yaml @@ -25,4 +25,6 @@ jobs: run: uv run ruff format --check - name: Ruff check run: uv run ruff check + - name: Basedpyright check + run: uv run basedpyright diff --git a/pyproject.toml b/pyproject.toml index 6e2a854..3c1bb33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "pytest-cov", "black", "ruff>=0.12.8", + "basedpyright>=1.31.1", ] [project.urls] @@ -63,3 +64,8 @@ lint.select = [ ] line-length = 120 + +[tool.basedpyright] +include = ["src/implicitdict/", "tests/"] +exclude = ["src/immplicitdict/_version.py"] +typeCheckingMode = "standard" diff --git a/src/implicitdict/__init__.py b/src/implicitdict/__init__.py index 942add6..0bad446 100644 --- a/src/implicitdict/__init__.py +++ b/src/implicitdict/__init__.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime as datetime_type from types import UnionType -from typing import Literal, Optional, Union, get_args, get_origin, get_type_hints +from typing import Literal, Optional, Union, get_args, get_origin, get_type_hints # pyright:ignore[reportDeprecated] import arrow import pytimeparse @@ -330,7 +330,10 @@ def __new__(cls, value: str | datetime.timedelta | int | float, reformat: bool = reformat: If true, override a provided string with a string representation of the parsed timedelta. """ if isinstance(value, str): - dt = datetime.timedelta(seconds=pytimeparse.parse(value)) + seconds = pytimeparse.parse(value) + if seconds is None: + raise ValueError(f"Could not parse type {type(value).__name__} into StringBasedTimeDelta") + dt = datetime.timedelta(seconds=seconds) s = str(dt) if reformat else value elif isinstance(value, float) or isinstance(value, int): dt = datetime.timedelta(seconds=value) diff --git a/src/implicitdict/jsonschema.py b/src/implicitdict/jsonschema.py index ed56b0b..afdf9d3 100644 --- a/src/implicitdict/jsonschema.py +++ b/src/implicitdict/jsonschema.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from types import UnionType -from typing import Literal, Union, get_args, get_origin, get_type_hints +from typing import Literal, TypeAlias, Union, cast, get_args, get_origin, get_type_hints from . import ImplicitDict, StringBasedDateTime, StringBasedTimeDelta, _fullname, _get_fields @@ -31,11 +31,14 @@ class SchemaVars: _implicitdict_doc = inspect.getdoc(ImplicitDict) +SchemaDictV: TypeAlias = Union[bool, str, list["SchemaDictV"], "SchemaDict"] +SchemaDict: TypeAlias = dict[str, SchemaDictV] + def make_json_schema( schema_type: type[ImplicitDict], schema_vars_resolver: SchemaVarsResolver, - schema_repository: dict[str, dict], + schema_repository: SchemaDict, ) -> None: """Create JSON Schema for the specified schema type and all dependencies. @@ -52,7 +55,7 @@ def make_json_schema( # Add placeholder to avoid recursive definition attempts while we're making this schema schema_repository[schema_vars.name] = {"$generating": True} - properties = {"$ref": {"type": "string", "description": "Path to content that replaces the $ref"}} + properties: SchemaDict = {"$ref": {"type": "string", "description": "Path to content that replaces the $ref"}} all_fields, optional_fields = _get_fields(schema_type) required_fields = [] hints = get_type_hints(schema_type) @@ -80,8 +83,8 @@ def make_json_schema( print(f"Warning: Omitting {schema_type.__name__}.{field} from definition because: {e}") continue - if field in field_docs: - properties[field]["description"] = field_docs[field] + if field in field_docs and isinstance(properties[field], dict): + cast(dict, properties[field])["description"] = field_docs[field] schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties} if schema_vars.schema_id is not None: @@ -90,7 +93,7 @@ def make_json_schema( docs = inspect.getdoc(schema_type) if docs != _implicitdict_doc: if schema_vars.description is not None: - schema["description"] = docs + "\n\n" + schema_vars.description + schema["description"] = f"{docs}\n\n{schema_vars.description}" else: schema["description"] = docs elif schema_vars.description is not None: @@ -104,8 +107,8 @@ def make_json_schema( def _schema_for( - value_type: type, schema_vars_resolver: SchemaVarsResolver, schema_repository: dict[str, dict], context: type -) -> tuple[dict, bool]: + value_type: type, schema_vars_resolver: SchemaVarsResolver, schema_repository: SchemaDict, context: type +) -> tuple[SchemaDict, bool]: """Get the JSON Schema representation of the value_type. Args: @@ -122,6 +125,7 @@ def _schema_for( E.g., _schema_for(Optional[float], ...) would indicate True because an Optional[float] field within an ImplicitDict would be an optional field in that object. """ + generic_type = get_origin(value_type) if generic_type: # Type is generic @@ -145,9 +149,9 @@ def _schema_for( ): # Type is an Optional declaration subschema, _ = _schema_for(arg_types[0], schema_vars_resolver, schema_repository, context) - schema = json.loads(json.dumps(subschema)) + schema: SchemaDict = json.loads(json.dumps(subschema)) if "type" in schema: - if "null" not in schema["type"]: + if not isinstance(schema["type"], list) or "null" not in schema["type"]: schema["type"] = [schema["type"], "null"] else: schema = {"oneOf": [{"type": "null"}, schema]} @@ -166,6 +170,9 @@ def _schema_for( if issubclass(value_type, ImplicitDict): make_json_schema(value_type, schema_vars_resolver, schema_repository) + + if not schema_vars.path_to: + raise NotImplementedError(f"SchemaVarsResolver for {value_type} didn't returned a path_to function") return {"$ref": schema_vars.path_to(value_type, context)}, False if value_type is bool or issubclass(value_type, bool): diff --git a/tests/test_containers.py b/tests/test_containers.py index b73733c..d2ebebc 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -10,6 +10,7 @@ def test_container_item_value_casting(): for v in containers.value_list: assert v.is_special + assert containers.optional_list is not None assert len(containers.optional_list) == 1 assert containers.optional_list[0].is_special diff --git a/tests/test_string_based_time_delta.py b/tests/test_string_based_time_delta.py index 127a3dd..d8993eb 100644 --- a/tests/test_string_based_time_delta.py +++ b/tests/test_string_based_time_delta.py @@ -75,6 +75,6 @@ def test_behavior_timedelta(): assert sbtd.timedelta == dt s = str(sbtd) with pytest.raises(AttributeError): # 'str' object has no attribute 'timedelta' - assert s.timedelta == dt + assert s.timedelta == dt # pyright:ignore[reportAttributeAccessIssue] sbtd2 = StringBasedTimeDelta(s) assert sbtd2.timedelta == dt diff --git a/uv.lock b/uv.lock index 9016aef..d4613dc 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "basedpyright" +version = "1.31.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/39/e2870a3739dce055a5b7822d027843c9ba9b3453dcb4b226d9b0e9d486f4/basedpyright-1.31.1.tar.gz", hash = "sha256:4e4d922a385f45dc93e50738d1131ec4533fee5d338b700ef2d28e2e0412e642", size = 22067890, upload-time = "2025-08-03T13:41:15.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/cc/8bca3b3a48d6a03a4b857a297fb1473ed1b9fa111be2d20c01f11112e75c/basedpyright-1.31.1-py3-none-any.whl", hash = "sha256:8b647bf07fff929892db4be83a116e6e1e59c13462ecb141214eb271f6785ee5", size = 11540576, upload-time = "2025-08-03T13:41:11.571Z" }, +] + [[package]] name = "black" version = "25.1.0" @@ -192,6 +204,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "basedpyright" }, { name = "black" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -207,6 +220,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "basedpyright", specifier = ">=1.31.1" }, { name = "black" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -258,6 +272,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "22.18.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/6d/773e09de4a052cc75c129c3766a3cf77c36bff8504a38693b735f4a1eb55/nodejs_wheel_binaries-22.18.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b04495857755c5d5658f7ac969d84f25898fe0b0c1bdc41172e5e0ac6105ca", size = 50873051, upload-time = "2025-08-01T11:10:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fc/3d6fd4ad5d26c9acd46052190d6a8895dc5050297b03d9cce03def53df0d/nodejs_wheel_binaries-22.18.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bd4d016257d4dfe604ed526c19bd4695fdc4f4cc32e8afc4738111447aa96d03", size = 51814481, upload-time = "2025-08-01T11:10:33.086Z" }, + { url = "https://files.pythonhosted.org/packages/10/f9/7be44809a861605f844077f9e731a117b669d5ca6846a7820e7dd82c9fad/nodejs_wheel_binaries-22.18.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b125f94f3f5e8ab9560d3bd637497f02e45470aeea74cf6fe60afe751cfa5f", size = 57804907, upload-time = "2025-08-01T11:10:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/e9/67/563e74a0dff653ec7ddee63dc49b3f37a20df39f23675cfc801d7e8e4bb7/nodejs_wheel_binaries-22.18.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bbb81b6e67c15f04e2a9c6c220d7615fb46ae8f1ad388df0d66abac6bed5f8", size = 58335587, upload-time = "2025-08-01T11:10:40.716Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/ec45fefef60223dd40e7953e2ff087964e200d6ec2d04eae0171d6428679/nodejs_wheel_binaries-22.18.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5d3ea8b7f957ae16b73241451f6ce831d6478156f363cce75c7ea71cbe6c6f7", size = 59662356, upload-time = "2025-08-01T11:10:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ed/6de2c73499eebf49d0d20e0704f64566029a3441c48cd4f655d49befd28b/nodejs_wheel_binaries-22.18.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bcda35b07677039670102a6f9b78c2313fd526111d407cb7ffc2a4c243a48ef9", size = 60706806, upload-time = "2025-08-01T11:10:48.985Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f5/487434b1792c4f28c63876e4a896f2b6e953e2dc1f0b3940e912bd087755/nodejs_wheel_binaries-22.18.0-py2.py3-none-win_amd64.whl", hash = "sha256:0f55e72733f1df2f542dce07f35145ac2e125408b5e2051cac08e5320e41b4d1", size = 39998139, upload-time = "2025-08-01T11:10:52.676Z" }, +] + [[package]] name = "packaging" version = "25.0"