protodantic-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- protodantic_py-0.1.0/.github/workflows/ci.yml +32 -0
- protodantic_py-0.1.0/.github/workflows/release.yml +66 -0
- protodantic_py-0.1.0/.gitignore +31 -0
- protodantic_py-0.1.0/AGENTS.md +57 -0
- protodantic_py-0.1.0/CHANGELOG.md +30 -0
- protodantic_py-0.1.0/CLAUDE.md +1 -0
- protodantic_py-0.1.0/LICENSE +21 -0
- protodantic_py-0.1.0/PKG-INFO +155 -0
- protodantic_py-0.1.0/README.md +126 -0
- protodantic_py-0.1.0/pyproject.toml +54 -0
- protodantic_py-0.1.0/src/protodantic/__init__.py +25 -0
- protodantic_py-0.1.0/src/protodantic/_version.py +1 -0
- protodantic_py-0.1.0/src/protodantic/cli.py +42 -0
- protodantic_py-0.1.0/src/protodantic/codegen.py +310 -0
- protodantic_py-0.1.0/src/protodantic/compiler.py +39 -0
- protodantic_py-0.1.0/src/protodantic/py.typed +0 -0
- protodantic_py-0.1.0/src/protodantic/runtime.py +345 -0
- protodantic_py-0.1.0/src/protodantic/types.py +59 -0
- protodantic_py-0.1.0/tests/conftest.py +44 -0
- protodantic_py-0.1.0/tests/protos/anypayload.proto +14 -0
- protodantic_py-0.1.0/tests/protos/collision_a.proto +11 -0
- protodantic_py-0.1.0/tests/protos/collision_b.proto +11 -0
- protodantic_py-0.1.0/tests/protos/common.proto +8 -0
- protodantic_py-0.1.0/tests/protos/demo.proto +44 -0
- protodantic_py-0.1.0/tests/protos/enums.proto +17 -0
- protodantic_py-0.1.0/tests/protos/hostile_names.proto +33 -0
- protodantic_py-0.1.0/tests/protos/legacy.proto +9 -0
- protodantic_py-0.1.0/tests/protos/naming.proto +12 -0
- protodantic_py-0.1.0/tests/protos/nesting.proto +14 -0
- protodantic_py-0.1.0/tests/protos/orders.proto +11 -0
- protodantic_py-0.1.0/tests/protos/presence.proto +21 -0
- protodantic_py-0.1.0/tests/protos/recursion.proto +20 -0
- protodantic_py-0.1.0/tests/protos/scalars.proto +26 -0
- protodantic_py-0.1.0/tests/protos/shadowing.proto +18 -0
- protodantic_py-0.1.0/tests/protos/structs.proto +11 -0
- protodantic_py-0.1.0/tests/protos/wire.proto +19 -0
- protodantic_py-0.1.0/tests/protos/wkt.proto +13 -0
- protodantic_py-0.1.0/tests/protos/wrappers.proto +13 -0
- protodantic_py-0.1.0/tests/test_cli.py +99 -0
- protodantic_py-0.1.0/tests/test_codegen_output.py +90 -0
- protodantic_py-0.1.0/tests/test_enums.py +46 -0
- protodantic_py-0.1.0/tests/test_ergonomics.py +84 -0
- protodantic_py-0.1.0/tests/test_multifile.py +42 -0
- protodantic_py-0.1.0/tests/test_naming.py +94 -0
- protodantic_py-0.1.0/tests/test_nesting.py +13 -0
- protodantic_py-0.1.0/tests/test_pb2_interop.py +68 -0
- protodantic_py-0.1.0/tests/test_presence.py +109 -0
- protodantic_py-0.1.0/tests/test_proto2.py +11 -0
- protodantic_py-0.1.0/tests/test_recursion.py +30 -0
- protodantic_py-0.1.0/tests/test_registry.py +86 -0
- protodantic_py-0.1.0/tests/test_roundtrip.py +100 -0
- protodantic_py-0.1.0/tests/test_scalars.py +119 -0
- protodantic_py-0.1.0/tests/test_wire_compat.py +45 -0
- protodantic_py-0.1.0/tests/test_wkt.py +165 -0
- protodantic_py-0.1.0/uv.lock +377 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [master, development]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
os: [ubuntu-latest, windows-latest]
|
|
17
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
18
|
+
runs-on: ${{ matrix.os }}
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Install uv
|
|
23
|
+
uses: astral-sh/setup-uv@v6
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync
|
|
30
|
+
|
|
31
|
+
- name: Run tests
|
|
32
|
+
run: uv run pytest tests/ -v
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
outputs:
|
|
14
|
+
version: ${{ steps.version.outputs.version }}
|
|
15
|
+
unpublished: ${{ steps.version.outputs.unpublished }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.11"
|
|
23
|
+
|
|
24
|
+
- name: Run tests
|
|
25
|
+
run: |
|
|
26
|
+
uv sync
|
|
27
|
+
uv run pytest tests/
|
|
28
|
+
|
|
29
|
+
- name: Check version against PyPI
|
|
30
|
+
id: version
|
|
31
|
+
run: |
|
|
32
|
+
VERSION=$(uv run python -c "from protodantic._version import __version__; print(__version__)")
|
|
33
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
34
|
+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/protodantic-py/$VERSION/json")
|
|
35
|
+
if [ "$STATUS" = "404" ]; then
|
|
36
|
+
echo "unpublished=true" >> "$GITHUB_OUTPUT"
|
|
37
|
+
else
|
|
38
|
+
echo "unpublished=false" >> "$GITHUB_OUTPUT"
|
|
39
|
+
fi
|
|
40
|
+
echo "version=$VERSION pypi_status=$STATUS"
|
|
41
|
+
|
|
42
|
+
- name: Build distributions
|
|
43
|
+
run: uv build
|
|
44
|
+
|
|
45
|
+
- uses: actions/upload-artifact@v4
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
|
|
50
|
+
publish:
|
|
51
|
+
needs: build
|
|
52
|
+
# publish only when the version in _version.py is not on PyPI yet:
|
|
53
|
+
# merging to master releases iff the PR bumped the version
|
|
54
|
+
if: needs.build.outputs.unpublished == 'true'
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
environment: pypi
|
|
57
|
+
permissions:
|
|
58
|
+
id-token: write # PyPI Trusted Publishing (OIDC)
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/download-artifact@v4
|
|
61
|
+
with:
|
|
62
|
+
name: dist
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
- name: Publish to PyPI
|
|
66
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Agents
|
|
2
|
+
.agent/
|
|
3
|
+
.codex/
|
|
4
|
+
.claude/
|
|
5
|
+
|
|
6
|
+
# IDE
|
|
7
|
+
.idea/
|
|
8
|
+
|
|
9
|
+
# Playgrounds
|
|
10
|
+
playground.py
|
|
11
|
+
|
|
12
|
+
# Env
|
|
13
|
+
*.env
|
|
14
|
+
|
|
15
|
+
# Python
|
|
16
|
+
**/__pycache__/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
*$py.class
|
|
19
|
+
.venv/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
.ruff_cache/
|
|
23
|
+
.coverage
|
|
24
|
+
.coverage.*
|
|
25
|
+
htmlcov/
|
|
26
|
+
dist/
|
|
27
|
+
build/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
|
|
30
|
+
# IDE (editors beyond .idea)
|
|
31
|
+
.vscode/
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# protodantic — agent guide
|
|
2
|
+
|
|
3
|
+
Bidirectional bridge between Protocol Buffers and Pydantic. Distribution name `protodantic-py`, import name `protodantic`. proto3 only, by design. Read this before changing anything: the project runs on a small set of hard conventions, and code review enforces them strictly — working code that violates them gets rejected.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/protodantic/
|
|
9
|
+
compiler.py .proto files -> serialized FileDescriptorSet bytes (protoc via grpcio-tools)
|
|
10
|
+
codegen.py fdset bytes -> python module source (_ModuleGenerator class)
|
|
11
|
+
runtime.py ProtoModel base: conversion both ways, registries, OpenEnum, field-name escaping
|
|
12
|
+
types.py range-validated ints, Struct/Value/ListValue aliases, NULL sentinel
|
|
13
|
+
cli.py click group; `generate` subcommand (future verbs must be additive)
|
|
14
|
+
_version.py single version source (hatch + generated-code stamps read it)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The **fdset-bytes boundary is load-bearing**: codegen takes `FileDescriptorSet` bytes and nothing else. Future input sources (installed `_pb2` packages via descriptor reflection, planned for 0.2) must produce those bytes and feed the same codegen. Do not add codegen inputs that bypass it.
|
|
18
|
+
|
|
19
|
+
## Philosophy (non-negotiable)
|
|
20
|
+
|
|
21
|
+
1. **The test suite is the specification.** Every supported behavior exists as a use-case test with a "USE CASE" style docstring. Workflow is red/green: write the failing spec test first, run it to confirm it fails *for the intended reason*, then implement, then confirm green. A red test in the suite is an accepted roadmap item, not a broken build.
|
|
22
|
+
2. **Fail loudly; no magic.** proto2 input → `NotImplementedError`. Name collisions (flattened types, escaped enum members) → `ValueError` naming the culprits and telling the user what to rename. Wrong message type in `from_proto` → `TypeError`. Unknown `Any` type URL → `LookupError`. Unknown field names at construction → rejected (`extra="forbid"`). Never emit silently-wrong data or auto-disambiguate with positional suffixes. The only automatic renaming allowed is the deterministic trailing-underscore escape (`class` → `class_`) — a pure function of the name alone, never dependent on what else exists in the schema.
|
|
23
|
+
3. **Validation is the product.** Models validate at construction *and* on assignment, and assignment is atomic: a rejected mutation must leave the model exactly as it was. If you add a validator, prove atomicity in a test.
|
|
24
|
+
4. **Scope decisions belong to the maintainer.** If "should we support X?" has no clear answer in the tests or this file, raise it as a question before implementing. Precedents: proto2 was consciously dropped; `Any` consciously maps to `typing.Any` with registry pack/unpack.
|
|
25
|
+
|
|
26
|
+
## Semantics ledger (do not regress; each line is pinned by tests)
|
|
27
|
+
|
|
28
|
+
- `None` means *unset on the wire*; explicit zero values keep their presence bit. `protodantic.NULL` (identity-checked singleton) means explicit JSON null in `Value` fields.
|
|
29
|
+
- proto3 enums are open: unknown wire values become `OpenEnum` pseudo-members, never errors, and re-serialize byte-identically.
|
|
30
|
+
- Unknown fields are dropped when a model re-serializes (the model is the source of truth). Naive datetimes are interpreted as UTC.
|
|
31
|
+
- Generated modules are deterministic (byte-identical output for the same input — they're meant to be committed and diffed), version-stamped, and depend only on `protodantic` + pydantic + protobuf at runtime (never `grpc_tools`). All imports in generated code are underscore-aliased (`import protodantic as _pd`, `import typing as _typing`, ...) so user message names (`message Any`, `message list`) cannot shadow them.
|
|
32
|
+
- Nested-message resolution is scoped per descriptor pool (per generated module), so duplicate generated modules coexist in one process. `model_for()` is global, last-import-wins. Plain subclasses of generated models do **not** register; re-declaring `__proto_full_name__` in the subclass body is the explicit opt-in.
|
|
33
|
+
- `_pb2` interop is a public contract: `from_proto()` accepts classic protoc-generated instances; `to_proto_bytes()` output parses into `_pb2` classes.
|
|
34
|
+
|
|
35
|
+
## Code conventions
|
|
36
|
+
|
|
37
|
+
- **Keyword-only parameters for all internal functions** (`def _fill_map(*, target, fd, value)`). Positional args are reserved for framework-fixed signatures (pydantic serializer callbacks) and stdlib-stable idioms — if a signature could ever grow a flag, it takes kwargs.
|
|
38
|
+
- **No nested function definitions.** State + recursion live in classes (see `_ModuleGenerator`); pure helpers go to module level.
|
|
39
|
+
- **Relative imports** inside the package. Generated code imports only the top-level `protodantic`.
|
|
40
|
+
- **No trivial docstrings or comments.** Comment only the non-obvious "why" (e.g. why registration checks `cls.__dict__`). Public functions get short contract docstrings.
|
|
41
|
+
- Magic strings for well-known types are forbidden — derive full names from the shipped descriptors (`timestamp_pb2.Timestamp.DESCRIPTOR.full_name`).
|
|
42
|
+
- CLI output goes through click (`click.echo`, `click.ClickException`), never bare `print`.
|
|
43
|
+
|
|
44
|
+
## Workflow
|
|
45
|
+
|
|
46
|
+
- uv-managed: `uv sync`, then `uv run pytest tests/ -q`.
|
|
47
|
+
- Python floor is 3.11. When touching `runtime.py`/`codegen.py`, run both: `uv run pytest tests/` and `uv run --python 3.11 --isolated pytest tests/`. CI runs ubuntu+windows × 3.11/3.12/3.13; all six must be green.
|
|
48
|
+
- Releases happen on merge to `master`: the workflow publishes to PyPI iff `_version.py` holds a version not yet published. A releasing PR bumps `_version.py` **and** moves `CHANGELOG.md` `[Unreleased]` entries under the new version heading, atomically.
|
|
49
|
+
- Update `CHANGELOG.md` `[Unreleased]` for any user-facing change (Keep a Changelog format).
|
|
50
|
+
- Never commit generated artifacts, secrets, or machine-local paths. Test protos live in `tests/protos/`; temporary schemas belong in `tmp_path`.
|
|
51
|
+
|
|
52
|
+
## Roadmap context (shapes what "don't block the future" means)
|
|
53
|
+
|
|
54
|
+
- **0.1.0 (current)** — greenfield: `.proto` → pydantic with lossless bidirectional round-trips.
|
|
55
|
+
- **0.2.0 — brownfield**: reverse schema codegen (pydantic models → `.proto`), generating from installed `_pb2` packages by descriptor reflection, `to_proto(into=TheirPb2Class)`. Weigh brownfield adoption at least as high as greenfield polish.
|
|
56
|
+
- **0.3.0 — performance**: benchmark suite first (vs `json.loads`+pydantic, raw `_pb2`, betterproto) — **benchmarks before perf claims** — then cached field plans and trusted-construction fast paths. The conversion internals are deliberately unconstrained by public API; keep it that way.
|
|
57
|
+
- gRPC service stubs are permanently out of scope: protodantic is a message layer.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
Initial release: the greenfield drop.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `protodantic generate` CLI and `compile_fdset()`/`generate_source()` API: compile `.proto` files (proto3) into pydantic v2 models with embedded descriptors — no `_pb2.py` files needed.
|
|
17
|
+
- Lossless bidirectional round-trips on every generated model: `to_proto()`, `to_proto_bytes()`, `to_proto_json()`, `from_proto()`, `from_proto_bytes()`, `from_proto_json()` — the wire output is canonical protobuf, parseable by any runtime in any language.
|
|
18
|
+
- Interop with classic protoc-generated `_pb2` classes: `from_proto()` accepts their instances directly.
|
|
19
|
+
- Validation at the boundary: proto integer range constraints, oneof mutual exclusion (construction *and* mutation, atomically), `extra="forbid"`, all overridable via standard pydantic config.
|
|
20
|
+
- Presence semantics: `None` ⇄ unset for `optional` fields, oneof members, and singular messages; explicit zero values keep their presence bit.
|
|
21
|
+
- Open enums (`OpenEnum`): unknown wire values are preserved as pseudo-members instead of raising, matching proto3 semantics.
|
|
22
|
+
- Well-known types: `Timestamp` ⇄ `datetime` (UTC), `Duration` ⇄ `timedelta`, wrappers ⇄ `T | None`, `Struct`/`Value`/`ListValue` ⇄ plain python data with the `protodantic.NULL` sentinel for explicit JSON null, `Any` ⇄ any generated model via registry-based pack/unpack.
|
|
23
|
+
- Field-name hazard handling: python keywords and pydantic-reserved names get a trailing underscore with the proto name as populate alias; generated imports are shadow-proof against user message names.
|
|
24
|
+
- `model_for("pkg.Message")` lookup; duplicate generated modules coexist safely (nested resolution is scoped per generated module).
|
|
25
|
+
- Fail-loud guarantees: proto2 input, flattened-name collisions, unrelated message types in `from_proto()`, and unknown `Any` type URLs all raise clear errors — no silent wrong data.
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
|
|
29
|
+
- Distribution name is `protodantic-py`; the import name is `protodantic`.
|
|
30
|
+
- Requires Python >= 3.11. proto3 only, by design. gRPC service stubs are out of scope.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 My Koryto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: protodantic-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bidirectional bridge between Protocol Buffers and Pydantic: generate pydantic models from .proto files with lossless round-trips to proto messages and wire bytes
|
|
5
|
+
Project-URL: Repository, https://github.com/Koryto/protodantic
|
|
6
|
+
Project-URL: Issues, https://github.com/Koryto/protodantic/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/Koryto/protodantic/blob/master/CHANGELOG.md
|
|
8
|
+
Author-email: My Koryto <mykoryto93@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: codegen,grpc,proto3,protobuf,pydantic,serialization
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pydantic
|
|
14
|
+
Classifier: Framework :: Pydantic :: 2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: click>=8.2
|
|
25
|
+
Requires-Dist: grpcio-tools>=1.66
|
|
26
|
+
Requires-Dist: protobuf>=6.30
|
|
27
|
+
Requires-Dist: pydantic>=2.7
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# protodantic
|
|
31
|
+
|
|
32
|
+
[](https://github.com/Koryto/protodantic/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/protodantic-py/)
|
|
34
|
+
[](https://pypi.org/project/protodantic-py/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
Bidirectional bridge between **Protocol Buffers** and **Pydantic**.
|
|
38
|
+
|
|
39
|
+
Point it at your `.proto` files and it generates plain pydantic v2 models — with full validation — where every model round-trips losslessly to and from real protobuf messages, wire bytes, and proto JSON. The pydantic → proto direction is a first-class citizen: `to_proto_bytes()` produces genuine wire-format output that any protobuf consumer in any language can parse.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uv add protodantic-py # or: pip install protodantic-py
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The distribution is named `protodantic-py` (the plain name is squatted on PyPI); the import stays `protodantic`:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import protodantic
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Given `demo.proto`:
|
|
56
|
+
|
|
57
|
+
```proto
|
|
58
|
+
syntax = "proto3";
|
|
59
|
+
package demo;
|
|
60
|
+
|
|
61
|
+
message Address {
|
|
62
|
+
string street = 1;
|
|
63
|
+
string city = 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
message User {
|
|
67
|
+
int64 id = 1;
|
|
68
|
+
string name = 2;
|
|
69
|
+
Address address = 3;
|
|
70
|
+
repeated string tags = 4;
|
|
71
|
+
optional string nickname = 5;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Generate models:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
protodantic generate demo.proto -o models.py
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from models import User, Address
|
|
85
|
+
|
|
86
|
+
user = User(id=7, name="kory", address=Address(city="Warsaw"), tags=["a", "b"])
|
|
87
|
+
|
|
88
|
+
# pydantic -> proto: real wire format, readable by any protobuf runtime
|
|
89
|
+
data: bytes = user.to_proto_bytes()
|
|
90
|
+
msg = user.to_proto() # a live protobuf Message
|
|
91
|
+
json_str = user.to_proto_json() # canonical proto JSON
|
|
92
|
+
|
|
93
|
+
# proto -> pydantic: parse + validate in one step
|
|
94
|
+
restored = User.from_proto_bytes(data)
|
|
95
|
+
assert restored == user
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or drive it from Python:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from protodantic import compile_fdset, generate_source
|
|
102
|
+
|
|
103
|
+
source = generate_source(compile_fdset(["demo.proto"]))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Type mapping
|
|
107
|
+
|
|
108
|
+
| proto | pydantic |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `int32/64`, `uint32/64`, `sint`, `fixed` | range-validated `int` (out-of-range fails at construction) |
|
|
111
|
+
| `float`, `double` | `float` |
|
|
112
|
+
| `string` / `bytes` / `bool` | `str` / `bytes` / `bool` |
|
|
113
|
+
| `enum` | generated `OpenEnum` (`IntEnum` that preserves unknown wire values — proto3 enums are open) |
|
|
114
|
+
| `message` | generated `ProtoModel` (nested types flattened as `Outer_Inner`) |
|
|
115
|
+
| `repeated T` / `map<K, V>` | `list[T]` / `dict[K, V]` |
|
|
116
|
+
| `optional`, oneof members, singular messages | `T \| None` (presence-aware: `None` ⇄ unset) |
|
|
117
|
+
| oneof groups | mutual exclusion enforced by a model validator |
|
|
118
|
+
| `google.protobuf.Timestamp` | `datetime.datetime` (UTC; naive input treated as UTC) |
|
|
119
|
+
| `google.protobuf.Duration` | `datetime.timedelta` |
|
|
120
|
+
| `google.protobuf.*Value` wrappers | `T \| None` |
|
|
121
|
+
| `google.protobuf.Struct` / `Value` / `ListValue` | `dict[str, Any]` / `Any` / `list[Any]` |
|
|
122
|
+
| `google.protobuf.Any` | `typing.Any` — accepts any `ProtoModel`; packed/unpacked via the model registry |
|
|
123
|
+
|
|
124
|
+
Field names that collide with python keywords or pydantic internals (`class`, `from`, `model_config`, ...) get a trailing underscore (`class_`) with the proto name kept as a populate alias. The same rule applies to message/enum type names and enum members that are python keywords or would shadow generated code (`message list` → `class list_`) — the proto full name stays the source of truth. Same-named messages in different packages get package-qualified class names; every model is also reachable via `protodantic.model_for("pkg.Message")`.
|
|
125
|
+
|
|
126
|
+
Semantics worth knowing:
|
|
127
|
+
|
|
128
|
+
- **Validation on mutation is on by default** (`validate_assignment=True`): assigning a second oneof member or an out-of-range int raises immediately. Opt out per-model with standard pydantic config on a subclass.
|
|
129
|
+
- **`protodantic.NULL`** expresses an explicit JSON null in a `google.protobuf.Value` field (`None` means *unset*). In `model_dump_json()` it serializes as real `null`; python-mode dumps keep the sentinel.
|
|
130
|
+
- **Subclassing a generated model does not affect parsing**: `from_proto`/`model_for` keep resolving to the generated class. To make your subclass the resolution target (e.g. to add custom validators applied on parse), re-declare `__proto_full_name__` in its body — explicit opt-in.
|
|
131
|
+
|
|
132
|
+
## Interop with existing `_pb2` code
|
|
133
|
+
|
|
134
|
+
Already consuming a centralized proto package as protoc-generated `_pb2` modules? Generated models interoperate directly:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
user = User.from_proto(their_pb2_user_instance) # accepts _pb2 messages
|
|
138
|
+
their_msg = their_pb2.User.FromString(user.to_proto_bytes()) # canonical bytes
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## How it works
|
|
142
|
+
|
|
143
|
+
`protoc` (bundled via `grpcio-tools`) compiles your protos to a `FileDescriptorSet`, which codegen embeds in the generated module. At runtime, `ProtoModel` builds dynamic protobuf message classes from those descriptors — no `_pb2.py` files needed, and no protobuf internals leak into your models.
|
|
144
|
+
|
|
145
|
+
If several imported generated modules define the same proto type, the registry behind `model_for()` / nested-message resolution is last-import-wins.
|
|
146
|
+
|
|
147
|
+
## Status & roadmap
|
|
148
|
+
|
|
149
|
+
Requires Python ≥ 3.11. proto3 only by design (proto2 input is rejected with a clear error). The full supported-behavior spec lives in [tests/](tests/) — every test documents one use case. Documented policies: unknown fields are dropped when a model re-serializes (the model is the source of truth), and naive datetimes are interpreted as UTC.
|
|
150
|
+
|
|
151
|
+
- **0.1.0 (current)** — greenfield: `.proto` → pydantic codegen with lossless bidirectional round-trips, plus the semantics future drops build on.
|
|
152
|
+
- **0.2.0 — brownfield** — reverse schema codegen (pydantic models → `.proto`), generating from installed `_pb2` packages by descriptor reflection, `to_proto(into=TheirPb2Class)`.
|
|
153
|
+
- **0.3.0 — performance** — benchmark suite (vs `json.loads`+pydantic, raw `_pb2`, betterproto), then cached field plans and trusted-construction fast paths.
|
|
154
|
+
|
|
155
|
+
gRPC service stubs are out of scope: protodantic is a message layer.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# protodantic
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Koryto/protodantic/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/protodantic-py/)
|
|
5
|
+
[](https://pypi.org/project/protodantic-py/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Bidirectional bridge between **Protocol Buffers** and **Pydantic**.
|
|
9
|
+
|
|
10
|
+
Point it at your `.proto` files and it generates plain pydantic v2 models — with full validation — where every model round-trips losslessly to and from real protobuf messages, wire bytes, and proto JSON. The pydantic → proto direction is a first-class citizen: `to_proto_bytes()` produces genuine wire-format output that any protobuf consumer in any language can parse.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
uv add protodantic-py # or: pip install protodantic-py
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The distribution is named `protodantic-py` (the plain name is squatted on PyPI); the import stays `protodantic`:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import protodantic
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Given `demo.proto`:
|
|
27
|
+
|
|
28
|
+
```proto
|
|
29
|
+
syntax = "proto3";
|
|
30
|
+
package demo;
|
|
31
|
+
|
|
32
|
+
message Address {
|
|
33
|
+
string street = 1;
|
|
34
|
+
string city = 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
message User {
|
|
38
|
+
int64 id = 1;
|
|
39
|
+
string name = 2;
|
|
40
|
+
Address address = 3;
|
|
41
|
+
repeated string tags = 4;
|
|
42
|
+
optional string nickname = 5;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Generate models:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
protodantic generate demo.proto -o models.py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from models import User, Address
|
|
56
|
+
|
|
57
|
+
user = User(id=7, name="kory", address=Address(city="Warsaw"), tags=["a", "b"])
|
|
58
|
+
|
|
59
|
+
# pydantic -> proto: real wire format, readable by any protobuf runtime
|
|
60
|
+
data: bytes = user.to_proto_bytes()
|
|
61
|
+
msg = user.to_proto() # a live protobuf Message
|
|
62
|
+
json_str = user.to_proto_json() # canonical proto JSON
|
|
63
|
+
|
|
64
|
+
# proto -> pydantic: parse + validate in one step
|
|
65
|
+
restored = User.from_proto_bytes(data)
|
|
66
|
+
assert restored == user
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or drive it from Python:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from protodantic import compile_fdset, generate_source
|
|
73
|
+
|
|
74
|
+
source = generate_source(compile_fdset(["demo.proto"]))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Type mapping
|
|
78
|
+
|
|
79
|
+
| proto | pydantic |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `int32/64`, `uint32/64`, `sint`, `fixed` | range-validated `int` (out-of-range fails at construction) |
|
|
82
|
+
| `float`, `double` | `float` |
|
|
83
|
+
| `string` / `bytes` / `bool` | `str` / `bytes` / `bool` |
|
|
84
|
+
| `enum` | generated `OpenEnum` (`IntEnum` that preserves unknown wire values — proto3 enums are open) |
|
|
85
|
+
| `message` | generated `ProtoModel` (nested types flattened as `Outer_Inner`) |
|
|
86
|
+
| `repeated T` / `map<K, V>` | `list[T]` / `dict[K, V]` |
|
|
87
|
+
| `optional`, oneof members, singular messages | `T \| None` (presence-aware: `None` ⇄ unset) |
|
|
88
|
+
| oneof groups | mutual exclusion enforced by a model validator |
|
|
89
|
+
| `google.protobuf.Timestamp` | `datetime.datetime` (UTC; naive input treated as UTC) |
|
|
90
|
+
| `google.protobuf.Duration` | `datetime.timedelta` |
|
|
91
|
+
| `google.protobuf.*Value` wrappers | `T \| None` |
|
|
92
|
+
| `google.protobuf.Struct` / `Value` / `ListValue` | `dict[str, Any]` / `Any` / `list[Any]` |
|
|
93
|
+
| `google.protobuf.Any` | `typing.Any` — accepts any `ProtoModel`; packed/unpacked via the model registry |
|
|
94
|
+
|
|
95
|
+
Field names that collide with python keywords or pydantic internals (`class`, `from`, `model_config`, ...) get a trailing underscore (`class_`) with the proto name kept as a populate alias. The same rule applies to message/enum type names and enum members that are python keywords or would shadow generated code (`message list` → `class list_`) — the proto full name stays the source of truth. Same-named messages in different packages get package-qualified class names; every model is also reachable via `protodantic.model_for("pkg.Message")`.
|
|
96
|
+
|
|
97
|
+
Semantics worth knowing:
|
|
98
|
+
|
|
99
|
+
- **Validation on mutation is on by default** (`validate_assignment=True`): assigning a second oneof member or an out-of-range int raises immediately. Opt out per-model with standard pydantic config on a subclass.
|
|
100
|
+
- **`protodantic.NULL`** expresses an explicit JSON null in a `google.protobuf.Value` field (`None` means *unset*). In `model_dump_json()` it serializes as real `null`; python-mode dumps keep the sentinel.
|
|
101
|
+
- **Subclassing a generated model does not affect parsing**: `from_proto`/`model_for` keep resolving to the generated class. To make your subclass the resolution target (e.g. to add custom validators applied on parse), re-declare `__proto_full_name__` in its body — explicit opt-in.
|
|
102
|
+
|
|
103
|
+
## Interop with existing `_pb2` code
|
|
104
|
+
|
|
105
|
+
Already consuming a centralized proto package as protoc-generated `_pb2` modules? Generated models interoperate directly:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
user = User.from_proto(their_pb2_user_instance) # accepts _pb2 messages
|
|
109
|
+
their_msg = their_pb2.User.FromString(user.to_proto_bytes()) # canonical bytes
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
`protoc` (bundled via `grpcio-tools`) compiles your protos to a `FileDescriptorSet`, which codegen embeds in the generated module. At runtime, `ProtoModel` builds dynamic protobuf message classes from those descriptors — no `_pb2.py` files needed, and no protobuf internals leak into your models.
|
|
115
|
+
|
|
116
|
+
If several imported generated modules define the same proto type, the registry behind `model_for()` / nested-message resolution is last-import-wins.
|
|
117
|
+
|
|
118
|
+
## Status & roadmap
|
|
119
|
+
|
|
120
|
+
Requires Python ≥ 3.11. proto3 only by design (proto2 input is rejected with a clear error). The full supported-behavior spec lives in [tests/](tests/) — every test documents one use case. Documented policies: unknown fields are dropped when a model re-serializes (the model is the source of truth), and naive datetimes are interpreted as UTC.
|
|
121
|
+
|
|
122
|
+
- **0.1.0 (current)** — greenfield: `.proto` → pydantic codegen with lossless bidirectional round-trips, plus the semantics future drops build on.
|
|
123
|
+
- **0.2.0 — brownfield** — reverse schema codegen (pydantic models → `.proto`), generating from installed `_pb2` packages by descriptor reflection, `to_proto(into=TheirPb2Class)`.
|
|
124
|
+
- **0.3.0 — performance** — benchmark suite (vs `json.loads`+pydantic, raw `_pb2`, betterproto), then cached field plans and trusted-construction fast paths.
|
|
125
|
+
|
|
126
|
+
gRPC service stubs are out of scope: protodantic is a message layer.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "protodantic-py"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Bidirectional bridge between Protocol Buffers and Pydantic: generate pydantic models from .proto files with lossless round-trips to proto messages and wire bytes"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "My Koryto", email = "mykoryto93@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["protobuf", "pydantic", "codegen", "serialization", "grpc", "proto3"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Framework :: Pydantic",
|
|
16
|
+
"Framework :: Pydantic :: 2",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Code Generators",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"pydantic>=2.7",
|
|
28
|
+
"protobuf>=6.30",
|
|
29
|
+
"grpcio-tools>=1.66",
|
|
30
|
+
"click>=8.2",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Repository = "https://github.com/Koryto/protodantic"
|
|
35
|
+
Issues = "https://github.com/Koryto/protodantic/issues"
|
|
36
|
+
Changelog = "https://github.com/Koryto/protodantic/blob/master/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
protodantic = "protodantic.cli:main"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["hatchling"]
|
|
48
|
+
build-backend = "hatchling.build"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.version]
|
|
51
|
+
path = "src/protodantic/_version.py"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/protodantic"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Bidirectional bridge between Protocol Buffers and Pydantic."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .codegen import generate_source
|
|
5
|
+
from .compiler import compile_fdset
|
|
6
|
+
from .runtime import OpenEnum, ProtoModel, load_pool, model_for
|
|
7
|
+
from .types import NULL, Int32, Int64, ListValue, Struct, UInt32, UInt64, Value
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Int32",
|
|
11
|
+
"Int64",
|
|
12
|
+
"ListValue",
|
|
13
|
+
"NULL",
|
|
14
|
+
"OpenEnum",
|
|
15
|
+
"ProtoModel",
|
|
16
|
+
"Struct",
|
|
17
|
+
"UInt32",
|
|
18
|
+
"UInt64",
|
|
19
|
+
"Value",
|
|
20
|
+
"__version__",
|
|
21
|
+
"compile_fdset",
|
|
22
|
+
"generate_source",
|
|
23
|
+
"load_pool",
|
|
24
|
+
"model_for",
|
|
25
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
from .codegen import generate_source
|
|
9
|
+
from .compiler import compile_fdset
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
13
|
+
@click.version_option(__version__, prog_name="protodantic")
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""Bidirectional bridge between Protocol Buffers and pydantic models."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@main.command()
|
|
19
|
+
@click.argument("protos", nargs=-1, required=True)
|
|
20
|
+
@click.option(
|
|
21
|
+
"-I", "--include", "includes", multiple=True, metavar="DIR",
|
|
22
|
+
help="Additional import search path (repeatable).",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"-o", "--out", "out", required=True, metavar="FILE",
|
|
26
|
+
help="Output python module path (e.g. models.py).",
|
|
27
|
+
)
|
|
28
|
+
def generate(protos: tuple[str, ...], includes: tuple[str, ...], out: str) -> None:
|
|
29
|
+
"""Generate pydantic models from .proto files."""
|
|
30
|
+
try:
|
|
31
|
+
fdset = compile_fdset(protos=protos, includes=includes)
|
|
32
|
+
source = generate_source(fdset_bytes=fdset)
|
|
33
|
+
out_path = Path(out)
|
|
34
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
out_path.write_text(source, encoding="utf-8")
|
|
36
|
+
except (RuntimeError, NotImplementedError, ValueError, OSError) as exc:
|
|
37
|
+
raise click.ClickException(str(exc)) from exc
|
|
38
|
+
click.echo(f"wrote {out_path}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|