Skip to content

Add async support and optimize Python codegen#14

Merged
emmapowers merged 6 commits into
mainfrom
modernize/3-python-codegen
Apr 14, 2026
Merged

Add async support and optimize Python codegen#14
emmapowers merged 6 commits into
mainfrom
modernize/3-python-codegen

Conversation

@emmapowers

@emmapowers emmapowers commented Apr 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add bakelite runtime -l python command to generate runtime files
  • Support --runtime-import flag for custom import paths
  • Rewrite Python template for black/ruff/mypy compliance
  • Add async support to protocol runtime via async_=True parameter
  • Support both BufferedIOBase (sync) and StreamReader/StreamWriter (async)
  • Add messages() as the primary iterator interface for consuming streams
  • Replace decorator-based API with dataclass base classes (Struct, BakeliteEnum)
  • Batch consecutive primitive fields into single struct.pack/unpack calls

Note: async support was folded into this PR because the codegen optimization
commit was written on top of it — they share changes to runtime.py.

Test plan

  • pixi run pytest passes
  • Generated Python code passes ruff/mypy
  • Async example works with asyncio.open_connection

Summary by CodeRabbit

  • New Features

    • Added runtime-import option for generation and improved runtime output (writes language-specific runtime files or directories).
    • Python generator emits richer code (updated typing style) and runtime file list support.
  • Refactor

    • Protocol and serialization now use byte-oriented pack()/unpack() APIs and class-based message mappings.
    • CLI option names standardized (input/output → input_file/output_file) and runtime output controls added.
  • Bug Fixes

    • Corrected unknown-language error message spelling.
  • Tests

    • Updated tests to validate byte-buffer pack/unpack behavior.

@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@emmapowers has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 10 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 9 minutes and 10 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3cc4e4d4-f835-4616-9fb9-846a36232efb

📥 Commits

Reviewing files that changed from the base of the PR and between 0e87740 and 969c91d.

📒 Files selected for processing (1)
  • bakelite/generator/cli.py
📝 Walkthrough

Walkthrough

Refactors generation and serialization from a registry/descriptor model to class-based Struct/BakeliteEnum, adds unified sync/async protocol I/O, updates the Python generator/template to emit explicit runtime files and message mappings, and extends the CLI with configurable runtime import/output options.

Changes

