Skip to content

Commit 347843e

Browse files
committed
chore: improved type and test coverage
1 parent b9114a8 commit 347843e

57 files changed

Lines changed: 2017 additions & 1000 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build.yml

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ on:
44
pull_request:
55
branches: [master]
66
jobs:
7-
build:
7+
lint:
88
runs-on: ubuntu-latest
99
steps:
10-
- name: Checkout Git
10+
- name: Checkout
1111
uses: actions/checkout@v6
1212
- name: Install UV
1313
uses: astral-sh/setup-uv@v6
@@ -16,11 +16,27 @@ jobs:
1616
enable-cache: true
1717
- name: Install dependencies
1818
run: uv sync --locked --dev
19-
- name: Run linters
20-
run: |
21-
uv run ruff check .
22-
uv run mypy .
23-
uv run ty check .
19+
- name: Run ruff
20+
run: uv run ruff check .
21+
- name: Run mypy
22+
run: uv run mypy .
23+
- name: Run ty
24+
run: uv run ty check .
25+
test:
26+
name: Test Python ${{ matrix.python-version }}
27+
strategy:
28+
matrix:
29+
python-version: ["3.10", "3.11", "3.12", "3.13"]
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v6
34+
- name: Install UV
35+
uses: astral-sh/setup-uv@v6
36+
with:
37+
python-version: ${{ matrix.python-version }}
38+
enable-cache: true
39+
- name: Install dependencies
40+
run: uv sync --locked --dev
2441
- name: Run tests
2542
run: uv run pytest
26-

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ classifiers = [
2525
dependencies = [
2626
"openapi-spec-validator>=0.8.5",
2727
"prance>=25.4.8.0",
28-
"ty>=0.0.37",
2928
]
3029

3130
[project.urls]
@@ -56,6 +55,10 @@ mypy_path = ["src"]
5655
[[tool.mypy.overrides]]
5756
module = "tests.*"
5857

58+
[[tool.mypy.overrides]]
59+
module = "prance"
60+
ignore_missing_imports = true
61+
5962
[tool.ruff.lint]
6063
extend-select = [
6164
"D", # pydocstyle
@@ -65,6 +68,7 @@ extend-select = [
6568
"I", # isort
6669
"ARG", # unused arguments
6770
"RUF100", # unused noqa
71+
"TID252", # ban relative imports
6872
]
6973

7074
[tool.ruff.lint.pydocstyle]

src/openapi_parser/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
from .parser import parse
1+
"""OpenAPI v3 specification parser."""
2+
3+
from openapi_parser.parser import parse
24

35
__all__ = ["parse"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Builders for converting raw OpenAPI dicts into typed specification objects."""

src/openapi_parser/builders/common.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1-
from collections import namedtuple
2-
from typing import Any, Callable, Dict, Optional
1+
"""Shared builder utilities and type helpers."""
32

4-
from ..errors import ParserError
3+
from collections.abc import Callable
4+
from dataclasses import dataclass
5+
from typing import Any
56

6-
PropertyMeta = namedtuple('PropertyMeta', ['cast', 'name'])
7+
from openapi_parser.errors import ParserError
78

89

9-
def extract_typed_props(data: dict, attrs_map: Dict[str, PropertyMeta]) -> Dict[str, Any]:
10-
"""Extract properties from the dictionary with type-casting using passed mapping
10+
@dataclass
11+
class PropertyMeta:
12+
"""Property metadata for type-casting extraction."""
13+
14+
name: str
15+
cast: Callable[..., Any] | None = None
16+
17+
18+
def extract_typed_props(
19+
data: dict[str, Any],
20+
attrs_map: dict[str, PropertyMeta],
21+
) -> dict[str, Any]:
22+
"""Extract properties from the dictionary with type-casting using passed mapping.
1123
1224
Args:
1325
data (dict): Original dictionary to process
@@ -17,13 +29,17 @@ def extract_typed_props(data: dict, attrs_map: Dict[str, PropertyMeta]) -> Dict[
1729
Dict[str, Any]: Dictionary with type-casted values
1830
"""
1931

20-
def cast_value(name: str, value: Any, type_cast_func: Optional[Callable]) -> Any:
32+
def cast_value(
33+
name: str,
34+
value: Any,
35+
type_cast_func: Callable[..., Any] | None,
36+
) -> Any:
2137
try:
22-
return type_cast_func(value) \
23-
if type_cast_func is not None \
24-
else value
38+
return type_cast_func(value) if type_cast_func is not None else value
2539
except ValueError:
26-
raise ParserError(f"Invalid value for '{name}' property, got '{value}'")
40+
raise ParserError(
41+
f"Invalid value for '{name}' property, got '{value}'"
42+
) from None
2743

2844
custom_attrs = {
2945
attr_name: cast_value(attr_info.name, data[attr_info.name], attr_info.cast)
@@ -34,8 +50,8 @@ def cast_value(name: str, value: Any, type_cast_func: Optional[Callable]) -> Any
3450
return custom_attrs
3551

3652

37-
def merge_schema(original: dict, other: dict) -> dict:
38-
"""Merge two schema dictionaries into single dict
53+
def merge_schema(original: dict[str, Any], other: dict[str, Any]) -> dict[str, Any]:
54+
"""Merge two schema dictionaries into single dict.
3955
4056
Args:
4157
original (dict): Source schema dictionary
@@ -49,30 +65,35 @@ def merge_schema(original: dict, other: dict) -> dict:
4965
for key, value in other.items():
5066
if key not in source:
5167
source[key] = value
52-
else:
53-
if isinstance(value, list):
68+
elif isinstance(value, list):
69+
if isinstance(source[key], list):
5470
source[key].extend(value)
55-
elif isinstance(value, dict):
71+
else:
72+
source[key] = value
73+
elif isinstance(value, dict):
74+
if isinstance(source[key], dict):
5675
source[key] = merge_schema(source[key], value)
5776
else:
5877
source[key] = value
78+
else:
79+
source[key] = value
5980

6081
return source
6182

6283

63-
def extract_extension_attributes(schema: dict) -> dict:
64-
"""Extract custom 'x-*' attributes from schema dictionary
84+
def extract_extension_attributes(schema: dict[str, Any]) -> dict[str, Any]:
85+
"""Extract custom 'x-*' attributes from schema dictionary.
6586
6687
Args:
6788
schema (dict): Schema dictionary
6889
6990
Returns:
7091
dict: Dictionary with parsed attributes w/o 'x-' prefix
7192
"""
72-
extension_key_format = 'x-'
93+
extension_key_format = "x-"
7394

74-
extensions_dict: dict = {
75-
key.replace(extension_key_format, '').replace('-', '_'): value
95+
extensions_dict: dict[str, Any] = {
96+
key.replace(extension_key_format, "").replace("-", "_"): value
7697
for key, value in schema.items()
7798
if key.startswith(extension_key_format)
7899
}
Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,62 @@
1+
"""Content builder for OpenAPI request/response bodies."""
2+
13
import logging
2-
from typing import Type, Union, Any
4+
from typing import Any
35

4-
from .schema import SchemaFactory
5-
from ..enumeration import ContentType
6-
from ..loose_types import LooseContentType
7-
from ..specification import Content
6+
from openapi_parser.builders.schema import SchemaFactory
7+
from openapi_parser.enumeration import ContentType
8+
from openapi_parser.loose_types import LooseContentType
9+
from openapi_parser.specification import Content
810

911
logger = logging.getLogger(__name__)
1012

11-
ContentTypeType = Union[Type[ContentType], Type[LooseContentType]]
13+
ContentTypeType = type[ContentType] | type[LooseContentType]
1214

1315

1416
class ContentBuilder:
15-
schema_factory: SchemaFactory
16-
strict_enum: bool
17+
"""Builds content objects for request/response bodies."""
18+
19+
_schema_factory: SchemaFactory
20+
_strict_enum: bool
1721

1822
def __init__(self, schema_factory: SchemaFactory, strict_enum: bool = True) -> None:
19-
self.schema_factory = schema_factory
20-
self.strict_enum = strict_enum
23+
"""Initialize content builder.
24+
25+
Args:
26+
schema_factory: Factory for creating schema objects
27+
strict_enum: Whether to validate enums strictly
28+
"""
29+
self._schema_factory = schema_factory
30+
self._strict_enum = strict_enum
2131

22-
def build_list(self, data: dict) -> list[Content]:
32+
def build_list(self, data: dict[str, Any]) -> list[Content]:
33+
"""Build a list of content objects from a dict of media types."""
2334
return [
24-
self._create_content(content_type, content_value.get('schema', {}),
25-
content_value.get('example', None),
26-
content_value.get('examples', {}))
27-
for content_type, content_value
28-
in data.items()
35+
self._create_content(
36+
content_type,
37+
content_value.get("schema", {}),
38+
content_value.get("example", None),
39+
content_value.get("examples", {}),
40+
)
41+
for content_type, content_value in data.items()
2942
]
3043

31-
def _create_content(self, content_type: str, schema: dict, example: Any, examples: dict) -> Content:
44+
def _create_content(
45+
self,
46+
content_type: str,
47+
schema: dict[str, Any],
48+
example: Any,
49+
examples: dict[str, Any],
50+
) -> Content:
3251
logger.debug(f"Content building [type={content_type}]")
33-
ContentTypeCls: ContentTypeType = ContentType if self.strict_enum else LooseContentType
52+
53+
ContentTypeCls: ContentTypeType = (
54+
ContentType if self._strict_enum else LooseContentType
55+
)
56+
3457
return Content(
3558
type=ContentTypeCls(content_type),
36-
schema=self.schema_factory.create(schema),
59+
schema=self._schema_factory.create(schema),
3760
example=example,
38-
examples=examples
61+
examples=examples,
3962
)
Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,37 @@
1+
"""External documentation builder."""
2+
13
import logging
4+
from typing import Any
25

3-
from .common import extract_extension_attributes
4-
from ..specification import ExternalDoc
6+
from openapi_parser.builders.common import extract_extension_attributes
7+
from openapi_parser.errors import ParserError
8+
from openapi_parser.specification import ExternalDoc
59

610
logger = logging.getLogger(__name__)
711

812

913
class ExternalDocBuilder:
14+
"""Builds external documentation objects."""
15+
1016
@staticmethod
11-
def build(data: dict) -> ExternalDoc:
12-
logger.debug(f"External doc parsing: {data['url']}")
17+
def build(data: dict[str, Any]) -> ExternalDoc:
18+
"""Build an ExternalDoc from a raw dict."""
19+
url = data.get("url")
20+
21+
if url is None:
22+
raise ParserError(
23+
"External documentation is missing required 'url' property"
24+
)
25+
26+
logger.debug(f"External doc parsing: {url}")
1327

1428
attrs = {
15-
"url": data['url'],
16-
"description": data.get('description'),
29+
"url": url,
30+
"description": data.get("description"),
1731
"extensions": extract_extension_attributes(data),
1832
}
1933

20-
if attrs['extensions']:
34+
if attrs["extensions"]:
2135
logger.debug(f"Extracted custom properties [{attrs['extensions'].keys()}]")
2236

2337
return ExternalDoc(**attrs)
Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,55 @@
1+
"""Header builder for response headers."""
2+
13
import logging
4+
from typing import Any
25

3-
from .common import extract_typed_props, PropertyMeta, extract_extension_attributes
4-
from .schema import SchemaFactory
5-
from ..specification import Header
6+
from openapi_parser.builders.common import (
7+
PropertyMeta,
8+
extract_extension_attributes,
9+
extract_typed_props,
10+
)
11+
from openapi_parser.builders.schema import SchemaFactory
12+
from openapi_parser.specification import Header
613

714
logger = logging.getLogger(__name__)
815

916

1017
class HeaderBuilder:
11-
schema_factory: SchemaFactory
18+
"""Builds header objects from raw specification data."""
19+
20+
_schema_factory: SchemaFactory
1221

1322
def __init__(self, schema_factory: SchemaFactory) -> None:
14-
self.schema_factory = schema_factory
23+
"""Initialize header builder.
24+
25+
Args:
26+
schema_factory: Factory for creating schema objects
27+
"""
28+
self._schema_factory = schema_factory
1529

16-
def build_list(self, data: dict) -> list[Header]:
30+
def build_list(self, data: dict[str, Any]) -> list[Header]:
31+
"""Build a list of headers from a dict of header definitions."""
1732
return [
1833
self._build(header_name, header_value)
19-
for header_name, header_value
20-
in data.items()
34+
for header_name, header_value in data.items()
2135
]
2236

23-
def _build(self, name: str, data: dict) -> Header:
37+
def _build(self, name: str, data: dict[str, Any]) -> Header:
2438
logger.debug(f"Header parsing: {name}")
2539

2640
attrs_map = {
27-
"schema": PropertyMeta(name="schema", cast=self.schema_factory.create),
41+
"schema": PropertyMeta(name="schema", cast=self._schema_factory.create),
2842
"description": PropertyMeta(name="description", cast=str),
29-
"deprecated": PropertyMeta(name="deprecated", cast=None),
30-
"required": PropertyMeta(name="required", cast=None),
43+
"deprecated": PropertyMeta(name="deprecated", cast=bool),
44+
"required": PropertyMeta(name="required", cast=bool),
3145
}
3246

3347
attrs = extract_typed_props(data, attrs_map)
3448

35-
attrs['name'] = name
49+
attrs["name"] = name
3650
attrs["extensions"] = extract_extension_attributes(data)
3751

38-
if attrs['extensions']:
52+
if attrs["extensions"]:
3953
logger.debug(f"Extracted custom properties [{attrs['extensions'].keys()}]")
4054

4155
return Header(**attrs)

0 commit comments

Comments
 (0)