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
20 changes: 17 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,39 @@
name: Release
on:
workflow_dispatch:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-release-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: write
id-token: write
steps:
- name: checkout
uses: actions/checkout@v6
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: force release branch to workflow sha
run: git reset --hard ${{ github.sha }}
- name: install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.12.3"
python-version: "3.10"
enable-cache: true
- name: dependencies
run: uv sync --locked --dev
- name: release
run: uv run semantic-release publish
- name: semantic release
id: release
run: uv run semantic-release -v version
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_COMMIT_AUTHOR: "manchenkoff <artem@manchenkoff.me>"
- name: publish to pypi
if: steps.release.outputs.released == 'true'
run: uv publish
85 changes: 0 additions & 85 deletions docs/recipes.md

This file was deleted.

46 changes: 26 additions & 20 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ classifiers = [
"Topic :: Software Development :: Libraries",
]
dependencies = [
"openapi-spec-validator>=0.8.5",
"prance>=25.4.8.0",
"pydantic>=2.0",
"referencing>=0.35",
"PyYAML>=6.0",
]

[project.urls]
Expand All @@ -35,9 +36,10 @@ dev = [
"mypy>=2.1.0",
"pytest>=9.0.3",
"pytest-cov>=6.1.0",
"python-semantic-release>=10.5.3",
"python-semantic-release>=10.6.1,<11",
"ruff>=0.15.13",
"ty>=0.0.37",
"types-pyyaml>=6.0.12.20260518",
]

[build-system]
Expand All @@ -49,33 +51,17 @@ module-name = "openapi_parser"