Cohort / File(s) Summary
CLI
bakelite/generator/cli.py
Reworked CLI: renamed flags (--input--input_file, --output--output_file, runtime --outputoutput_path), added --runtime-import, adjusted Python gen to accept runtime_import and default to "bakelite_runtime", changed runtime output behavior for python vs cpptiny, fixed "Unknown" typo, and added docstrings.
Python generator & templates
bakelite/generator/python.py, bakelite/generator/templates/python.py.j2
Expanded Python codegen: added RUNTIME_FILES and runtime() to load packaged runtime sources; centralized primitive metadata and packing helpers; added batching for primitive fields; updated render() template params; template now emits Struct/BakeliteEnum classes, class-level _message_types/_message_ids`, and conditional runtime import logic.
Runtime / Protocol
bakelite/proto/runtime.py
Rewrote ProtocolBase to use generated _message_types/message_idsinstead of registry/descriptor; accept either syncBufferedIOBaseor async(StreamReader, StreamWriter)streams;send/pollsupportasyncflag (may return coroutine); added_encode_message/_decode_frameandmessages()iterator; CRC validation raisesProtocolError`.
Serialization core
bakelite/proto/serialization.py
Removed registry/decorator and stream-based pack/unpack system; added BakeliteFieldInfo and bakelite_field() metadata helper; introduced bytes-oriented base classes Struct and BakeliteEnum with pack()/unpack() signatures and SerializationError.
Tests
bakelite/tests/proto/test_serialization.py
Updated tests to buffer-oriented API: pack() returns bytes, unpack() returns (value, consumed); adapted assertions and input formats; removed obsolete stream-based scaffolding.
Examples & build
examples/arduino/Makefile, examples/arduino/bakelite.h, examples/arduino/proto.py
Makefile: added bakelite_runtime prerequisite for proto.py. Header: added conditional #include <avr/pgmspace.h> for __AVR__. Example proto: converted decorator/registry code into Struct subclasses using bakelite_field and explicit pack/unpack, and updated Protocol to class-level mappings and simplified init.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ProtocolBase
    participant Framer
    participant Stream

    Client->>ProtocolBase: send(message, async_=False)
    ProtocolBase->>Framer: _encode_message(message) -> framed_bytes
    ProtocolBase->>Stream: write(framed_bytes)
    Note right of Stream: sync write completes

    Client->>ProtocolBase: send(message, async_=True)
    ProtocolBase->>Framer: _encode_message(message) -> framed_bytes
    ProtocolBase->>Stream: async write coroutine -> await drain()
    Note right of Stream: async writer drained

    loop receive loop
      ProtocolBase->>Stream: read (sync or await)
      Stream-->>ProtocolBase: bytes
      ProtocolBase->>Framer: append bytes
      Framer-->>ProtocolBase: frame ready?
      alt frame complete
        ProtocolBase->>ProtocolBase: _decode_frame() -> Struct instance
        ProtocolBase-->>Client: return/yield Struct
      else not complete
        ProtocolBase-->>Client: continue/backoff
      end
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I hopped from registry to class,
Bytes tucked neatly in each pass.
Sync and async, both in stride,
Runtime files laid side by side.
Carrot-shaped changes — hop with pride! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add async support and optimize Python codegen' accurately reflects two major changes in the PR: async support for the protocol runtime and optimization of Python code generation through batching and inline pack/unpack.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@emmapowers emmapowers force-pushed the modernize/2-python-modernization branch from 526979e to 51ddf34 Compare April 14, 2026 14:46
@emmapowers emmapowers force-pushed the modernize/3-python-codegen branch from c724fc0 to 7def8d3 Compare April 14, 2026 14:46
@emmapowers emmapowers force-pushed the modernize/2-python-modernization branch from 51ddf34 to fee9b06 Compare April 14, 2026 14:53
@emmapowers emmapowers force-pushed the modernize/3-python-codegen branch from 7def8d3 to 0759207 Compare April 14, 2026 14:53
@emmapowers emmapowers force-pushed the modernize/2-python-modernization branch from fee9b06 to 34a256e Compare April 14, 2026 15:33
@emmapowers emmapowers force-pushed the modernize/3-python-codegen branch from 0759207 to afb3f51 Compare April 14, 2026 15:34
Base automatically changed from modernize/2-python-modernization to main April 14, 2026 15:44
- Add 'bakelite runtime -l python' command to generate runtime files
- Support --runtime-import flag for custom import paths
- Rewrite Python template for black/ruff/mypy compliance
- Use BLANK_LINE variable for clearer template formatting
- Generated code uses temporary sys.path modification for imports
- Update arduino example with new runtime generation
- Add async_= parameter to send(), poll(), and messages() methods
- Use @overload decorators for proper type hints
- Support both BufferedIOBase (sync) and StreamReader/StreamWriter (async)
- Add messages() as the primary iterator interface for consuming streams
- Replace decorator-based API with dataclass base classes (Struct, BakeliteEnum)
- Generate inline pack/unpack code instead of runtime interpretation
- Batch consecutive primitive fields into single struct.pack/unpack calls
- Simplify ProtocolBase to use class attributes for message mapping
@emmapowers emmapowers force-pushed the modernize/3-python-codegen branch from afb3f51 to 7f9f728 Compare April 14, 2026 15:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
bakelite/generator/cli.py (1)

20-27: Consider clarifying the --runtime-import help text.

The current help text "Import path for runtime. No value=bakelite.proto, omit=runtime" is cryptic. A clearer description would help users understand the three modes:

  • Omit the flag entirely → uses "bakelite_runtime" (relative import)
  • --runtime-import (no value) → uses "bakelite.proto" (installed package)
  • --runtime-import=my.module → uses "my.module" (custom path)
