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.
Files changed (25) hide show
  1. wizard_codegen-0.2.0/README.md → wizard_codegen-0.2.2/PKG-INFO +56 -15
  2. wizard_codegen-0.2.0/PKG-INFO → wizard_codegen-0.2.2/README.md +41 -30
  3. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/cli/main.py +65 -21
  4. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/config.py +1 -0
  5. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/context_builder.py +43 -5
  6. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/filter.py +15 -1
  7. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/renderer.py +9 -2
  8. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/pyproject.toml +3 -3
  9. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/utils/__init__.py +5 -0
  10. wizard_codegen-0.2.2/utils/options.py +511 -0
  11. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/.gitignore +0 -0
  12. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/cli/__init__.py +0 -0
  13. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/__init__.py +0 -0
  14. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/core/writer.py +0 -0
  15. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/hooks/__init__.py +0 -0
  16. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/hooks/hooks.py +0 -0
  17. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/__init__.py +0 -0
  18. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/discover.py +0 -0
  19. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/fds_loader.py +0 -0
  20. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/proto_source.py +0 -0
  21. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/proto/protoc_runner.py +0 -0
  22. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/tests/fixtures/hooks/__init__.py +0 -0
  23. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/tests/fixtures/hooks/type_mapping.py +0 -0
  24. {wizard_codegen-0.2.0 → wizard_codegen-0.2.2}/utils/name.py +0 -0
  25. {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
  [![CircleCI](https://dl.circleci.com/status-badge/img/gh/ConsultingMD/wizard-codegen/tree/main.svg?style=svg&circle-token=CCIPRJ_6cYihJ2CPYtLrt8VjrCcSV_f02bbbce30f122a85da4e93588e8887e973128fd)](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 --config custom.yaml generate
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 | Example |
444
- |-----------------|---------------------------|--------------------------------------|
445
- | `name` | Message/enum/service name | `"*Form"`, `"User*"`, `"Order"` |
446
- | `package` | Proto package name | `"wizard.*"`, `"com.example.*"` |
447
- | `file` | Proto file path | `"*/shared/*"`, `"user/*.proto"` |
448
- | `full_name` | Fully qualified name | `".wizard.UserForm"` |
449
- | `option.equals` | Match proto options | `{ key: "deprecated", value: true }` |
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": < FileOptions >,
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": < MethodOptions >,
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
  [![CircleCI](https://dl.circleci.com/status-badge/img/gh/ConsultingMD/wizard-codegen/tree/main.svg?style=svg&circle-token=CCIPRJ_6cYihJ2CPYtLrt8VjrCcSV_f02bbbce30f122a85da4e93588e8887e973128fd)](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 --config custom.yaml generate
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 | Example |
459
- |-----------------|---------------------------|--------------------------------------|
460
- | `name` | Message/enum/service name | `"*Form"`, `"User*"`, `"Order"` |
461
- | `package` | Proto package name | `"wizard.*"`, `"com.example.*"` |
462
- | `file` | Proto file path | `"*/shared/*"`, `"user/*.proto"` |
463
- | `full_name` | Fully qualified name | `".wizard.UserForm"` |
464
- | `option.equals` | Match proto options | `{ key: "deprecated", value: true }` |
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": < FileOptions >,
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": < MethodOptions >,
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(ctx: typer.Context):
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
- proto_root = resolve_proto_root(config, use_local=ctx.obj.local)
66
- files = discover_proto_files(proto_root, config)
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 ctx.obj.verbose:
91
+ if verbose:
69
92
  _print_files_table(files, proto_root)
70
93
 
71
- fds_path, tmp_dir = build_descriptor_set(config, proto_root, files, ctx.obj.verbose)
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
- fds = load_fds(fds_path)
74
- jinja_ctx = build_context(config, fds)
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
- apply_plan(plan, ctx.obj.dry_run, ctx.obj.verbose)
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
- proto_root = resolve_proto_root(config, use_local=ctx.obj.local)
95
- files = discover_proto_files(proto_root, config)
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
- if ctx.obj.verbose:
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
- fds_path, tmp_dir = build_descriptor_set(config, proto_root, files, ctx.obj.verbose)
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
- fds = load_fds(fds_path)
113
- jinja_ctx = build_context(config, fds)
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
- # Cleanup Tmp Dir
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": method.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 {"name": value.name, "number": value.number}
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": proto_file.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(cfg: CodegenConfig, context: dict, out_override: Path | None = None) -> list[PlanItem]:
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.0"
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.1",
12
+ "rich==14.3.3",
13
13
  "click==8.3.1",
14
- "protobuf==6.33.4",
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)