[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
branch = "main"
build_command = """
uv lock --upgrade-package "$PACKAGE_NAME"
git add uv.lock
uv build
"""
upload_to_pypi = true
upload_to_release = true
remove_dist = true

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true

[tool.mypy]
python_version = "3.10"
strict = true
mypy_path = ["src"]

[[tool.mypy.overrides]]
module = "tests.*"

[[tool.mypy.overrides]]
module = "prance"
ignore_missing_imports = true
plugins = ["pydantic.mypy"]

[tool.ruff.lint]
extend-select = [
Expand All @@ -87,14 +73,34 @@ extend-select = [
"ARG", # unused arguments
"RUF100", # unused noqa
"TID252", # ban relative imports
"PLR0915", # too-many-statements
]

[tool.ruff.lint.pylint]
max-statements = 30

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "ARG"]

[tool.ty]
# Tests use Pydantic aliases (e.g. location= instead of in=, not_schema=
# instead of not=) which ty does not resolve through Field(alias=) +
# populate_by_name=True correctly. These are false positives from the
# Pydantic-to-ty impedance mismatch.
[[tool.ty.overrides]]
include = ["tests/test_parse/**"]
[tool.ty.overrides.rules]
"unknown-argument" = "ignore"
"missing-argument" = "ignore"
"invalid-argument-type" = "ignore"

[tool.pytest.ini_options]
log_cli = true
log_cli_level = "WARNING"

[tool.coverage.run]
source = ["openapi_parser"]
omit = ["tests/*"]
119 changes: 66 additions & 53 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openapi3-parser)](https://pypi.org/project/openapi3-parser/)
[![PyPI - Format](https://img.shields.io/pypi/format/openapi3-parser)](https://pypi.org/project/openapi3-parser/)

Parse OpenAPI 3 documents into fully typed Python dataclass objects.
Parse OpenAPI and Swagger documents into fully typed Pydantic models.
Navigate your API specification programmatically — servers, paths,
operations, parameters, schemas, security schemes, and more.

| Version | Status |
| ------- | -------------- |
| 2.0 | Deprecated |
| 3.0 | **Supported** |
| 3.1 | In development |
| 2.0 | Supported* |
| 3.0 | Supported |
| 3.1 | Supported |
| 3.2 | Supported |

\* Swagger 2.0 documents are normalized to OpenAPI 3.0.

## Installation

Expand Down Expand Up @@ -49,6 +52,12 @@ info:
version: "1.0.0"
paths: {}
""")

# From raw string with external $refs (resolved relative to base_uri)
spec = parse(
spec_string=open("specs/openapi.yml").read(),
base_uri="file:///abs/path/specs/openapi.yml",
)
```

### Navigate servers, paths, and operations
Expand All @@ -60,35 +69,36 @@ specification = parse("swagger.yml")
for server in specification.servers:
print(f"{server.description} - {server.url}")

# List all paths and their HTTP methods
for path in specification.paths:
methods = ", ".join(op.method.value for op in path.operations)
print(f"{path.url}: [{methods}]")
# Iterate paths and their HTTP methods
for path, path_item in specification.paths.items():
methods = ", ".join(
method for method in ("get", "put", "post", "delete", "patch")
if getattr(path_item, method) is not None
)
print(f"{path}: [{methods}]")

# Inspect operation details
for path in specification.paths:
for op in path.operations:
print(f"[{op.method.value}] {path.url}: {op.summary}")
if op.deprecated:
print(" (deprecated)")
if op.operation_id:
print(f" operationId: {op.operation_id}")
for path_item in specification.paths.values():
get_op = path_item.get
if get_op is None:
continue
print(f"[GET] {path}: {get_op.summary}")
if get_op.deprecated:
print(" (deprecated)")
if get_op.operation_id:
print(f" operationId: {get_op.operation_id}")
```

### Enum strictness
### Follow `$ref` references

By default, content types, string formats, and other enum fields are validated
against predefined enums. For specs that use custom values, pass
`strict_enum=False`:
`$ref` entries are resolved in place and annotated with a `ref_name`
pointing back to their canonical location:

```python
# Accepts non-standard content types like "application/vnd.api+json"
spec = parse("swagger.yml", strict_enum=False)
schema = specification.components.schemas["Pet"]
print(schema.ref_name) # "#/components/schemas/Pet"
```

When strict mode is off, unrecognized values are wrapped in a `LooseEnum`
object instead of raising an error.

### Error Handling

```python
Expand All @@ -98,36 +108,39 @@ try:
spec = parse("invalid.yml")
except ParserError as e:
print(f"Parsing failed: {e}")
for detail in e.errors():
print(detail["loc"], detail["msg"])
```

## Data Model

Parsed documents return a `Specification` object composed of fully typed
dataclasses:

| Model | Description |
| --------------- | ----------- |
| `Specification` | Root document — version, info, servers, paths, schemas, security |
| `Info` | API metadata — title, version, description, contact, license |
| `Server` | Server definition — url, description, variables |
| `Path` | URL path — operations, parameters |
| `Operation` | HTTP method — responses, parameters, request body, security |
| `Parameter` | Path/query/header/cookie param — schema, style, required |
| `Response` | Status code, description, content, headers |
| `RequestBody` | Content, description, required |
| `Content` | Media type, schema, example |
| `Schema` | Base type — Integer, Number, String, Boolean, Array, Object, Null |
| `Property` | Object property — name, schema |
| `OneOf`/`AnyOf` | Composition schemas with discriminator support |
| `Security` | Security scheme — apiKey, http, oauth2, openIdConnect |
| `OAuthFlow` | OAuth flow — authorization, token, scopes |
| `Header` | Response header — name, schema, description |
| `Tag` | Tag with optional external docs |
| `ExternalDoc` | External documentation reference |
| `Discriminator` | Polymorphism discriminator — property name, mapping |

See the [specification module](src/openapi_parser/specification.py) for
all available fields and types.
Pydantic models:

| Model | Description |
| ---------------- | ----------- |
| `Specification` | Root document — openapi, info, servers, paths, components, security |
| `Info` | API metadata — title, version, description, contact, license |
| `Server` | Server definition — url, description, variables |
| `PathItem` | URL path — get/post/put/delete/patch, parameters, servers |
| `Operation` | HTTP method — responses, parameters, request body, security |
| `Parameter` | Path/query/header/cookie param — schema, style, required |
| `Response` | Status code, description, content, headers |
| `RequestBody` | Content, description, required |
| `MediaType` | Media type — schema, example, encoding |
| `Schema` | Data definition — type, properties, items, composition |
| `Components` | Reusable schemas, responses, parameters, examples, headers, ... |
| `SecurityScheme` | Security scheme — apiKey, http, oauth2, openIdConnect, mutualTLS |
| `OAuthFlow` | OAuth flow — authorization, token, scopes |
| `Header` | Response header — name, schema, description |
| `Link` | Link definition — operation, parameters, request body |
| `Example` | Example — value, summary, externalValue |
| `Tag` | Tag with optional external docs |
| `ExternalDoc` | External documentation reference |
| `Discriminator` | Polymorphism discriminator — property name, mapping |

See the [models](src/openapi_parser/models/) package for all available
fields and types.

## Development

Expand All @@ -136,13 +149,13 @@ all available fields and types.
uv sync --dev

# Lint
uv run ruff check .
uv run mypy .
uv run ty check .
uv run ruff check src/ tests/
uv run mypy src/ tests/
uv run ty check

# Test
uv run pytest

# Format
uv run ruff format .
uv run ruff format src/ tests/
```
Loading
Loading