Suggested improvement
 `@click.option`(
     "--runtime-import",
     "runtime_import",
     is_flag=False,
     flag_value="bakelite.proto",
     default=None,
-    help="Import path for runtime. No value=bakelite.proto, omit=runtime",
+    help="Runtime import path. Omit for relative bakelite_runtime, use without value for bakelite.proto, or specify custom path",
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bakelite/generator/cli.py` around lines 20 - 27, Update the help string for
the click option defined on runtime_import (the click.option call with
"--runtime-import") to clearly describe the three behaviors: omitting the flag
selects the relative import "bakelite_runtime", passing the flag with no value
selects the installed-package import "bakelite.proto", and passing a value
(e.g., --runtime-import=my.module) uses that custom import path; modify the help
text to succinctly list these three modes and their resulting import values so
users can understand the flag's three behaviors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@bakelite/generator/cli.py`:
- Around line 55-58: The cpptiny branch currently writes cpptiny.runtime() to
output_path assuming it's a file, which fails if output_path is the default ".";
update the language == "cpptiny" branch to detect if output_path is a directory
(use os.path.isdir(output_path)) and, if so, set a sensible filename (e.g.,
os.path.join(output_path, "bakelite.h")) before opening; otherwise use
output_path as given, and validate that the resolved path is not a directory
before calling open(...); modify the code around cpptiny.runtime(), output_path,
and the open(...) call accordingly.

In `@bakelite/generator/templates/python.py.j2`:
- Around line 7-9: The template is importing Enum explicitly in the conditional
blocks even though generated enum classes inherit from BakeliteEnum (which
already subclasses Enum); remove the redundant "from enum import Enum" import
lines in both conditional sections of the python.py.j2 template, ensure no
remaining references to the bare Enum name in the template (leave BakeliteEnum
as the base for generated enums), and run a quick generation check to confirm
enums still import/resolve via BakeliteEnum.

In `@bakelite/proto/runtime.py`:
- Around line 215-227: Both `_messages_sync` and `_messages_async` spin when
`poll()` returns None causing high CPU; update these functions so that when `msg
is None` they perform a short backoff instead of immediately looping: in
`_messages_sync`, call a blocking wait such as `time.sleep()` (or implement an
exponential backoff) after a missed poll; in `_messages_async`, call `await
asyncio.sleep()` similarly; alternatively, if `poll()` supports a blocking mode,
switch to using that (e.g., `self.poll(block=True)` / `await
self.poll(async_=True, block=True)`); ensure you import `time` and `asyncio` as
needed and keep yielding messages unchanged when `msg` is not None.
- Around line 157-166: The synchronous poll method reads from self._stream with
read() which blocks until EOF; change it to read a bounded size (e.g. 4096) like
the async path to avoid indefinite blocking on non-EOF streams (serial ports).
Update the call in poll (the code that currently calls self._stream.read()) to
pass a buffer size (4096) and keep the subsequent logic that appends to
self._framer and decodes via _framer.decode_frame() and _decode_frame(frame)
unchanged so behavior remains consistent with the async implementation.
- Around line 173-179: The _poll_async loop currently swallows all exceptions
from await self._async_reader.read(4096) which can hide real errors; update
_poll_async to stop using a bare except Exception: pass — either catch only
expected exceptions such as asyncio.IncompleteReadError (and handle
reconnect/EOF), or log unexpected exceptions via the module logger (including
exception info) and re-raise if it indicates a fatal condition; ensure any
caught error still allows safe cleanup and that successful reads continue to
call self._framer.append_buffer(data).

In `@bakelite/proto/serialization.py`:
- Around line 47-50: The wrapper around dataclasses.field currently prefers
default over default_factory and silently drops default_factory; change it to
detect when both default and default_factory are provided and raise the same
error dataclasses.field would (e.g., raise ValueError("cannot specify both
default and default_factory")), so update the wrapper function that returns
field(... ) to check if default is not _MISSING and default_factory is not
_MISSING and raise; otherwise proceed to call field(...) with the appropriate
argument and metadata.

In `@bakelite/tests/proto/test_serialization.py`:
- Around line 178-194: The test function rejects_fixed_array_wrong_size
currently builds an ArrayStruct via gen_code and test_struct but contains no
assertion; update it to either be removed or explicitly skipped/asserted: the
quick fix is to mark rejects_fixed_array_wrong_size with pytest.skip (or call
pytest.skip at start) with a brief message explaining that non-batched
fixed-size array validation is not implemented, referencing
ArrayStruct/gen_code/test_struct in the message; alternatively, if you want a
real test, replace the body with an explicit assertion about the packed output
or validation behavior (e.g., compare pack() output or expected exception) so
the test actually verifies a specific outcome.

---

Nitpick comments:
In `@bakelite/generator/cli.py`:
- Around line 20-27: Update the help string for the click option defined on
runtime_import (the click.option call with "--runtime-import") to clearly
describe the three behaviors: omitting the flag selects the relative import
"bakelite_runtime", passing the flag with no value selects the installed-package
import "bakelite.proto", and passing a value (e.g., --runtime-import=my.module)
uses that custom import path; modify the help text to succinctly list these
three modes and their resulting import values so users can understand the flag's
three behaviors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 113ba610-bdf5-4006-97ce-2b47852498ba

📥 Commits

Reviewing files that changed from the base of the PR and between 8c6ce20 and 7f9f728.

📒 Files selected for processing (9)
  • bakelite/generator/cli.py
  • bakelite/generator/python.py
  • bakelite/generator/templates/python.py.j2
  • bakelite/proto/runtime.py
  • bakelite/proto/serialization.py
  • bakelite/tests/proto/test_serialization.py
  • examples/arduino/Makefile
  • examples/arduino/bakelite.h
  • examples/arduino/proto.py

Comment thread bakelite/generator/cli.py
Comment thread bakelite/generator/templates/python.py.j2 Outdated
Comment thread bakelite/proto/runtime.py
Comment thread bakelite/proto/runtime.py Outdated
Comment thread bakelite/proto/runtime.py
Comment thread bakelite/proto/serialization.py
Comment thread bakelite/tests/proto/test_serialization.py
- Default runtime output to "bakelite.h" for cpptiny, "." for python
- Remove unused `from enum import Enum` from generated code template
- Use bounded read(4096) in sync poll to avoid blocking on serial ports
- Remove bare except in async poll, let exceptions propagate
- Add sleep(1ms) in message iterators to avoid CPU spin
- Reject specifying both default and default_factory in bakelite_field
- Mark array size validation test as skipped (not yet implemented)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
bakelite/proto/serialization.py (1)

25-32: Consider renaming type parameter to avoid shadowing builtin.

The parameter type shadows Python's built-in type(). While harmless within this function's scope, it's a minor anti-pattern that can confuse readers or cause issues if the builtin is needed later.

♻️ Suggested rename
 def bakelite_field(
-    type: str,
+    wire_type: str,
     *,
     max_length: int | None = None,
     array_size: int | None = None,
     default: Any = _MISSING,
     default_factory: Any = _MISSING,
 ) -> Any:
     """Define a bakelite field with serialization metadata.

     Args:
-        type: The bakelite wire type (e.g., "uint8", "int32", "string").
+        wire_type: The bakelite wire type (e.g., "uint8", "int32", "string").
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bakelite/proto/serialization.py` around lines 25 - 32, The parameter name
"type" in the function bakelite_field shadows the builtin type()—rename the
parameter (for example to "field_type") across the bakelite_field signature and
every use inside that function and update any callers or references in the
module (including type hints, default handling, and any code constructing
bakelite_field) to use the new name to avoid confusion and preserve builtin
access.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@bakelite/generator/cli.py`:
- Around line 20-27: The help text for the Click option runtime_import is
incorrect: it says "omit=runtime" but the actual default is "bakelite_runtime"
(the option is defined with flag_value="bakelite.proto" and default=None and
later resolved to "bakelite_runtime"). Update the help string for the Click
option (the --runtime-import definition / runtime_import flag) to reflect the
actual behavior, e.g. "Import path for runtime. No value=bakelite.proto,
omit=bakelite_runtime", or alternatively change the default resolution logic to
match "runtime" if you want the help text to remain "omit=runtime".

---

Nitpick comments:
In `@bakelite/proto/serialization.py`:
- Around line 25-32: The parameter name "type" in the function bakelite_field
shadows the builtin type()—rename the parameter (for example to "field_type")
across the bakelite_field signature and every use inside that function and
update any callers or references in the module (including type hints, default
handling, and any code constructing bakelite_field) to use the new name to avoid
confusion and preserve builtin access.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9de46d37-d9f1-42d6-b568-8f6e8c055fc0

📥 Commits

Reviewing files that changed from the base of the PR and between 7f9f728 and 26f845a.

📒 Files selected for processing (5)
  • bakelite/generator/cli.py
  • bakelite/generator/templates/python.py.j2
  • bakelite/proto/runtime.py
  • bakelite/proto/serialization.py
  • bakelite/tests/proto/test_serialization.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • bakelite/tests/proto/test_serialization.py

Comment thread bakelite/generator/cli.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
bakelite/generator/cli.py (1)

62-65: ⚠️ Potential issue | 🟡 Minor

Handle directory paths for cpptiny runtime output.

If -o points to a directory (for example -o .), this branch still calls open() on a directory and crashes. Normalize directory inputs to a filename (e.g., bakelite.h) before writing.

Suggested patch
     if language == "cpptiny":
-        output_path = output_path or "bakelite.h"
+        target = Path(output_path or "bakelite.h")
+        if target.is_dir():
+            target = target / "bakelite.h"
+        target.parent.mkdir(parents=True, exist_ok=True)
         generated_file = cpptiny.runtime()
-        with open(output_path, "w", encoding="utf-8") as f:
+        with target.open("w", encoding="utf-8") as f:
             f.write(generated_file)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bakelite/generator/cli.py` around lines 62 - 65, The code currently writes
cpptiny.runtime() to output_path without handling when output_path is a
directory (e.g., "-o ."), causing open() to fail; update the write path before
opening by checking if output_path is a directory (use
os.path.isdir(output_path)) and, if so, replace it with
os.path.join(output_path, "bakelite.h") (or the default filename) so
generated_file from cpptiny.runtime() is written to a file, not a directory;
ensure os is imported and keep using the variables output_path, generated_file,
and the with open(...) write block as shown.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@bakelite/generator/cli.py`:
- Around line 70-71: The loop that writes generated Python runtime files uses
(runtime_dir / filename).write_text(content) which is locale-dependent; update
the write to explicitly specify UTF-8 encoding by calling the write_text
equivalent with encoding='utf-8' (i.e., change the write_text call inside the
for filename, content in python.runtime().items() loop to pass encoding='utf-8')
so all runtime files are written deterministically across environments.

---

Duplicate comments:
In `@bakelite/generator/cli.py`:
- Around line 62-65: The code currently writes cpptiny.runtime() to output_path
without handling when output_path is a directory (e.g., "-o ."), causing open()
to fail; update the write path before opening by checking if output_path is a
directory (use os.path.isdir(output_path)) and, if so, replace it with
os.path.join(output_path, "bakelite.h") (or the default filename) so
generated_file from cpptiny.runtime() is written to a file, not a directory;
ensure os is imported and keep using the variables output_path, generated_file,
and the with open(...) write block as shown.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ff1651d-5747-49ed-82d8-0f9c50f9352f

📥 Commits

Reviewing files that changed from the base of the PR and between 26f845a and 0e87740.

📒 Files selected for processing (1)
  • bakelite/generator/cli.py

Comment thread bakelite/generator/cli.py Outdated
@emmapowers emmapowers merged commit 65091ba into main Apr 14, 2026
3 checks passed
@emmapowers emmapowers deleted the modernize/3-python-codegen branch April 14, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant