|
| 1 | +# Copyright 2025, 2026 Query Farm LLC - https://query.farm |
| 2 | + |
| 3 | +r"""Emit ``vgi_protocol_names.hpp`` — the C++ side's view of the wire routing keys. |
| 4 | +
|
| 5 | +Third sibling of :mod:`vgi.codegen.cpp_constants` (byte keys from |
| 6 | +``vgi_rpc.metadata``) and :mod:`vgi.codegen.cpp_protocol_version` (the surface |
| 7 | +versions). This one emits the ``vgi_rpc.protocol`` *values*: the names a client |
| 8 | +must send to reach each protocol this repo declares. |
| 9 | +
|
| 10 | +Why this generator exists |
| 11 | +------------------------- |
| 12 | +
|
| 13 | +It was added after a real break, and the break is the specification. Neither |
| 14 | +Protocol declared a ``protocol_name``, so every implementation's wire name |
| 15 | +defaulted to whatever its local class happened to be called — six |
| 16 | +implementations, four different answers. That was invisible until the |
| 17 | +transports made ``vgi_rpc.protocol`` a required routing key, at which point |
| 18 | +there was no single string the DuckDB extension could send. |
| 19 | +
|
| 20 | +The names were then decided (``vgi.v2``, ``vgi.secret.v1``) and hand-written on |
| 21 | +both sides. That is the same shape of hazard one step removed: the version is |
| 22 | +generated *and* drift-guarded, so a version bump that misses the C++ tree is a |
| 23 | +red build, while a rename that missed it was a silent misroute. A wire contract |
| 24 | +that two repos spell independently will eventually be spelled differently. |
| 25 | +
|
| 26 | +Both names live in ONE header, unlike the versions, which have one file each. |
| 27 | +They are not two independent facts: they are the wire-name contract, decided |
| 28 | +together and — as the commit that introduced them shows — changed together. One |
| 29 | +artifact means one regen entry and one drift test, and makes it impossible to |
| 30 | +update one name while leaving the other stale. |
| 31 | +
|
| 32 | +The name is read through vgi-rpc's own ``_protocol_wire_name`` rather than |
| 33 | +reimplementing its rule here. The rule has a subtlety worth not duplicating: |
| 34 | +it reads ``vars(protocol)`` rather than ``getattr``, so a Protocol that |
| 35 | +subclasses another and forgets to redeclare does NOT silently inherit its |
| 36 | +parent's routing key. Reimplementing that with ``getattr`` would make this |
| 37 | +generator disagree with the dispatcher it exists to serve — which is precisely |
| 38 | +the class of drift the generator is for. |
| 39 | +
|
| 40 | +Workflow: |
| 41 | +
|
| 42 | + uv run --project ~/Development/vgi-python python scripts/regen_generated.py |
| 43 | +
|
| 44 | +``tests/test_generated_cpp_protocol_name.py`` enforces drift detection at PR time. |
| 45 | +""" |
| 46 | + |
| 47 | +from __future__ import annotations |
| 48 | + |
| 49 | +import argparse |
| 50 | +import io |
| 51 | +import sys |
| 52 | +from typing import TYPE_CHECKING |
| 53 | + |
| 54 | +from vgi_rpc.rpc._types import ( |
| 55 | + _protocol_wire_name, |
| 56 | + validate_protocol_name, |
| 57 | +) |
| 58 | + |
| 59 | +from vgi.codegen._common import ( |
| 60 | + DEFAULT_CPP_NAMESPACE, |
| 61 | + GeneratorError, |
| 62 | + close_namespace, |
| 63 | + open_namespace, |
| 64 | + parse_cpp_namespace, |
| 65 | + provenance_comment, |
| 66 | +) |
| 67 | +from vgi.protocol import VgiProtocol |
| 68 | +from vgi.secret_protocol import VgiSecretProtocol |
| 69 | + |
| 70 | +if TYPE_CHECKING: |
| 71 | + from typing import TextIO |
| 72 | + |
| 73 | + |
| 74 | +GENERATOR_VERSION = "1" |
| 75 | + |
| 76 | + |
| 77 | +def current_protocol_name() -> str: |
| 78 | + """Return the wire routing key for :class:`vgi.protocol.VgiProtocol`.""" |
| 79 | + return _wire_name(VgiProtocol) |
| 80 | + |
| 81 | + |
| 82 | +def current_secret_protocol_name() -> str: |
| 83 | + """Return the wire routing key for :class:`vgi.secret_protocol.VgiSecretProtocol`.""" |
| 84 | + return _wire_name(VgiSecretProtocol) |
| 85 | + |
| 86 | + |
| 87 | +def _wire_name(protocol: type) -> str: |
| 88 | + """Resolve and validate one Protocol's wire name. |
| 89 | +
|
| 90 | + Resolution is delegated to vgi-rpc so this never disagrees with the |
| 91 | + dispatcher. Validation is vgi-rpc's too, and runs here so a name that the |
| 92 | + server would refuse to route fails at generation rather than becoming a |
| 93 | + string literal that every client dutifully sends and every server rejects. |
| 94 | +
|
| 95 | + ``allow_reserved`` is deliberately NOT set: the ``vgi_rpc.`` prefix belongs |
| 96 | + to protocols the framework itself defines, and nothing this repo declares |
| 97 | + may claim it. |
| 98 | + """ |
| 99 | + name = _protocol_wire_name(protocol) |
| 100 | + try: |
| 101 | + validate_protocol_name(name) |
| 102 | + except ValueError as exc: |
| 103 | + raise GeneratorError(f"{protocol.__name__} has an unroutable wire name: {exc}") from exc |
| 104 | + |
| 105 | + # The name is emitted into a C++ string literal AND into an HTTP URL path |
| 106 | + # segment. validate_protocol_name's charset already excludes everything that |
| 107 | + # would need escaping in either; this asserts that rather than assuming it, |
| 108 | + # so a future loosening upstream surfaces here instead of as a malformed |
| 109 | + # header or a percent-encoding mismatch at some proxy. |
| 110 | + if not all(0x20 <= ord(c) < 0x7F for c in name): |
| 111 | + raise GeneratorError(f"non-printable byte in protocol_name {name!r}; this is a bug") |
| 112 | + return name |
| 113 | + |
| 114 | + |
| 115 | +def emit(out: TextIO, namespace: list[str] | None = None) -> None: |
| 116 | + """Emit ``vgi_protocol_names.hpp`` to *out*.""" |
| 117 | + if namespace is None: |
| 118 | + namespace = parse_cpp_namespace(DEFAULT_CPP_NAMESPACE) |
| 119 | + |
| 120 | + entries = [ |
| 121 | + ( |
| 122 | + "VGI_PROTOCOL_NAME", |
| 123 | + current_protocol_name(), |
| 124 | + "VgiProtocol", |
| 125 | + "the worker/catalog protocol: bind, init, catalog_*, aggregates, table_buffering_*", |
| 126 | + ), |
| 127 | + ( |
| 128 | + "VGI_SECRET_PROTOCOL_NAME", |
| 129 | + current_secret_protocol_name(), |
| 130 | + "VgiSecretProtocol", |
| 131 | + "the standalone secret service: the single unary secret_lookup", |
| 132 | + ), |
| 133 | + ] |
| 134 | + |
| 135 | + body = io.StringIO() |
| 136 | + body.write("#pragma once\n\n") |
| 137 | + body.write("#include <string_view>\n\n") |
| 138 | + body.write(open_namespace(namespace)) |
| 139 | + body.write("\n") |
| 140 | + body.write("// Wire routing keys. A vgi-rpc server dispatches on the pair\n") |
| 141 | + body.write("// (protocol, method): one server may co-host several protocols and their\n") |
| 142 | + body.write("// method names may collide, so every request must name the protocol it\n") |
| 143 | + body.write("// addresses under `vgi_rpc.protocol`. On the raw transports that metadata\n") |
| 144 | + body.write("// key is the only carrier; over HTTP the same value is also the protocol\n") |
| 145 | + body.write("// path segment, and the server rejects a request whose two disagree.\n") |
| 146 | + body.write("//\n") |
| 147 | + body.write("// The major version is part of the name, so an incompatible major is a\n") |
| 148 | + body.write("// DIFFERENT protocol and an unroutable request 404s -- an answer any proxy\n") |
| 149 | + body.write("// understands without an Arrow parser.\n") |
| 150 | + for constant_name, value, source, purpose in entries: |
| 151 | + body.write("\n") |
| 152 | + body.write(f"// {source} -- {purpose}.\n") |
| 153 | + body.write(f"// Sourced from {source}.protocol_name (vgi-python).\n") |
| 154 | + body.write(f'inline constexpr std::string_view {constant_name} = "{value}";\n') |
| 155 | + body.write("\n") |
| 156 | + body.write(close_namespace(namespace)) |
| 157 | + |
| 158 | + out.write("// ============================================================================\n") |
| 159 | + out.write( |
| 160 | + provenance_comment( |
| 161 | + generator_module="vgi.codegen.cpp_protocol_name", |
| 162 | + generator_command="python -m vgi.codegen.cpp_protocol_name", |
| 163 | + generator_version=GENERATOR_VERSION, |
| 164 | + regen_command_lines=[ |
| 165 | + "uv run --project ~/Development/vgi-python python scripts/regen_generated.py", |
| 166 | + ], |
| 167 | + body=body.getvalue(), |
| 168 | + ) |
| 169 | + ) |
| 170 | + out.write("// ============================================================================\n") |
| 171 | + out.write("\n") |
| 172 | + out.write(body.getvalue()) |
| 173 | + |
| 174 | + |
| 175 | +def main() -> None: |
| 176 | + """Console-script entry point.""" |
| 177 | + parser = argparse.ArgumentParser(description=__doc__) |
| 178 | + parser.add_argument( |
| 179 | + "--namespace", |
| 180 | + default=DEFAULT_CPP_NAMESPACE, |
| 181 | + help=( |
| 182 | + "C++ namespace to emit into, `::`-separated " |
| 183 | + f"(default: {DEFAULT_CPP_NAMESPACE}). VGI is not DuckDB-only: a " |
| 184 | + "standalone worker SDK wants something like `vgi::generated`." |
| 185 | + ), |
| 186 | + ) |
| 187 | + args = parser.parse_args() |
| 188 | + try: |
| 189 | + namespace = parse_cpp_namespace(args.namespace) |
| 190 | + except GeneratorError as e: |
| 191 | + print(f"\nerror: {e}\n", file=sys.stderr) |
| 192 | + sys.exit(2) |
| 193 | + emit(sys.stdout, namespace) |
| 194 | + |
| 195 | + |
| 196 | +if __name__ == "__main__": |
| 197 | + main() |
0 commit comments