wizard-codegen 0.2.0__tar.gz → 0.2.2__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.
- wizard_codegen-0.2.0/README.md → wizard_codegen-0.2.2/PKG-INFO +56 -15
- wizard_codegen-0.2.0/PKG-INFO → wizard_codegen-0.2.2/README.md +41 -30
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/cli/main.py +65 -21
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/config.py +1 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/context_builder.py +43 -5
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/filter.py +15 -1
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/renderer.py +9 -2
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/pyproject.toml +3 -3
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/utils/__init__.py +5 -0
- wizard_codegen-0.2.2/utils/options.py +511 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/.gitignore +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/cli/__init__.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/__init__.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/writer.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/hooks/__init__.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/hooks/hooks.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/__init__.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/discover.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/fds_loader.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/proto_source.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/protoc_runner.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/tests/fixtures/hooks/__init__.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/tests/fixtures/hooks/type_mapping.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/utils/name.py +0 -0
- {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/utils/path.py +0 -0
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wizard-codegen
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: A powerful, template-driven code generation tool for Protocol Buffers
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click==8.3.1
|
|
7
|
+
Requires-Dist: jinja2==3.1.6
|
|
8
|
+
Requires-Dist: protobuf==6.33.5
|
|
9
|
+
Requires-Dist: pydantic-core==2.41.5
|
|
10
|
+
Requires-Dist: pydantic==2.12.5
|
|
11
|
+
Requires-Dist: pyyaml==6.0.3
|
|
12
|
+
Requires-Dist: rich==14.3.3
|
|
13
|
+
Requires-Dist: typer==0.21.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
1
16
|
# 🧙 Wizard Codegen
|
|
2
17
|
|
|
3
18
|
[](https://dl.circleci.com/status-badge/redirect/gh/ConsultingMD/wizard-codegen/tree/main)
|
|
@@ -46,6 +61,8 @@ handlers — this tool has you covered.
|
|
|
46
61
|
| **Multi-Language Support** | Generate code for TypeScript, Swift, Kotlin, Go, and more |
|
|
47
62
|
| **Jinja2 Templates** | Full power of Jinja2 templating with custom filters |
|
|
48
63
|
| **Flexible Filtering** | Target specific messages, enums, or services with `where` clauses |
|
|
64
|
+
| **Runtime Name Filter** | Generate only specific items from CLI: `wizard-codegen generate SampleProtoPage` |
|
|
65
|
+
| **Annotation Filtering** | Filter messages by custom proto annotations/options (e.g., `has_option: "*.page"`) |
|
|
49
66
|
| **Git Proto Sources** | Fetch proto definitions directly from Git repositories |
|
|
50
67
|
| **Multiple Write Modes** | `overwrite`, `append`, or `write-once` file generation |
|
|
51
68
|
| **Custom Hooks** | Extend with your own Jinja filters and helpers |
|
|
@@ -53,6 +70,7 @@ handlers — this tool has you covered.
|
|
|
53
70
|
| **Dry Run Mode** | Preview changes without writing files |
|
|
54
71
|
| **Verbose Output** | Debug with detailed proto and context inspection |
|
|
55
72
|
| **Nested Type Support** | Full support for nested messages and enums |
|
|
73
|
+
| **Options Extraction** | Access message, field, and enum options in templates |
|
|
56
74
|
|
|
57
75
|
---
|
|
58
76
|
|
|
@@ -193,9 +211,18 @@ The CLI is built with [Typer](https://typer.tiangolo.com/) and provides rich, co
|
|
|
193
211
|
#### `generate` — Generate code from protos
|
|
194
212
|
|
|
195
213
|
```bash
|
|
196
|
-
# Basic generation
|
|
214
|
+
# Basic generation (all items)
|
|
197
215
|
wizard-codegen generate
|
|
198
216
|
|
|
217
|
+
# Generate only specific items by name
|
|
218
|
+
wizard-codegen generate SampleProtoPage
|
|
219
|
+
|
|
220
|
+
# Generate multiple specific items
|
|
221
|
+
wizard-codegen generate SampleProtoPage UserFormPage
|
|
222
|
+
|
|
223
|
+
# Use glob patterns to match item names
|
|
224
|
+
wizard-codegen generate "*Page"
|
|
225
|
+
|
|
199
226
|
# With custom config file
|
|
200
227
|
wizard-codegen --config path/to/codegen.yaml generate
|
|
201
228
|
|
|
@@ -205,10 +232,12 @@ wizard-codegen --dry-run generate
|
|
|
205
232
|
# Verbose mode (detailed output)
|
|
206
233
|
wizard-codegen --verbose generate
|
|
207
234
|
|
|
208
|
-
# Combine flags
|
|
209
|
-
wizard-codegen --verbose --dry-run
|
|
235
|
+
# Combine flags and name filter
|
|
236
|
+
wizard-codegen --verbose --dry-run generate SampleProtoPage
|
|
210
237
|
```
|
|
211
238
|
|
|
239
|
+
The optional positional `NAMES` arguments filter which items are generated at runtime. This works alongside any `where` clauses in your config — the config filters are applied first, then the name filter narrows the results further. Glob patterns (`*Page`, `Sample*`) and regex patterns are supported. When no names are provided, all matching items are generated as usual.
|
|
240
|
+
|
|
212
241
|
#### `list-protos` — List discovered proto files
|
|
213
242
|
|
|
214
243
|
```bash
|
|
@@ -440,13 +469,14 @@ where:
|
|
|
440
469
|
|
|
441
470
|
##### Predicate Fields
|
|
442
471
|
|
|
443
|
-
| Field | Description
|
|
444
|
-
|
|
445
|
-
| `name` | Message/enum/service name
|
|
446
|
-
| `package` | Proto package name
|
|
447
|
-
| `file` | Proto file path
|
|
448
|
-
| `full_name` | Fully qualified name
|
|
449
|
-
| `option.equals` | Match proto options
|
|
472
|
+
| Field | Description | Example |
|
|
473
|
+
|-----------------|---------------------------------------|------------------------------------------|
|
|
474
|
+
| `name` | Message/enum/service name | `"*Form"`, `"User*"`, `"Order"` |
|
|
475
|
+
| `package` | Proto package name | `"wizard.*"`, `"com.example.*"` |
|
|
476
|
+
| `file` | Proto file path | `"*/shared/*"`, `"user/*.proto"` |
|
|
477
|
+
| `full_name` | Fully qualified name | `".wizard.UserForm"` |
|
|
478
|
+
| `option.equals` | Match proto options | `{ key: "deprecated", value: true }` |
|
|
479
|
+
| `has_option` | Check if option/annotation is present | `"*.page"`, `"annotations.wizard.v1.page"` |
|
|
450
480
|
|
|
451
481
|
Patterns support:
|
|
452
482
|
|
|
@@ -508,7 +538,10 @@ When a template is rendered, it receives a rich context:
|
|
|
508
538
|
"messages": [...],
|
|
509
539
|
"enums": [...],
|
|
510
540
|
"services": [...],
|
|
511
|
-
"options":
|
|
541
|
+
"options": {
|
|
542
|
+
# Extracted file options (standard and extensions)
|
|
543
|
+
# e.g., {"java_package": "com.example", "go_package": "example/pb"}
|
|
544
|
+
},
|
|
512
545
|
}
|
|
513
546
|
```
|
|
514
547
|
|
|
@@ -529,11 +562,16 @@ When a template is rendered, it receives a rich context:
|
|
|
529
562
|
"type_name": "", # For message/enum refs: ".package.Type"
|
|
530
563
|
"json_name": "userName",
|
|
531
564
|
"field": < FieldDescriptor >,
|
|
565
|
+
"options": {}, # Extracted field options
|
|
532
566
|
},
|
|
533
567
|
...
|
|
534
568
|
],
|
|
535
569
|
"nested_messages": [...],
|
|
536
570
|
"nested_enums": [...],
|
|
571
|
+
"options": {
|
|
572
|
+
# Extracted message options (standard and extensions)
|
|
573
|
+
# Example: {"annotations.wizard.v1.page": True}
|
|
574
|
+
},
|
|
537
575
|
}
|
|
538
576
|
```
|
|
539
577
|
|
|
@@ -550,6 +588,7 @@ When a template is rendered, it receives a rich context:
|
|
|
550
588
|
{"name": "STATUS_ACTIVE", "number": 1},
|
|
551
589
|
...
|
|
552
590
|
],
|
|
591
|
+
"options": {}, # Extracted enum options
|
|
553
592
|
}
|
|
554
593
|
```
|
|
555
594
|
|
|
@@ -558,6 +597,7 @@ When a template is rendered, it receives a rich context:
|
|
|
558
597
|
```python
|
|
559
598
|
{
|
|
560
599
|
"name": Name("UserService"),
|
|
600
|
+
"full_name": ".wizard.UserService",
|
|
561
601
|
"file": "user/user_service.proto",
|
|
562
602
|
"package": "wizard",
|
|
563
603
|
"methods": [
|
|
@@ -567,10 +607,11 @@ When a template is rendered, it receives a rich context:
|
|
|
567
607
|
"output_type": ".wizard.User",
|
|
568
608
|
"client_streaming": False,
|
|
569
609
|
"server_streaming": False,
|
|
570
|
-
"options":
|
|
571
|
-
},
|
|
572
|
-
...
|
|
573
|
-
],
|
|
610
|
+
"options": {}, # Extracted method options
|
|
611
|
+
},
|
|
612
|
+
...
|
|
613
|
+
],
|
|
614
|
+
"options": {}, # Extracted service options
|
|
574
615
|
}
|
|
575
616
|
```
|
|
576
617
|
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: wizard-codegen
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: A powerful, template-driven code generation tool for Protocol Buffers
|
|
5
|
-
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: click==8.3.1
|
|
7
|
-
Requires-Dist: jinja2==3.1.6
|
|
8
|
-
Requires-Dist: protobuf==6.33.4
|
|
9
|
-
Requires-Dist: pydantic-core==2.41.5
|
|
10
|
-
Requires-Dist: pydantic==2.12.5
|
|
11
|
-
Requires-Dist: pyyaml==6.0.3
|
|
12
|
-
Requires-Dist: rich==14.3.1
|
|
13
|
-
Requires-Dist: typer==0.21.1
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
1
|
# 🧙 Wizard Codegen
|
|
17
2
|
|
|
18
3
|
[](https://dl.circleci.com/status-badge/redirect/gh/ConsultingMD/wizard-codegen/tree/main)
|
|
@@ -61,6 +46,8 @@ handlers — this tool has you covered.
|
|
|
61
46
|
| **Multi-Language Support** | Generate code for TypeScript, Swift, Kotlin, Go, and more |
|
|
62
47
|
| **Jinja2 Templates** | Full power of Jinja2 templating with custom filters |
|
|
63
48
|
| **Flexible Filtering** | Target specific messages, enums, or services with `where` clauses |
|
|
49
|
+
| **Runtime Name Filter** | Generate only specific items from CLI: `wizard-codegen generate SampleProtoPage` |
|
|
50
|
+
| **Annotation Filtering** | Filter messages by custom proto annotations/options (e.g., `has_option: "*.page"`) |
|
|
64
51
|
| **Git Proto Sources** | Fetch proto definitions directly from Git repositories |
|
|
65
52
|
| **Multiple Write Modes** | `overwrite`, `append`, or `write-once` file generation |
|
|
66
53
|
| **Custom Hooks** | Extend with your own Jinja filters and helpers |
|
|
@@ -68,6 +55,7 @@ handlers — this tool has you covered.
|
|
|
68
55
|
| **Dry Run Mode** | Preview changes without writing files |
|
|
69
56
|
| **Verbose Output** | Debug with detailed proto and context inspection |
|
|
70
57
|
| **Nested Type Support** | Full support for nested messages and enums |
|
|
58
|
+
| **Options Extraction** | Access message, field, and enum options in templates |
|
|
71
59
|
|
|
72
60
|
---
|
|
73
61
|
|
|
@@ -208,9 +196,18 @@ The CLI is built with [Typer](https://typer.tiangolo.com/) and provides rich, co
|
|
|
208
196
|
#### `generate` — Generate code from protos
|
|
209
197
|
|
|
210
198
|
```bash
|
|
211
|
-
# Basic generation
|
|
199
|
+
# Basic generation (all items)
|
|
212
200
|
wizard-codegen generate
|
|
213
201
|
|
|
202
|
+
# Generate only specific items by name
|
|
203
|
+
wizard-codegen generate SampleProtoPage
|
|
204
|
+
|
|
205
|
+
# Generate multiple specific items
|
|
206
|
+
wizard-codegen generate SampleProtoPage UserFormPage
|
|
207
|
+
|
|
208
|
+
# Use glob patterns to match item names
|
|
209
|
+
wizard-codegen generate "*Page"
|
|
210
|
+
|
|
214
211
|
# With custom config file
|
|
215
212
|
wizard-codegen --config path/to/codegen.yaml generate
|
|
216
213
|
|
|
@@ -220,10 +217,12 @@ wizard-codegen --dry-run generate
|
|
|
220
217
|
# Verbose mode (detailed output)
|
|
221
218
|
wizard-codegen --verbose generate
|
|
222
219
|
|
|
223
|
-
# Combine flags
|
|
224
|
-
wizard-codegen --verbose --dry-run
|
|
220
|
+
# Combine flags and name filter
|
|
221
|
+
wizard-codegen --verbose --dry-run generate SampleProtoPage
|
|
225
222
|
```
|
|
226
223
|
|
|
224
|
+
The optional positional `NAMES` arguments filter which items are generated at runtime. This works alongside any `where` clauses in your config — the config filters are applied first, then the name filter narrows the results further. Glob patterns (`*Page`, `Sample*`) and regex patterns are supported. When no names are provided, all matching items are generated as usual.
|
|
225
|
+
|
|
227
226
|
#### `list-protos` — List discovered proto files
|
|
228
227
|
|
|
229
228
|
```bash
|
|
@@ -455,13 +454,14 @@ where:
|
|
|
455
454
|
|
|
456
455
|
##### Predicate Fields
|
|
457
456
|
|
|
458
|
-
| Field | Description
|
|
459
|
-
|
|
460
|
-
| `name` | Message/enum/service name
|
|
461
|
-
| `package` | Proto package name
|
|
462
|
-
| `file` | Proto file path
|
|
463
|
-
| `full_name` | Fully qualified name
|
|
464
|
-
| `option.equals` | Match proto options
|
|
457
|
+
| Field | Description | Example |
|
|
458
|
+
|-----------------|---------------------------------------|------------------------------------------|
|
|
459
|
+
| `name` | Message/enum/service name | `"*Form"`, `"User*"`, `"Order"` |
|
|
460
|
+
| `package` | Proto package name | `"wizard.*"`, `"com.example.*"` |
|
|
461
|
+
| `file` | Proto file path | `"*/shared/*"`, `"user/*.proto"` |
|
|
462
|
+
| `full_name` | Fully qualified name | `".wizard.UserForm"` |
|
|
463
|
+
| `option.equals` | Match proto options | `{ key: "deprecated", value: true }` |
|
|
464
|
+
| `has_option` | Check if option/annotation is present | `"*.page"`, `"annotations.wizard.v1.page"` |
|
|
465
465
|
|
|
466
466
|
Patterns support:
|
|
467
467
|
|
|
@@ -523,7 +523,10 @@ When a template is rendered, it receives a rich context:
|
|
|
523
523
|
"messages": [...],
|
|
524
524
|
"enums": [...],
|
|
525
525
|
"services": [...],
|
|
526
|
-
"options":
|
|
526
|
+
"options": {
|
|
527
|
+
# Extracted file options (standard and extensions)
|
|
528
|
+
# e.g., {"java_package": "com.example", "go_package": "example/pb"}
|
|
529
|
+
},
|
|
527
530
|
}
|
|
528
531
|
```
|
|
529
532
|
|
|
@@ -544,11 +547,16 @@ When a template is rendered, it receives a rich context:
|
|
|
544
547
|
"type_name": "", # For message/enum refs: ".package.Type"
|
|
545
548
|
"json_name": "userName",
|
|
546
549
|
"field": < FieldDescriptor >,
|
|
550
|
+
"options": {}, # Extracted field options
|
|
547
551
|
},
|
|
548
552
|
...
|
|
549
553
|
],
|
|
550
554
|
"nested_messages": [...],
|
|
551
555
|
"nested_enums": [...],
|
|
556
|
+
"options": {
|
|
557
|
+
# Extracted message options (standard and extensions)
|
|
558
|
+
# Example: {"annotations.wizard.v1.page": True}
|
|
559
|
+
},
|
|
552
560
|
}
|
|
553
561
|
```
|
|
554
562
|
|
|
@@ -565,6 +573,7 @@ When a template is rendered, it receives a rich context:
|
|
|
565
573
|
{"name": "STATUS_ACTIVE", "number": 1},
|
|
566
574
|
...
|
|
567
575
|
],
|
|
576
|
+
"options": {}, # Extracted enum options
|
|
568
577
|
}
|
|
569
578
|
```
|
|
570
579
|
|
|
@@ -573,6 +582,7 @@ When a template is rendered, it receives a rich context:
|
|
|
573
582
|
```python
|
|
574
583
|
{
|
|
575
584
|
"name": Name("UserService"),
|
|
585
|
+
"full_name": ".wizard.UserService",
|
|
576
586
|
"file": "user/user_service.proto",
|
|
577
587
|
"package": "wizard",
|
|
578
588
|
"methods": [
|
|
@@ -582,10 +592,11 @@ When a template is rendered, it receives a rich context:
|
|
|
582
592
|
"output_type": ".wizard.User",
|
|
583
593
|
"client_streaming": False,
|
|
584
594
|
"server_streaming": False,
|
|
585
|
-
"options":
|
|
586
|
-
},
|
|
587
|
-
...
|
|
588
|
-
],
|
|
595
|
+
"options": {}, # Extracted method options
|
|
596
|
+
},
|
|
597
|
+
...
|
|
598
|
+
],
|
|
599
|
+
"options": {}, # Extracted service options
|
|
589
600
|
}
|
|
590
601
|
```
|
|
591
602
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import typer
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from contextlib import contextmanager
|
|
5
6
|
from rich.console import Console
|
|
6
7
|
from rich.table import Table
|
|
7
8
|
from rich.panel import Panel
|
|
@@ -18,6 +19,17 @@ app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich")
|
|
|
18
19
|
console = Console()
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
@contextmanager
|
|
23
|
+
def _step(label: str, verbose: bool = False):
|
|
24
|
+
"""Show a Rich spinner while a pipeline step runs. In verbose mode, print the label instead."""
|
|
25
|
+
if verbose:
|
|
26
|
+
console.print(f"[dim]→ {label}[/]")
|
|
27
|
+
yield
|
|
28
|
+
else:
|
|
29
|
+
with console.status(f"[bold]{label}[/]", spinner="dots"):
|
|
30
|
+
yield
|
|
31
|
+
|
|
32
|
+
|
|
21
33
|
def version_callback(value: bool):
|
|
22
34
|
if value:
|
|
23
35
|
ver = get_version("wizard-codegen")
|
|
@@ -59,25 +71,44 @@ def common(
|
|
|
59
71
|
|
|
60
72
|
|
|
61
73
|
@app.command(help="Generate code from protos")
|
|
62
|
-
def generate(
|
|
74
|
+
def generate(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
names: Optional[list[str]] = typer.Argument(
|
|
77
|
+
default=None,
|
|
78
|
+
help="Only generate for items matching these names (supports glob patterns)",
|
|
79
|
+
),
|
|
80
|
+
):
|
|
81
|
+
verbose = ctx.obj.verbose
|
|
63
82
|
_print_verbose_enabled(ctx)
|
|
64
83
|
config = _load_config(ctx.obj.config_path, ctx)
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
|
|
85
|
+
with _step("Resolving proto source", verbose):
|
|
86
|
+
proto_root = resolve_proto_root(config, use_local=ctx.obj.local)
|
|
87
|
+
|
|
88
|
+
with _step("Discovering proto files", verbose):
|
|
89
|
+
files = discover_proto_files(proto_root, config)
|
|
67
90
|
_validate_proto_files(files, config, proto_root)
|
|
68
|
-
if
|
|
91
|
+
if verbose:
|
|
69
92
|
_print_files_table(files, proto_root)
|
|
70
93
|
|
|
71
|
-
|
|
94
|
+
with _step("Running protoc", verbose):
|
|
95
|
+
fds_path, tmp_dir = build_descriptor_set(config, proto_root, files, verbose)
|
|
72
96
|
print_fds_content(fds_path, ctx, console)
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
|
|
98
|
+
with _step("Building context", verbose):
|
|
99
|
+
fds = load_fds(fds_path)
|
|
100
|
+
jinja_ctx = build_context(config, fds)
|
|
75
101
|
print_build_context(jinja_ctx, ctx, console)
|
|
76
|
-
plan = render_all(config, jinja_ctx)
|
|
77
102
|
|
|
78
|
-
|
|
103
|
+
with _step("Rendering templates", verbose):
|
|
104
|
+
plan = render_all(config, jinja_ctx, name_filter=names)
|
|
105
|
+
|
|
106
|
+
if names and not plan:
|
|
107
|
+
console.print(f"[bold yellow]Warning:[/] No items matched: {', '.join(names)}")
|
|
108
|
+
|
|
109
|
+
with _step("Writing files", verbose):
|
|
110
|
+
apply_plan(plan, ctx.obj.dry_run, verbose)
|
|
79
111
|
|
|
80
|
-
# Cleanup Tmp Dir
|
|
81
112
|
if tmp_dir:
|
|
82
113
|
shutil.rmtree(tmp_dir)
|
|
83
114
|
|
|
@@ -89,32 +120,45 @@ def generate(ctx: typer.Context):
|
|
|
89
120
|
|
|
90
121
|
@app.command("list-protos", help="List available protos")
|
|
91
122
|
def list_protos(ctx: typer.Context):
|
|
123
|
+
verbose = ctx.obj.verbose
|
|
92
124
|
_print_verbose_enabled(ctx)
|
|
93
125
|
config = _load_config(ctx.obj.config_path, ctx)
|
|
94
|
-
|
|
95
|
-
|
|
126
|
+
|
|
127
|
+
with _step("Resolving proto source", verbose):
|
|
128
|
+
proto_root = resolve_proto_root(config, use_local=ctx.obj.local)
|
|
129
|
+
|
|
130
|
+
with _step("Discovering proto files", verbose):
|
|
131
|
+
files = discover_proto_files(proto_root, config)
|
|
96
132
|
_print_files_table(files, proto_root)
|
|
97
133
|
|
|
98
134
|
|
|
99
135
|
@app.command(help="Validate the jinja2 templates, resolves proto root (git checkout), runs protoc descriptor build if needed, prints missing filters/variables")
|
|
100
136
|
def validate(ctx: typer.Context):
|
|
137
|
+
verbose = ctx.obj.verbose
|
|
101
138
|
_print_verbose_enabled(ctx)
|
|
102
139
|
config = _load_config(ctx.obj.config_path, ctx)
|
|
103
|
-
proto_root = resolve_proto_root(config, use_local=ctx.obj.local)
|
|
104
|
-
files = discover_proto_files(proto_root, config)
|
|
105
|
-
_validate_proto_files(files, config, proto_root)
|
|
106
140
|
|
|
107
|
-
|
|
141
|
+
with _step("Resolving proto source", verbose):
|
|
142
|
+
proto_root = resolve_proto_root(config, use_local=ctx.obj.local)
|
|
143
|
+
|
|
144
|
+
with _step("Discovering proto files", verbose):
|
|
145
|
+
files = discover_proto_files(proto_root, config)
|
|
146
|
+
_validate_proto_files(files, config, proto_root)
|
|
147
|
+
if verbose:
|
|
108
148
|
_print_files_table(files, proto_root)
|
|
109
149
|
|
|
110
|
-
|
|
150
|
+
with _step("Running protoc", verbose):
|
|
151
|
+
fds_path, tmp_dir = build_descriptor_set(config, proto_root, files, verbose)
|
|
111
152
|
print_fds_content(fds_path, ctx, console)
|
|
112
|
-
|
|
113
|
-
|
|
153
|
+
|
|
154
|
+
with _step("Building context", verbose):
|
|
155
|
+
fds = load_fds(fds_path)
|
|
156
|
+
jinja_ctx = build_context(config, fds)
|
|
114
157
|
print_build_context(jinja_ctx, ctx, console)
|
|
115
|
-
render_all(config, jinja_ctx)
|
|
116
158
|
|
|
117
|
-
|
|
159
|
+
with _step("Rendering templates", verbose):
|
|
160
|
+
render_all(config, jinja_ctx)
|
|
161
|
+
|
|
118
162
|
if tmp_dir:
|
|
119
163
|
shutil.rmtree(tmp_dir)
|
|
120
164
|
|
|
@@ -33,6 +33,7 @@ class Predicate(BaseModel):
|
|
|
33
33
|
file: str | None = None
|
|
34
34
|
full_name: str | None = None
|
|
35
35
|
option_equals: EqualsKV | None = Field(default=None, alias="option.equals")
|
|
36
|
+
has_option: str | None = Field(default=None, alias="has_option")
|
|
36
37
|
|
|
37
38
|
model_config = {"populate_by_name": True}
|
|
38
39
|
|
|
@@ -11,6 +11,7 @@ from rich.pretty import Pretty
|
|
|
11
11
|
|
|
12
12
|
from .config import CodegenConfig
|
|
13
13
|
from utils import Name
|
|
14
|
+
from utils.options import extract_options, build_extension_registry
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def topo_order(files_by_name: dict[str, descriptor_pb2.FileDescriptorProto]) -> list[str]:
|
|
@@ -55,7 +56,11 @@ def _build_field_data(field: Any) -> Dict[str, Any]:
|
|
|
55
56
|
"type": int(field.type),
|
|
56
57
|
"type_name": field.type_name,
|
|
57
58
|
"json_name": field.json_name,
|
|
58
|
-
"field": field
|
|
59
|
+
"field": field,
|
|
60
|
+
"options": extract_options(
|
|
61
|
+
getattr(field, 'options', None),
|
|
62
|
+
extendee_type=".google.protobuf.FieldOptions"
|
|
63
|
+
),
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
|
|
@@ -67,13 +72,23 @@ def _build_method_data(method: Any) -> Dict[str, Any]:
|
|
|
67
72
|
"output_type": method.output_type,
|
|
68
73
|
"client_streaming": bool(method.client_streaming),
|
|
69
74
|
"server_streaming": bool(method.server_streaming),
|
|
70
|
-
"options":
|
|
75
|
+
"options": extract_options(
|
|
76
|
+
getattr(method, 'options', None),
|
|
77
|
+
extendee_type=".google.protobuf.MethodOptions"
|
|
78
|
+
),
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
def _build_enum_value_data(value: Any) -> Dict[str, Any]:
|
|
75
83
|
"""Build enum value data dictionary from protobuf enum value descriptor."""
|
|
76
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
"name": value.name,
|
|
86
|
+
"number": value.number,
|
|
87
|
+
"options": extract_options(
|
|
88
|
+
getattr(value, 'options', None),
|
|
89
|
+
extendee_type=".google.protobuf.EnumValueOptions"
|
|
90
|
+
),
|
|
91
|
+
}
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
def _update_type_index(type_index: Dict[str, Any], full_name: str, kind: str, file_name: str, package: str) -> None:
|
|
@@ -99,6 +114,10 @@ def _process_nested_enum(
|
|
|
99
114
|
"file": file_name,
|
|
100
115
|
"package": package,
|
|
101
116
|
"enum_values": enum_values, # Use enum_values to avoid conflict with dict.values()
|
|
117
|
+
"options": extract_options(
|
|
118
|
+
getattr(enum, 'options', None),
|
|
119
|
+
extendee_type=".google.protobuf.EnumOptions"
|
|
120
|
+
),
|
|
102
121
|
}
|
|
103
122
|
|
|
104
123
|
_update_type_index(type_index, full_name, "enum", file_name, package)
|
|
@@ -160,6 +179,10 @@ def _process_message_recursive(
|
|
|
160
179
|
"package": package,
|
|
161
180
|
"nested_messages": nested_messages,
|
|
162
181
|
"nested_enums": nested_enums,
|
|
182
|
+
"options": extract_options(
|
|
183
|
+
getattr(message, 'options', None),
|
|
184
|
+
extendee_type=".google.protobuf.MessageOptions"
|
|
185
|
+
),
|
|
163
186
|
}
|
|
164
187
|
|
|
165
188
|
_update_type_index(type_index, full_name, "message", file_name, package)
|
|
@@ -214,6 +237,10 @@ def _process_enums(
|
|
|
214
237
|
"file": file_name,
|
|
215
238
|
"package": package,
|
|
216
239
|
"enum_values": enum_values, # Use enum_values to avoid conflict with dict.values()
|
|
240
|
+
"options": extract_options(
|
|
241
|
+
getattr(enum, 'options', None),
|
|
242
|
+
extendee_type=".google.protobuf.EnumOptions"
|
|
243
|
+
),
|
|
217
244
|
}
|
|
218
245
|
|
|
219
246
|
enums.append(enum_data)
|
|
@@ -238,9 +265,14 @@ def _process_services(
|
|
|
238
265
|
|
|
239
266
|
service_data = {
|
|
240
267
|
"name": Name(service.name),
|
|
268
|
+
"full_name": full_name,
|
|
241
269
|
"methods": methods,
|
|
242
270
|
"file": file_name,
|
|
243
|
-
"package": package
|
|
271
|
+
"package": package,
|
|
272
|
+
"options": extract_options(
|
|
273
|
+
getattr(service, 'options', None),
|
|
274
|
+
extendee_type=".google.protobuf.ServiceOptions"
|
|
275
|
+
),
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
services.append(service_data)
|
|
@@ -274,12 +306,18 @@ def _process_proto_file(
|
|
|
274
306
|
"messages": messages,
|
|
275
307
|
"enums": enums,
|
|
276
308
|
"services": services,
|
|
277
|
-
"options":
|
|
309
|
+
"options": extract_options(
|
|
310
|
+
getattr(proto_file, 'options', None),
|
|
311
|
+
extendee_type=".google.protobuf.FileOptions"
|
|
312
|
+
),
|
|
278
313
|
}
|
|
279
314
|
|
|
280
315
|
|
|
281
316
|
def build_context(cfg: CodegenConfig, fds: descriptor_pb2.FileDescriptorSet) -> Dict[str, Any]:
|
|
282
317
|
"""Build the complete context for code generation from proto file descriptors."""
|
|
318
|
+
# Build extension registry for extracting custom options
|
|
319
|
+
build_extension_registry(fds)
|
|
320
|
+
|
|
283
321
|
files_by_name = {f.name: f for f in fds.file}
|
|
284
322
|
ordered_files = topo_order(files_by_name)
|
|
285
323
|
|
|
@@ -2,12 +2,18 @@ from __future__ import annotations
|
|
|
2
2
|
import re
|
|
3
3
|
from typing import Any
|
|
4
4
|
from .config import Where, Predicate
|
|
5
|
+
from utils import has_option as _has_option_util
|
|
5
6
|
import fnmatch
|
|
6
7
|
|
|
7
8
|
def _get_opt(item: dict, key: str) -> Any:
|
|
8
9
|
# expect custom options extracted into item["options"] as dict
|
|
9
10
|
return (item.get("options") or {}).get(key)
|
|
10
11
|
|
|
12
|
+
def _has_opt(item: dict, option_name: str) -> bool:
|
|
13
|
+
"""Check if an item has a specific option (supports suffix matching)."""
|
|
14
|
+
options = item.get("options") or {}
|
|
15
|
+
return _has_option_util(options, option_name)
|
|
16
|
+
|
|
11
17
|
def _match_regex(value: str | None, pattern: str) -> bool:
|
|
12
18
|
if value is None:
|
|
13
19
|
return False
|
|
@@ -30,6 +36,9 @@ def _pred_ok(item: dict, p: Predicate) -> bool:
|
|
|
30
36
|
if p.option_equals:
|
|
31
37
|
if _get_opt(item, p.option_equals.key) != p.option_equals.value:
|
|
32
38
|
return False
|
|
39
|
+
if p.has_option:
|
|
40
|
+
if not _has_opt(item, p.has_option):
|
|
41
|
+
return False
|
|
33
42
|
return True
|
|
34
43
|
|
|
35
44
|
def where_ok(item: dict, where: Where | None) -> bool:
|
|
@@ -53,6 +62,11 @@ def where_ok(item: dict, where: Where | None) -> bool:
|
|
|
53
62
|
|
|
54
63
|
return True
|
|
55
64
|
|
|
65
|
+
def matches_name_filter(item: dict, names: list[str]) -> bool:
|
|
66
|
+
"""Return True if item's name matches any of the given name patterns."""
|
|
67
|
+
raw = _name_raw(item)
|
|
68
|
+
return any(_match_regex(raw, n) for n in names)
|
|
69
|
+
|
|
56
70
|
def _name_raw(item: dict) -> str | None:
|
|
57
71
|
n = item.get("name")
|
|
58
72
|
if n is None:
|
|
@@ -62,4 +76,4 @@ def _name_raw(item: dict) -> str | None:
|
|
|
62
76
|
if isinstance(n, dict):
|
|
63
77
|
return n.get("raw")
|
|
64
78
|
# dataclass / pydantic / simple object
|
|
65
|
-
return getattr(n, "raw", str(n))
|
|
79
|
+
return getattr(n, "raw", str(n))
|
|
@@ -5,7 +5,7 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
|
5
5
|
|
|
6
6
|
from .config import CodegenConfig
|
|
7
7
|
from hooks import load_hooks
|
|
8
|
-
from .filter import where_ok
|
|
8
|
+
from .filter import where_ok, matches_name_filter
|
|
9
9
|
from utils import expand_path
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
@@ -46,7 +46,12 @@ def _render_item(item, context: dict, tpl, out_tpl, out_root: Path, mode: str) -
|
|
|
46
46
|
content = tpl.render(**ctx)
|
|
47
47
|
return PlanItem(output_path=out_root / rel_out, content=content, mode=mode)
|
|
48
48
|
|
|
49
|
-
def render_all(
|
|
49
|
+
def render_all(
|
|
50
|
+
cfg: CodegenConfig,
|
|
51
|
+
context: dict,
|
|
52
|
+
out_override: Path | None = None,
|
|
53
|
+
name_filter: list[str] | None = None,
|
|
54
|
+
) -> list[PlanItem]:
|
|
50
55
|
plan: list[PlanItem] = []
|
|
51
56
|
|
|
52
57
|
for target, tcfg in cfg.targets.items():
|
|
@@ -68,6 +73,8 @@ def render_all(cfg: CodegenConfig, context: dict, out_override: Path | None = No
|
|
|
68
73
|
for item in items:
|
|
69
74
|
if not where_ok(item, ep.where):
|
|
70
75
|
continue
|
|
76
|
+
if name_filter and not matches_name_filter(item, name_filter):
|
|
77
|
+
continue
|
|
71
78
|
plan_item = _render_item(item, context, tpl, out_tpl, out_root, ep.mode)
|
|
72
79
|
plan.append(plan_item)
|
|
73
80
|
else:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wizard-codegen"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "A powerful, template-driven code generation tool for Protocol Buffers"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -9,9 +9,9 @@ dependencies = [
|
|
|
9
9
|
"pydantic==2.12.5",
|
|
10
10
|
"pydantic_core==2.41.5",
|
|
11
11
|
"jinja2==3.1.6",
|
|
12
|
-
"rich==14.3.
|
|
12
|
+
"rich==14.3.3",
|
|
13
13
|
"click==8.3.1",
|
|
14
|
-
"protobuf==6.33.
|
|
14
|
+
"protobuf==6.33.5",
|
|
15
15
|
"pyyaml==6.0.3",
|
|
16
16
|
]
|
|
17
17
|
|
|
@@ -6,6 +6,7 @@ Contains helper functions for name transformations and other utilities.
|
|
|
6
6
|
|
|
7
7
|
from .name import Name, to_snake, to_kebab, to_pascal, to_camel, to_macro_snake, to_macro, to_micro
|
|
8
8
|
from .path import expand_path
|
|
9
|
+
from .options import extract_options, has_option, get_option, build_extension_registry
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
11
12
|
"Name",
|
|
@@ -17,4 +18,8 @@ __all__ = [
|
|
|
17
18
|
"to_micro",
|
|
18
19
|
"to_macro_snake",
|
|
19
20
|
"expand_path",
|
|
21
|
+
"extract_options",
|
|
22
|
+
"has_option",
|
|
23
|
+
"get_option",
|
|
24
|
+
"build_extension_registry",
|
|
20
25
|
]
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Utility functions for extracting protobuf options and annotations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
import logging
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
# Global extension registry: maps (extendee_type, field_number) -> (full_name, type_name, field_type)
|
|
10
|
+
# field_type is the protobuf type enum (TYPE_BOOL, TYPE_INT32, etc.) for decoding primitives.
|
|
11
|
+
# This is populated by build_extension_registry() when loading the FDS
|
|
12
|
+
_extension_registry: Dict[tuple, Tuple[str, str, int]] = {}
|
|
13
|
+
|
|
14
|
+
# Global message registry: maps full_name -> list of (field_number, field_name, field_type)
|
|
15
|
+
_message_registry: Dict[str, List[Tuple[int, str, int]]] = {}
|
|
16
|
+
|
|
17
|
+
# Proto wire types
|
|
18
|
+
WIRETYPE_VARINT = 0
|
|
19
|
+
WIRETYPE_FIXED64 = 1
|
|
20
|
+
WIRETYPE_LENGTH_DELIMITED = 2
|
|
21
|
+
WIRETYPE_FIXED32 = 5
|
|
22
|
+
|
|
23
|
+
# Proto field types
|
|
24
|
+
TYPE_DOUBLE = 1
|
|
25
|
+
TYPE_FLOAT = 2
|
|
26
|
+
TYPE_INT64 = 3
|
|
27
|
+
TYPE_UINT64 = 4
|
|
28
|
+
TYPE_INT32 = 5
|
|
29
|
+
TYPE_FIXED64 = 6
|
|
30
|
+
TYPE_FIXED32 = 7
|
|
31
|
+
TYPE_BOOL = 8
|
|
32
|
+
TYPE_STRING = 9
|
|
33
|
+
TYPE_MESSAGE = 11
|
|
34
|
+
TYPE_BYTES = 12
|
|
35
|
+
TYPE_UINT32 = 13
|
|
36
|
+
TYPE_ENUM = 14
|
|
37
|
+
TYPE_SFIXED32 = 15
|
|
38
|
+
TYPE_SFIXED64 = 16
|
|
39
|
+
TYPE_SINT32 = 17
|
|
40
|
+
TYPE_SINT64 = 18
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_extension_registry(fds: Any) -> Dict[tuple, Tuple[str, str, int]]:
|
|
44
|
+
"""
|
|
45
|
+
Build a registry of extensions from a FileDescriptorSet.
|
|
46
|
+
|
|
47
|
+
This extracts all extension definitions and creates a mapping from
|
|
48
|
+
(extendee, field_number) to (extension_full_name, extension_type_name, field_type).
|
|
49
|
+
|
|
50
|
+
Also builds a message registry for parsing extension message values.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
fds: A FileDescriptorSet protobuf message
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A dictionary mapping (extendee_type, field_number) to (full_name, type_name, field_type).
|
|
57
|
+
"""
|
|
58
|
+
global _extension_registry, _message_registry
|
|
59
|
+
registry: Dict[tuple, Tuple[str, str, int]] = {}
|
|
60
|
+
msg_registry: Dict[str, List[Tuple[int, str, int]]] = {}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
for file_desc in fds.file:
|
|
64
|
+
# Get package prefix for building full names
|
|
65
|
+
package = file_desc.package
|
|
66
|
+
|
|
67
|
+
# Build message registry for all messages
|
|
68
|
+
def register_message(msg, parent_name: str):
|
|
69
|
+
msg_full_name = f"{parent_name}.{msg.name}" if parent_name else f".{msg.name}"
|
|
70
|
+
if package and not parent_name:
|
|
71
|
+
msg_full_name = f".{package}.{msg.name}"
|
|
72
|
+
|
|
73
|
+
fields = []
|
|
74
|
+
for field in msg.field:
|
|
75
|
+
fields.append((field.number, field.name, field.type))
|
|
76
|
+
msg_registry[msg_full_name] = fields
|
|
77
|
+
|
|
78
|
+
for nested in msg.nested_type:
|
|
79
|
+
register_message(nested, msg_full_name)
|
|
80
|
+
|
|
81
|
+
for msg in file_desc.message_type:
|
|
82
|
+
register_message(msg, "")
|
|
83
|
+
|
|
84
|
+
# Process file-level extensions
|
|
85
|
+
for ext in file_desc.extension:
|
|
86
|
+
extendee = ext.extendee # e.g., ".google.protobuf.MessageOptions"
|
|
87
|
+
field_number = ext.number
|
|
88
|
+
type_name = ext.type_name # e.g., ".annotations.wizard.v1.WizardPageOptions"
|
|
89
|
+
raw_type = getattr(ext, "type", 0)
|
|
90
|
+
field_type = int(raw_type) if isinstance(raw_type, int) else 0 # TYPE_* enum for decoding primitives
|
|
91
|
+
# Build full name: package.name
|
|
92
|
+
full_name = f"{package}.{ext.name}" if package else ext.name
|
|
93
|
+
registry[(extendee, field_number)] = (full_name, type_name, field_type)
|
|
94
|
+
|
|
95
|
+
# Process extensions nested in messages
|
|
96
|
+
def process_nested_extensions(msg, parent_name: str):
|
|
97
|
+
for ext in msg.extension:
|
|
98
|
+
extendee = ext.extendee
|
|
99
|
+
field_number = ext.number
|
|
100
|
+
type_name = ext.type_name
|
|
101
|
+
raw_type = getattr(ext, "type", 0)
|
|
102
|
+
field_type = int(raw_type) if isinstance(raw_type, int) else 0
|
|
103
|
+
full_name = f"{parent_name}.{ext.name}"
|
|
104
|
+
registry[(extendee, field_number)] = (full_name, type_name, field_type)
|
|
105
|
+
|
|
106
|
+
for nested in msg.nested_type:
|
|
107
|
+
nested_name = f"{parent_name}.{nested.name}"
|
|
108
|
+
process_nested_extensions(nested, nested_name)
|
|
109
|
+
|
|
110
|
+
for msg in file_desc.message_type:
|
|
111
|
+
msg_name = f"{package}.{msg.name}" if package else msg.name
|
|
112
|
+
process_nested_extensions(msg, msg_name)
|
|
113
|
+
except (AttributeError, TypeError) as e:
|
|
114
|
+
# Intentionally ignore descriptor shape/type issues and fall back to empty registries.
|
|
115
|
+
# This allows callers to treat "unparseable" FileDescriptorSet objects as having no extensions.
|
|
116
|
+
_ = e
|
|
117
|
+
|
|
118
|
+
_extension_registry = registry
|
|
119
|
+
_message_registry = msg_registry
|
|
120
|
+
return registry
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_varint(data: bytes, pos: int) -> tuple:
|
|
124
|
+
"""Parse a varint from bytes at the given position."""
|
|
125
|
+
result = 0
|
|
126
|
+
shift = 0
|
|
127
|
+
while pos < len(data):
|
|
128
|
+
byte = data[pos]
|
|
129
|
+
result |= (byte & 0x7f) << shift
|
|
130
|
+
pos += 1
|
|
131
|
+
if not (byte & 0x80):
|
|
132
|
+
break
|
|
133
|
+
shift += 7
|
|
134
|
+
return result, pos
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _decode_zigzag(n: int) -> int:
|
|
138
|
+
"""Decode a ZigZag-encoded signed integer."""
|
|
139
|
+
return (n >> 1) ^ -(n & 1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _decode_varint_value(raw: int, field_type: int) -> Any:
|
|
143
|
+
"""Decode a varint value according to protobuf field type (TYPE_*)."""
|
|
144
|
+
if field_type == TYPE_BOOL:
|
|
145
|
+
return bool(raw)
|
|
146
|
+
if field_type in (TYPE_SINT32, TYPE_SINT64):
|
|
147
|
+
return _decode_zigzag(raw)
|
|
148
|
+
return raw
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _decode_fixed64_value(data: bytes, pos: int, field_type: int) -> Tuple[Any, int]:
|
|
152
|
+
"""Decode 8 bytes at pos as FIXED64; return (value, new_pos)."""
|
|
153
|
+
if field_type == TYPE_DOUBLE:
|
|
154
|
+
return struct.unpack("<d", data[pos : pos + 8])[0], pos + 8
|
|
155
|
+
return int.from_bytes(data[pos : pos + 8], "little"), pos + 8
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _decode_fixed32_value(data: bytes, pos: int, field_type: int) -> Tuple[Any, int]:
|
|
159
|
+
"""Decode 4 bytes at pos as FIXED32; return (value, new_pos)."""
|
|
160
|
+
if field_type == TYPE_FLOAT:
|
|
161
|
+
return struct.unpack("<f", data[pos : pos + 4])[0], pos + 4
|
|
162
|
+
return int.from_bytes(data[pos : pos + 4], "little"), pos + 4
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_message_bytes(data: bytes, type_name: str) -> Dict[str, Any]:
|
|
166
|
+
"""
|
|
167
|
+
Parse a serialized protobuf message into a dictionary.
|
|
168
|
+
|
|
169
|
+
Uses the message registry to map field numbers to names.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
data: Serialized protobuf message bytes
|
|
173
|
+
type_name: Full name of the message type (e.g., ".annotations.wizard.v1.WizardPageOptions")
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
A dictionary of field names to values.
|
|
177
|
+
"""
|
|
178
|
+
result: Dict[str, Any] = {}
|
|
179
|
+
|
|
180
|
+
# Get field definitions for this message type
|
|
181
|
+
fields = _message_registry.get(type_name, [])
|
|
182
|
+
if not fields:
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
# Build field lookup: number -> (name, type)
|
|
186
|
+
field_lookup: Dict[int, Tuple[str, int]] = {
|
|
187
|
+
f[0]: (f[1], f[2]) for f in fields
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pos = 0
|
|
191
|
+
while pos < len(data):
|
|
192
|
+
try:
|
|
193
|
+
# Parse tag
|
|
194
|
+
tag, pos = _parse_varint(data, pos)
|
|
195
|
+
field_number = tag >> 3
|
|
196
|
+
wire_type = tag & 0x7
|
|
197
|
+
|
|
198
|
+
value: Any = None
|
|
199
|
+
|
|
200
|
+
if wire_type == WIRETYPE_VARINT:
|
|
201
|
+
value, pos = _parse_varint(data, pos)
|
|
202
|
+
# Check if it's a bool
|
|
203
|
+
if field_number in field_lookup:
|
|
204
|
+
_, field_type = field_lookup[field_number]
|
|
205
|
+
if field_type == TYPE_BOOL:
|
|
206
|
+
value = bool(value)
|
|
207
|
+
elif field_type in (TYPE_SINT32, TYPE_SINT64):
|
|
208
|
+
value = _decode_zigzag(value)
|
|
209
|
+
|
|
210
|
+
elif wire_type == WIRETYPE_FIXED64:
|
|
211
|
+
value = int.from_bytes(data[pos:pos+8], 'little')
|
|
212
|
+
pos += 8
|
|
213
|
+
|
|
214
|
+
elif wire_type == WIRETYPE_LENGTH_DELIMITED:
|
|
215
|
+
length, pos = _parse_varint(data, pos)
|
|
216
|
+
raw_bytes = data[pos:pos+length]
|
|
217
|
+
pos += length
|
|
218
|
+
|
|
219
|
+
# Determine if it's a string or bytes based on field type
|
|
220
|
+
if field_number in field_lookup:
|
|
221
|
+
_, field_type = field_lookup[field_number]
|
|
222
|
+
if field_type == TYPE_STRING:
|
|
223
|
+
value = raw_bytes.decode('utf-8')
|
|
224
|
+
elif field_type == TYPE_BYTES:
|
|
225
|
+
value = raw_bytes
|
|
226
|
+
elif field_type == TYPE_MESSAGE:
|
|
227
|
+
# Recursively parse nested message
|
|
228
|
+
# We'd need to look up the nested type - for now just mark as present
|
|
229
|
+
value = True
|
|
230
|
+
else:
|
|
231
|
+
value = raw_bytes
|
|
232
|
+
else:
|
|
233
|
+
# Try to decode as string, fall back to bytes
|
|
234
|
+
try:
|
|
235
|
+
value = raw_bytes.decode('utf-8')
|
|
236
|
+
except UnicodeDecodeError:
|
|
237
|
+
value = raw_bytes
|
|
238
|
+
|
|
239
|
+
elif wire_type == WIRETYPE_FIXED32:
|
|
240
|
+
value = int.from_bytes(data[pos:pos+4], 'little')
|
|
241
|
+
pos += 4
|
|
242
|
+
else:
|
|
243
|
+
# Unknown wire type, skip
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
# Map field number to name
|
|
247
|
+
if field_number in field_lookup:
|
|
248
|
+
field_name, _ = field_lookup[field_number]
|
|
249
|
+
result[field_name] = value
|
|
250
|
+
|
|
251
|
+
except (IndexError, UnicodeDecodeError):
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _extract_unknown_extensions(options: Any, extendee_type: str) -> Dict[str, Any]:
|
|
258
|
+
"""
|
|
259
|
+
Extract extension options from serialized unknown fields.
|
|
260
|
+
|
|
261
|
+
Custom extensions that aren't registered with Python protobuf are stored
|
|
262
|
+
as serialized bytes. This function parses those bytes to extract
|
|
263
|
+
extension field numbers and matches them against our registry.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
options: A protobuf options message
|
|
267
|
+
extendee_type: The full name of the options type (e.g., ".google.protobuf.MessageOptions")
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
A dictionary of extension names to values.
|
|
271
|
+
"""
|
|
272
|
+
result: Dict[str, Any] = {}
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
# Serialize the options to get all data including unknown fields
|
|
276
|
+
serialized = options.SerializeToString()
|
|
277
|
+
if not serialized:
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
# Parse the wire format to find field numbers
|
|
281
|
+
pos = 0
|
|
282
|
+
while pos < len(serialized):
|
|
283
|
+
# Parse the tag (field number + wire type)
|
|
284
|
+
tag, pos = _parse_varint(serialized, pos)
|
|
285
|
+
field_number = tag >> 3
|
|
286
|
+
wire_type = tag & 0x7
|
|
287
|
+
|
|
288
|
+
value_bytes: bytes = b""
|
|
289
|
+
scalar_value: Any = None # For VARINT/FIXED32/FIXED64
|
|
290
|
+
|
|
291
|
+
# Extract the value based on wire type
|
|
292
|
+
if wire_type == WIRETYPE_VARINT:
|
|
293
|
+
raw_varint, pos = _parse_varint(serialized, pos)
|
|
294
|
+
scalar_value = raw_varint
|
|
295
|
+
elif wire_type == WIRETYPE_FIXED64:
|
|
296
|
+
if (extendee_type, field_number) in _extension_registry:
|
|
297
|
+
_, __, field_type = _extension_registry[(extendee_type, field_number)]
|
|
298
|
+
scalar_value, pos = _decode_fixed64_value(serialized, pos, field_type)
|
|
299
|
+
else:
|
|
300
|
+
pos += 8
|
|
301
|
+
elif wire_type == WIRETYPE_LENGTH_DELIMITED:
|
|
302
|
+
length, pos = _parse_varint(serialized, pos)
|
|
303
|
+
value_bytes = serialized[pos : pos + length]
|
|
304
|
+
pos += length
|
|
305
|
+
elif wire_type == WIRETYPE_FIXED32:
|
|
306
|
+
if (extendee_type, field_number) in _extension_registry:
|
|
307
|
+
_, __, field_type = _extension_registry[(extendee_type, field_number)]
|
|
308
|
+
scalar_value, pos = _decode_fixed32_value(serialized, pos, field_type)
|
|
309
|
+
else:
|
|
310
|
+
pos += 4
|
|
311
|
+
else:
|
|
312
|
+
# Unknown wire type, can't continue
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
# Check if this field number is a known extension
|
|
316
|
+
key = (extendee_type, field_number)
|
|
317
|
+
if key in _extension_registry:
|
|
318
|
+
ext_name, ext_type_name, field_type = _extension_registry[key]
|
|
319
|
+
|
|
320
|
+
# If it's a message type (length-delimited), try to parse the value
|
|
321
|
+
if wire_type == WIRETYPE_LENGTH_DELIMITED and ext_type_name:
|
|
322
|
+
parsed_value = _parse_message_bytes(value_bytes, ext_type_name)
|
|
323
|
+
# If no fields were set, return True to indicate presence
|
|
324
|
+
result[ext_name] = parsed_value if parsed_value else True
|
|
325
|
+
elif scalar_value is not None:
|
|
326
|
+
# VARINT, FIXED32, or FIXED64: use actual decoded value
|
|
327
|
+
if wire_type == WIRETYPE_VARINT:
|
|
328
|
+
result[ext_name] = _decode_varint_value(scalar_value, field_type)
|
|
329
|
+
else:
|
|
330
|
+
result[ext_name] = scalar_value
|
|
331
|
+
else:
|
|
332
|
+
result[ext_name] = True
|
|
333
|
+
|
|
334
|
+
except (AttributeError, TypeError, IndexError) as e:
|
|
335
|
+
# Intentionally ignore malformed / unexpected option data and fall back to an empty result.
|
|
336
|
+
# Log at debug level to aid troubleshooting without breaking callers.
|
|
337
|
+
logging.debug("Failed to extract unknown extensions from options %r: %s", options, e)
|
|
338
|
+
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def extract_options(options: Any, extendee_type: Optional[str] = None) -> Dict[str, Any]:
|
|
343
|
+
"""
|
|
344
|
+
Extract options from a protobuf options message into a dictionary.
|
|
345
|
+
|
|
346
|
+
This function handles both standard protobuf options (like 'deprecated')
|
|
347
|
+
and custom extension options (like 'annotations.wizard.v1.page').
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
options: A protobuf options message (MessageOptions, FieldOptions, etc.)
|
|
351
|
+
or None
|
|
352
|
+
extendee_type: Optional. The full name of the options message type
|
|
353
|
+
(e.g., ".google.protobuf.MessageOptions"). Required for
|
|
354
|
+
extracting custom extensions.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
A dictionary mapping option names to their values.
|
|
358
|
+
Standard options use short names (e.g., 'deprecated').
|
|
359
|
+
Extension options use their full qualified name
|
|
360
|
+
(e.g., 'annotations.wizard.v1.page').
|
|
361
|
+
|
|
362
|
+
Example:
|
|
363
|
+
For a message with option (annotations.wizard.v1.page) = {};
|
|
364
|
+
Returns: {'annotations.wizard.v1.page': True}
|
|
365
|
+
|
|
366
|
+
For a message with option deprecated = true;
|
|
367
|
+
Returns: {'deprecated': True}
|
|
368
|
+
"""
|
|
369
|
+
if options is None:
|
|
370
|
+
return {}
|
|
371
|
+
|
|
372
|
+
result: Dict[str, Any] = {}
|
|
373
|
+
|
|
374
|
+
# First, extract known fields via ListFields()
|
|
375
|
+
try:
|
|
376
|
+
for field_desc, value in options.ListFields():
|
|
377
|
+
# Check if this is an extension field
|
|
378
|
+
if field_desc.is_extension:
|
|
379
|
+
# Use the full name for extension options
|
|
380
|
+
# e.g., "annotations.wizard.v1.page"
|
|
381
|
+
key = field_desc.full_name
|
|
382
|
+
else:
|
|
383
|
+
# Use the simple name for standard options
|
|
384
|
+
# e.g., "deprecated", "java_package"
|
|
385
|
+
key = field_desc.name
|
|
386
|
+
|
|
387
|
+
# Convert the value to a Python-friendly format
|
|
388
|
+
result[key] = _convert_value(value)
|
|
389
|
+
except (AttributeError, TypeError):
|
|
390
|
+
# If options doesn't support ListFields (e.g., Mock object without setup)
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
# Then, extract unknown extensions from serialized data.
|
|
394
|
+
# Only add entries for keys not already in result, so we never overwrite
|
|
395
|
+
# extensions that were properly extracted via ListFields() with lower-fidelity
|
|
396
|
+
# wire-parsed values (e.g., nested messages would become True).
|
|
397
|
+
if extendee_type and _extension_registry:
|
|
398
|
+
unknown_extensions = _extract_unknown_extensions(options, extendee_type)
|
|
399
|
+
for k, v in unknown_extensions.items():
|
|
400
|
+
if k not in result:
|
|
401
|
+
result[k] = v
|
|
402
|
+
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _convert_value(value: Any) -> Any:
|
|
407
|
+
"""
|
|
408
|
+
Convert a protobuf value to a Python-friendly format.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
value: A protobuf value (could be a message, repeated field, etc.)
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
A Python-native representation of the value.
|
|
415
|
+
"""
|
|
416
|
+
# Check if it's a repeated field (list-like)
|
|
417
|
+
if hasattr(value, '__iter__') and not isinstance(value, (str, bytes)):
|
|
418
|
+
try:
|
|
419
|
+
# Try to convert as a list
|
|
420
|
+
return [_convert_value(v) for v in value]
|
|
421
|
+
except TypeError:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
# Check if it's a message type with fields
|
|
425
|
+
if hasattr(value, 'ListFields'):
|
|
426
|
+
# It's a nested message, convert it to a dict
|
|
427
|
+
nested = {}
|
|
428
|
+
try:
|
|
429
|
+
for field_desc, field_value in value.ListFields():
|
|
430
|
+
nested[field_desc.name] = _convert_value(field_value)
|
|
431
|
+
except (AttributeError, TypeError) as exc:
|
|
432
|
+
# Intentionally ignore descriptor/type issues when inspecting nested messages.
|
|
433
|
+
# This keeps option extraction robust for mock or non-standard message-like objects.
|
|
434
|
+
logging.debug("Failed to ListFields() on nested value %r: %s", value, exc)
|
|
435
|
+
# If the message is empty (no fields set), return True to indicate presence
|
|
436
|
+
return nested if nested else True
|
|
437
|
+
|
|
438
|
+
# Check if it's an enum value
|
|
439
|
+
if hasattr(value, 'number') and hasattr(value, 'name'):
|
|
440
|
+
return value.name
|
|
441
|
+
|
|
442
|
+
# Return primitive values as-is
|
|
443
|
+
return value
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def has_option(options: Dict[str, Any], option_name: str) -> bool:
|
|
447
|
+
"""
|
|
448
|
+
Check if an option is present in the extracted options dictionary.
|
|
449
|
+
|
|
450
|
+
Matching modes:
|
|
451
|
+
- Exact match: `"annotations.wizard.v1.page"` matches only that exact key
|
|
452
|
+
- Glob pattern (with `*`): `"*.page"` matches any option ending with `.page`
|
|
453
|
+
- Glob pattern: `"annotations.*"` matches any option starting with `annotations.`
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
options: The extracted options dictionary
|
|
457
|
+
option_name: The option name or glob pattern to check for
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
True if the option is present, False otherwise.
|
|
461
|
+
|
|
462
|
+
Examples:
|
|
463
|
+
has_option({"annotations.wizard.v1.page": True}, "annotations.wizard.v1.page") # True
|
|
464
|
+
has_option({"annotations.wizard.v1.page": True}, "*.page") # True
|
|
465
|
+
has_option({"annotations.wizard.v1.page": True}, "page") # False (no wildcard)
|
|
466
|
+
"""
|
|
467
|
+
import fnmatch
|
|
468
|
+
|
|
469
|
+
if not options:
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
# Check if it's a glob pattern (contains *)
|
|
473
|
+
if "*" in option_name:
|
|
474
|
+
for key in options:
|
|
475
|
+
if fnmatch.fnmatch(key, option_name):
|
|
476
|
+
return True
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
# Exact match only
|
|
480
|
+
return option_name in options
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def get_option(options: Dict[str, Any], option_name: str) -> Any:
|
|
484
|
+
"""
|
|
485
|
+
Get an option value from the extracted options dictionary.
|
|
486
|
+
|
|
487
|
+
Supports the same matching as has_option:
|
|
488
|
+
- Exact match: `"annotations.wizard.v1.page"` matches only that exact key
|
|
489
|
+
- Glob pattern (with `*`): `"*.page"` matches any option ending with `.page`
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
options: The extracted options dictionary
|
|
493
|
+
option_name: The option name or glob pattern to get
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
The option value if found, None otherwise.
|
|
497
|
+
"""
|
|
498
|
+
import fnmatch
|
|
499
|
+
|
|
500
|
+
if not options:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
# Check if it's a glob pattern (contains *)
|
|
504
|
+
if "*" in option_name:
|
|
505
|
+
for key, value in options.items():
|
|
506
|
+
if fnmatch.fnmatch(key, option_name):
|
|
507
|
+
return value
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
# Exact match only
|
|
511
|
+
return options.get(option_name)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|