Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/hygiene.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dev = [
"pytest-cov",
"black",
"ruff>=0.12.8",
"basedpyright>=1.31.1",
]

[project.urls]
Expand All @@ -63,3 +64,8 @@ lint.select = [
]

line-length = 120

[tool.basedpyright]
include = ["src/implicitdict/", "tests/"]
exclude = ["src/immplicitdict/_version.py"]
typeCheckingMode = "standard"
7 changes: 5 additions & 2 deletions src/implicitdict/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 17 additions & 10 deletions src/implicitdict/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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]}
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/test_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/test_string_based_time_delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading