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.
Files changed (55) hide show
  1. protodantic_py-0.1.0/.github/workflows/ci.yml +32 -0
  2. protodantic_py-0.1.0/.github/workflows/release.yml +66 -0
  3. protodantic_py-0.1.0/.gitignore +31 -0
  4. protodantic_py-0.1.0/AGENTS.md +57 -0
  5. protodantic_py-0.1.0/CHANGELOG.md +30 -0
  6. protodantic_py-0.1.0/CLAUDE.md +1 -0
  7. protodantic_py-0.1.0/LICENSE +21 -0
  8. protodantic_py-0.1.0/PKG-INFO +155 -0
  9. protodantic_py-0.1.0/README.md +126 -0
  10. protodantic_py-0.1.0/pyproject.toml +54 -0
  11. protodantic_py-0.1.0/src/protodantic/__init__.py +25 -0
  12. protodantic_py-0.1.0/src/protodantic/_version.py +1 -0
  13. protodantic_py-0.1.0/src/protodantic/cli.py +42 -0
  14. protodantic_py-0.1.0/src/protodantic/codegen.py +310 -0
  15. protodantic_py-0.1.0/src/protodantic/compiler.py +39 -0
  16. protodantic_py-0.1.0/src/protodantic/py.typed +0 -0
  17. protodantic_py-0.1.0/src/protodantic/runtime.py +345 -0
  18. protodantic_py-0.1.0/src/protodantic/types.py +59 -0
  19. protodantic_py-0.1.0/tests/conftest.py +44 -0
  20. protodantic_py-0.1.0/tests/protos/anypayload.proto +14 -0
  21. protodantic_py-0.1.0/tests/protos/collision_a.proto +11 -0
  22. protodantic_py-0.1.0/tests/protos/collision_b.proto +11 -0
  23. protodantic_py-0.1.0/tests/protos/common.proto +8 -0
  24. protodantic_py-0.1.0/tests/protos/demo.proto +44 -0
  25. protodantic_py-0.1.0/tests/protos/enums.proto +17 -0
  26. protodantic_py-0.1.0/tests/protos/hostile_names.proto +33 -0
  27. protodantic_py-0.1.0/tests/protos/legacy.proto +9 -0
  28. protodantic_py-0.1.0/tests/protos/naming.proto +12 -0
  29. protodantic_py-0.1.0/tests/protos/nesting.proto +14 -0
  30. protodantic_py-0.1.0/tests/protos/orders.proto +11 -0
  31. protodantic_py-0.1.0/tests/protos/presence.proto +21 -0
  32. protodantic_py-0.1.0/tests/protos/recursion.proto +20 -0
  33. protodantic_py-0.1.0/tests/protos/scalars.proto +26 -0
  34. protodantic_py-0.1.0/tests/protos/shadowing.proto +18 -0
  35. protodantic_py-0.1.0/tests/protos/structs.proto +11 -0
  36. protodantic_py-0.1.0/tests/protos/wire.proto +19 -0
  37. protodantic_py-0.1.0/tests/protos/wkt.proto +13 -0
  38. protodantic_py-0.1.0/tests/protos/wrappers.proto +13 -0
  39. protodantic_py-0.1.0/tests/test_cli.py +99 -0
  40. protodantic_py-0.1.0/tests/test_codegen_output.py +90 -0
  41. protodantic_py-0.1.0/tests/test_enums.py +46 -0
  42. protodantic_py-0.1.0/tests/test_ergonomics.py +84 -0
  43. protodantic_py-0.1.0/tests/test_multifile.py +42 -0
  44. protodantic_py-0.1.0/tests/test_naming.py +94 -0
  45. protodantic_py-0.1.0/tests/test_nesting.py +13 -0
  46. protodantic_py-0.1.0/tests/test_pb2_interop.py +68 -0
  47. protodantic_py-0.1.0/tests/test_presence.py +109 -0
  48. protodantic_py-0.1.0/tests/test_proto2.py +11 -0
  49. protodantic_py-0.1.0/tests/test_recursion.py +30 -0
  50. protodantic_py-0.1.0/tests/test_registry.py +86 -0
  51. protodantic_py-0.1.0/tests/test_roundtrip.py +100 -0
  52. protodantic_py-0.1.0/tests/test_scalars.py +119 -0
  53. protodantic_py-0.1.0/tests/test_wire_compat.py +45 -0
  54. protodantic_py-0.1.0/tests/test_wkt.py +165 -0
  55. 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
+ [![CI](https://github.com/Koryto/protodantic/actions/workflows/ci.yml/badge.svg)](https://github.com/Koryto/protodantic/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/protodantic-py)](https://pypi.org/project/protodantic-py/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/protodantic-py)](https://pypi.org/project/protodantic-py/)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![CI](https://github.com/Koryto/protodantic/actions/workflows/ci.yml/badge.svg)](https://github.com/Koryto/protodantic/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/protodantic-py)](https://pypi.org/project/protodantic-py/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/protodantic-py)](https://pypi.org/project/protodantic-py/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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()