plating 0.0.0.dev0__py3-none-any.whl

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.
plating/plater.py ADDED
@@ -0,0 +1,487 @@
1
+ #
2
+ # plating/plater.py
3
+ #
4
+ """Garnish documentation plating system."""
5
+
6
+ from pathlib import Path
7
+
8
+ from jinja2 import DictLoader, Environment, select_autoescape
9
+ from provide.foundation import logger
10
+
11
+ from plating.errors import PlatingRenderError, TemplateError, handle_error
12
+ from plating.plating import PlatingBundle, PlatingDiscovery
13
+ from plating.schema import SchemaProcessor
14
+
15
+
16
+ class PlatingPlater:
17
+ """Documentation plater using .plating bundles."""
18
+
19
+ def __init__(
20
+ self,
21
+ bundles: list[PlatingBundle] | None = None,
22
+ schema_processor: SchemaProcessor | None = None,
23
+ ):
24
+ """Initialize plater with bundles and optional schema processor.
25
+
26
+ Args:
27
+ bundles: List of PlatingBundle objects to render
28
+ schema_processor: Optional schema processor for schema extraction
29
+ """
30
+ self.bundles = bundles or []
31
+ self.schema_processor = schema_processor
32
+ self.provider_schema = None
33
+
34
+ if self.schema_processor:
35
+ try:
36
+ self.provider_schema = self.schema_processor.extract_provider_schema()
37
+ except Exception as e:
38
+ handle_error(e, logger)
39
+ logger.warning(f"Failed to extract provider schema: {e}")
40
+
41
+ def plate(self, output_dir: Path, force: bool = False) -> None:
42
+ """Plate all bundles to the output directory.
43
+
44
+ Args:
45
+ output_dir: Directory to write plated documentation
46
+ force: Force plating even if output exists
47
+ """
48
+ output_dir = Path(output_dir)
49
+ output_dir.mkdir(parents=True, exist_ok=True)
50
+
51
+ for bundle in self.bundles:
52
+ try:
53
+ self._plate_bundle(bundle, output_dir, force)
54
+ except PlatingRenderError:
55
+ raise # Re-raise our custom errors
56
+ except Exception as e:
57
+ error = PlatingRenderError(bundle.name, str(e))
58
+ handle_error(error, logger)
59
+ logger.error(f"Failed to plate bundle {bundle.name}: {e}")
60
+
61
+ def _plate_bundle(
62
+ self, bundle: PlatingBundle, output_dir: Path, force: bool
63
+ ) -> None:
64
+ """Plate a single bundle.
65
+
66
+ Args:
67
+ bundle: The PlatingBundle to render
68
+ output_dir: Directory to write output
69
+ force: Force overwrite existing files
70
+ """
71
+ # Load bundle assets
72
+ logger.trace(f"Loading assets for bundle {bundle.name}")
73
+ template_content = bundle.load_main_template()
74
+ if not template_content:
75
+ logger.debug(f"No template found for {bundle.name}, skipping")
76
+ return
77
+
78
+ examples = bundle.load_examples()
79
+ partials = bundle.load_partials()
80
+
81
+ # Create plating context
82
+ context = _create_plating_context(
83
+ bundle,
84
+ self._get_schema_for_component(bundle),
85
+ self.schema_processor.provider_name
86
+ if self.schema_processor
87
+ else "provider",
88
+ )
89
+
90
+ # Add examples to context
91
+ context["examples"] = examples
92
+
93
+ # Plate template
94
+ try:
95
+ plated = self._plate_template(template_content, context, partials)
96
+ except Exception as e:
97
+ logger.error(f"Template rendering failed for {bundle.name}: {e}")
98
+ return # Skip this bundle on error
99
+
100
+ # Determine output path
101
+ subdir = _get_output_subdir(bundle.component_type)
102
+ output_path = output_dir / subdir / f"{bundle.name}.md"
103
+
104
+ # Check if file exists and force flag
105
+ if output_path.exists() and not force:
106
+ logger.debug(
107
+ f"Output file {output_path} exists, skipping (use force=True to overwrite)"
108
+ )
109
+ return
110
+
111
+ # Write output
112
+ try:
113
+ output_path.parent.mkdir(parents=True, exist_ok=True)
114
+ output_path.write_text(plated)
115
+ logger.info(f"Successfully plated {bundle.name} to {output_path}")
116
+ except OSError as e:
117
+ raise PlatingRenderError(bundle.name, f"Failed to write output file: {e}")
118
+
119
+ def _get_schema_for_component(self, bundle: PlatingBundle) -> dict | None:
120
+ """Get schema for a component from the provider schema.
121
+
122
+ Args:
123
+ bundle: The bundle to get schema for
124
+
125
+ Returns:
126
+ Component schema dict or None
127
+ """
128
+ if not self.provider_schema:
129
+ return None
130
+
131
+ # Try to find the component schema
132
+ provider_schemas = self.provider_schema.get("provider_schemas", {})
133
+ for provider_key, provider_data in provider_schemas.items():
134
+ # Check resources
135
+ if bundle.component_type == "resource":
136
+ schemas = provider_data.get("resource_schemas", {})
137
+ if bundle.name in schemas:
138
+ return schemas[bundle.name]
139
+ if f"pyvider_{bundle.name}" in schemas:
140
+ return schemas[f"pyvider_{bundle.name}"]
141
+
142
+ # Check data sources
143
+ elif bundle.component_type == "data_source":
144
+ schemas = provider_data.get("data_source_schemas", {})
145
+ if bundle.name in schemas:
146
+ return schemas[bundle.name]
147
+ if f"pyvider_{bundle.name}" in schemas:
148
+ return schemas[f"pyvider_{bundle.name}"]
149
+
150
+ # Check functions
151
+ elif bundle.component_type == "function":
152
+ functions = provider_data.get("functions", {})
153
+ if bundle.name in functions:
154
+ return functions[bundle.name]
155
+ if f"pyvider_{bundle.name}" in functions:
156
+ return functions[f"pyvider_{bundle.name}"]
157
+
158
+ return None
159
+
160
+ def _plate_template(
161
+ self, template_content: str, context: dict, partials: dict[str, str]
162
+ ) -> str:
163
+ """Plate a Jinja2 template with context.
164
+
165
+ Args:
166
+ template_content: The template string
167
+ context: Rendering context dictionary
168
+ partials: Partial templates dictionary
169
+
170
+ Returns:
171
+ Plated template string
172
+ """
173
+ # Set up Jinja2 environment
174
+ templates = {"main.tmpl": template_content}
175
+ templates.update(partials)
176
+
177
+ env = Environment(
178
+ loader=DictLoader(templates),
179
+ autoescape=select_autoescape(["html", "xml"]),
180
+ )
181
+
182
+ # Add custom template functions
183
+ env.globals["schema"] = lambda: context.get("schema_markdown", "")
184
+ env.globals["example"] = lambda name: _format_example(
185
+ context.get("examples", {}).get(name, "")
186
+ )
187
+ env.globals["include"] = lambda filename: partials.get(filename, "")
188
+
189
+ # Plate template
190
+ template = env.get_template("main.tmpl")
191
+ return template.render(**context)
192
+
193
+
194
+ def _create_plating_context(
195
+ bundle: PlatingBundle, schema: dict | None, provider_name: str
196
+ ) -> dict:
197
+ """Create plating context for a bundle.
198
+
199
+ Args:
200
+ bundle: The PlatingBundle
201
+ schema: Component schema dict or None
202
+ provider_name: Name of the provider
203
+
204
+ Returns:
205
+ Context dictionary for template plating
206
+ """
207
+ context = {
208
+ "name": bundle.name,
209
+ "type": _format_component_type(bundle.component_type),
210
+ "provider_name": provider_name,
211
+ "component_type": bundle.component_type,
212
+ }
213
+
214
+ if schema:
215
+ context["description"] = schema.get("description", "")
216
+ context["schema_markdown"] = _plate_schema_markdown(schema)
217
+
218
+ # Add function-specific fields
219
+ if bundle.component_type == "function" and "signature" in schema:
220
+ context["signature"] = _format_function_signature(schema)
221
+ context["arguments"] = _format_function_arguments(schema)
222
+
223
+ return context
224
+
225
+
226
+ def _format_component_type(component_type: str) -> str:
227
+ """Format component type for display.
228
+
229
+ Args:
230
+ component_type: Raw component type
231
+
232
+ Returns:
233
+ Formatted component type
234
+ """
235
+ return {
236
+ "resource": "Resource",
237
+ "data_source": "Data Source",
238
+ "function": "Function",
239
+ }.get(component_type, component_type.title())
240
+
241
+
242
+ def _get_output_subdir(component_type: str) -> str:
243
+ """Get output subdirectory for component type.
244
+
245
+ Args:
246
+ component_type: Component type
247
+
248
+ Returns:
249
+ Output subdirectory name
250
+ """
251
+ return {
252
+ "resource": "resources",
253
+ "data_source": "data_sources",
254
+ "function": "functions",
255
+ }.get(component_type, "resources")
256
+
257
+
258
+ def _format_example(example_code: str) -> str:
259
+ """Format example code for display.
260
+
261
+ Args:
262
+ example_code: Raw example code
263
+
264
+ Returns:
265
+ Formatted example with code block
266
+ """
267
+ if not example_code:
268
+ return ""
269
+ return f"```terraform\n{example_code}\n```"
270
+
271
+
272
+ def _plate_schema_markdown(schema: dict) -> str:
273
+ """Plate schema to markdown format.
274
+
275
+ Args:
276
+ schema: Schema dictionary
277
+
278
+ Returns:
279
+ Markdown formatted schema
280
+ """
281
+ lines = ["## Schema", ""]
282
+
283
+ block = schema.get("block", {})
284
+ attributes = block.get("attributes", {})
285
+
286
+ # Separate attributes by type
287
+ required_attrs = []
288
+ optional_attrs = []
289
+ computed_attrs = []
290
+
291
+ for attr_name, attr_def in attributes.items():
292
+ attr_type = _format_type_string(attr_def.get("type"))
293
+ description = attr_def.get("description", "")
294
+
295
+ if attr_def.get("required"):
296
+ required_attrs.append((attr_name, attr_type, description))
297
+ elif attr_def.get("computed") and not attr_def.get("optional"):
298
+ computed_attrs.append((attr_name, attr_type, description))
299
+ else:
300
+ optional_attrs.append((attr_name, attr_type, description))
301
+
302
+ # Format sections
303
+ if required_attrs:
304
+ lines.extend(["### Required", ""])
305
+ for name, type_str, desc in required_attrs:
306
+ lines.append(f"- `{name}` ({type_str}) - {desc}")
307
+ lines.append("")
308
+
309
+ if optional_attrs:
310
+ lines.extend(["### Optional", ""])
311
+ for name, type_str, desc in optional_attrs:
312
+ lines.append(f"- `{name}` ({type_str}) - {desc}")
313
+ lines.append("")
314
+
315
+ if computed_attrs:
316
+ lines.extend(["### Read-Only", ""])
317
+ for name, type_str, desc in computed_attrs:
318
+ lines.append(f"- `{name}` ({type_str}) - {desc}")
319
+ lines.append("")
320
+
321
+ # Handle nested blocks
322
+ blocks = block.get("block_types", {})
323
+ if blocks:
324
+ lines.extend(["### Blocks", ""])
325
+ for block_name, block_def in blocks.items():
326
+ max_items = block_def.get("max_items", 0)
327
+ if max_items == 1:
328
+ lines.append(f"- `{block_name}` (Optional)")
329
+ else:
330
+ lines.append(f"- `{block_name}` (Optional, List)")
331
+ lines.append("")
332
+
333
+ # Return empty string if no content was generated
334
+ if len(lines) == 2: # Just "## Schema" and empty line
335
+ return ""
336
+
337
+ return "\n".join(lines)
338
+
339
+
340
+ def _format_type_string(type_info) -> str:
341
+ """Format type information to human-readable string.
342
+
343
+ Args:
344
+ type_info: Type information (string, list, or dict)
345
+
346
+ Returns:
347
+ Formatted type string
348
+ """
349
+ if not type_info:
350
+ return "Dynamic"
351
+
352
+ if isinstance(type_info, str):
353
+ return type_info.title()
354
+
355
+ if isinstance(type_info, list) and len(type_info) >= 2:
356
+ container_type = type_info[0]
357
+ element_type = type_info[1]
358
+
359
+ if container_type == "list":
360
+ return f"List of {_format_type_string(element_type)}"
361
+ elif container_type == "set":
362
+ return f"Set of {_format_type_string(element_type)}"
363
+ elif container_type == "map":
364
+ return f"Map of {_format_type_string(element_type)}"
365
+ elif container_type == "object":
366
+ if isinstance(element_type, dict):
367
+ attrs = ", ".join(
368
+ f"{k}: {_format_type_string(v)}" for k, v in element_type.items()
369
+ )
370
+ return f"Object({attrs})"
371
+ return "Object"
372
+
373
+ return "Dynamic"
374
+
375
+
376
+ def _format_function_signature(schema: dict) -> str:
377
+ """Format function signature from schema.
378
+
379
+ Args:
380
+ schema: Function schema
381
+
382
+ Returns:
383
+ Formatted function signature
384
+ """
385
+ signature = schema.get("signature", {})
386
+ params = []
387
+
388
+ # Parameters
389
+ for param in signature.get("parameters", []):
390
+ param_name = param.get("name", "arg")
391
+ param_type = param.get("type", "any")
392
+ params.append(f"{param_name}: {param_type}")
393
+
394
+ # Variadic parameter
395
+ if "variadic_parameter" in signature:
396
+ variadic = signature["variadic_parameter"]
397
+ variadic_name = variadic.get("name", "args")
398
+ variadic_type = variadic.get("type", "any")
399
+ params.append(f"...{variadic_name}: {variadic_type}")
400
+
401
+ # Return type
402
+ return_type = signature.get("return_type", "any")
403
+ param_str = ", ".join(params)
404
+
405
+ return f"({param_str}) -> {return_type}"
406
+
407
+
408
+ def _format_function_arguments(schema: dict) -> str:
409
+ """Format function arguments from schema.
410
+
411
+ Args:
412
+ schema: Function schema
413
+
414
+ Returns:
415
+ Formatted arguments list
416
+ """
417
+ signature = schema.get("signature", {})
418
+ lines = []
419
+
420
+ # Parameters
421
+ for param in signature.get("parameters", []):
422
+ param_name = param.get("name", "arg")
423
+ param_type = param.get("type", "any")
424
+ description = param.get("description", "")
425
+ lines.append(f"- `{param_name}` ({param_type}) - {description}")
426
+
427
+ # Variadic parameter
428
+ if "variadic_parameter" in signature:
429
+ variadic = signature["variadic_parameter"]
430
+ variadic_name = variadic.get("name", "args")
431
+ variadic_type = variadic.get("type", "any")
432
+ description = variadic.get("description", "")
433
+ lines.append(f"- `...{variadic_name}` ({variadic_type}) - {description}")
434
+
435
+ return "\n".join(lines)
436
+
437
+
438
+ def generate_docs(
439
+ output_dir: Path | str = "docs",
440
+ provider_name: str | None = None,
441
+ package_name: str = "pyvider.components",
442
+ component_type: str | None = None,
443
+ force: bool = False,
444
+ ) -> None:
445
+ """Generate documentation for all discovered plating bundles.
446
+
447
+ This is the main entry point for documentation generation.
448
+
449
+ Args:
450
+ output_dir: Directory to write documentation
451
+ provider_name: Optional provider name for schema extraction
452
+ package_name: Package to search for plating bundles
453
+ component_type: Optional filter for component type
454
+ force: Force overwrite existing files
455
+ """
456
+ # Discover bundles
457
+ discovery = PlatingDiscovery(package_name)
458
+ bundles = discovery.discover_bundles(component_type)
459
+
460
+ if not bundles:
461
+ logger.warning(f"No plating bundles found in {package_name}")
462
+ return
463
+
464
+ logger.info(f"Found {len(bundles)} plating bundles")
465
+
466
+ # Initialize schema processor if provider name given
467
+ schema_processor = None
468
+ if provider_name:
469
+ try:
470
+ # Create mock generator for schema processor
471
+ mock_generator = type(
472
+ "MockGenerator",
473
+ (),
474
+ {"provider_name": provider_name, "provider_dir": Path.cwd()},
475
+ )()
476
+ schema_processor = SchemaProcessor(mock_generator)
477
+ except Exception as e:
478
+ logger.warning(f"Failed to initialize schema processor: {e}")
479
+
480
+ # Create renderer and plate
481
+ plater = PlatingPlater(bundles, schema_processor)
482
+ plater.plate(Path(output_dir), force)
483
+
484
+ logger.info(f"Documentation generated in {output_dir}")
485
+
486
+
487
+ # 🍲🥄📄🪄
plating/plating.py ADDED
@@ -0,0 +1,207 @@
1
+ #
2
+ # plating/garnish.py
3
+ #
4
+ """Garnish bundle discovery and management."""
5
+
6
+ import importlib.util
7
+ from pathlib import Path
8
+
9
+ import attrs
10
+
11
+
12
+ @attrs.define
13
+ class PlatingBundle:
14
+ """Represents a single .plating bundle with its assets."""
15
+
16
+ name: str
17
+ plating_dir: Path
18
+ component_type: str # "resource", "data_source", "function"
19
+
20
+ @property
21
+ def docs_dir(self) -> Path:
22
+ """Directory containing documentation templates and partials."""
23
+ return self.plating_dir / "docs"
24
+
25
+ @property
26
+ def examples_dir(self) -> Path:
27
+ """Directory containing example Terraform files."""
28
+ return self.plating_dir / "examples"
29
+
30
+ @property
31
+ def fixtures_dir(self) -> Path:
32
+ """Directory containing fixture files for tests (inside examples dir)."""
33
+ return self.examples_dir / "fixtures"
34
+
35
+ def load_main_template(self) -> str | None:
36
+ """Load the main template file for this component."""
37
+ # Main template is typically <component_name>.tmpl.md
38
+ template_file = self.docs_dir / f"{self.name}.tmpl.md"
39
+
40
+ if not template_file.exists():
41
+ return None
42
+
43
+ try:
44
+ return template_file.read_text(encoding="utf-8")
45
+ except Exception:
46
+ return None
47
+
48
+ def load_examples(self) -> dict[str, str]:
49
+ """Load all example files as a dictionary."""
50
+ examples = {}
51
+
52
+ if not self.examples_dir.exists():
53
+ return examples
54
+
55
+ for example_file in self.examples_dir.glob("*.tf"):
56
+ try:
57
+ examples[example_file.stem] = example_file.read_text(encoding="utf-8")
58
+ except Exception:
59
+ continue
60
+
61
+ return examples
62
+
63
+ def load_partials(self) -> dict[str, str]:
64
+ """Load all partial files from docs directory.
65
+
66
+ Partials are files starting with underscore (_) in the docs directory.
67
+ """
68
+ partials = {}
69
+
70
+ if not self.docs_dir.exists():
71
+ return partials
72
+
73
+ # Load only files starting with underscore (partial convention)
74
+ for partial_file in self.docs_dir.glob("_*"):
75
+ if partial_file.is_file():
76
+ try:
77
+ partials[partial_file.name] = partial_file.read_text(
78
+ encoding="utf-8"
79
+ )
80
+ except Exception:
81
+ continue
82
+
83
+ return partials
84
+
85
+ def load_fixtures(self) -> dict[str, str]:
86
+ """Load all fixture files from fixtures directory."""
87
+ fixtures = {}
88
+
89
+ if not self.fixtures_dir.exists():
90
+ return fixtures
91
+
92
+ for fixture_file in self.fixtures_dir.rglob("*"):
93
+ if fixture_file.is_file():
94
+ try:
95
+ # Use relative path from fixtures dir as key
96
+ rel_path = fixture_file.relative_to(self.fixtures_dir)
97
+ fixtures[str(rel_path)] = fixture_file.read_text(encoding="utf-8")
98
+ except Exception:
99
+ continue
100
+
101
+ return fixtures
102
+
103
+
104
+ class PlatingDiscovery:
105
+ """Discovers .plating bundles from installed packages."""
106
+
107
+ def __init__(self, package_name: str = "pyvider.components"):
108
+ self.package_name = package_name
109
+
110
+ def discover_bundles(
111
+ self, component_type: str | None = None
112
+ ) -> list[PlatingBundle]:
113
+ """Discover all .plating bundles from the installed package."""
114
+ bundles = []
115
+
116
+ # Find the package location
117
+ try:
118
+ spec = importlib.util.find_spec(self.package_name)
119
+ if not spec or not spec.origin:
120
+ return bundles
121
+ except (ModuleNotFoundError, ValueError):
122
+ # Package doesn't exist or invalid package name
123
+ return bundles
124
+
125
+ package_path = Path(spec.origin).parent
126
+
127
+ # Search for .plating directories
128
+ for plating_dir in package_path.rglob("*.plating"):
129
+ if not plating_dir.is_dir():
130
+ continue
131
+
132
+ # Skip hidden directories
133
+ if plating_dir.name.startswith("."):
134
+ continue
135
+
136
+ # Determine component type from path
137
+ bundle_component_type = self._determine_component_type(plating_dir)
138
+ if component_type and bundle_component_type != component_type:
139
+ continue
140
+
141
+ # Check if this is a multi-component bundle
142
+ sub_component_bundles = self._discover_sub_components(
143
+ plating_dir, bundle_component_type
144
+ )
145
+ if sub_component_bundles:
146
+ # Multi-component bundle - use individual components
147
+ bundles.extend(sub_component_bundles)
148
+ else:
149
+ # Single component bundle
150
+ component_name = plating_dir.name.replace(".plating", "")
151
+
152
+ bundle = PlatingBundle(
153
+ name=component_name,
154
+ plating_dir=plating_dir,
155
+ component_type=bundle_component_type,
156
+ )
157
+
158
+ bundles.append(bundle)
159
+
160
+ return bundles
161
+
162
+ def _discover_sub_components(
163
+ self, plating_dir: Path, component_type: str
164
+ ) -> list[PlatingBundle]:
165
+ """Discover individual components within a multi-component .plating bundle."""
166
+ sub_bundles = []
167
+
168
+ # Look for subdirectories that contain docs/ and examples/ folders
169
+ for item in plating_dir.iterdir():
170
+ if not item.is_dir():
171
+ continue
172
+
173
+ # Check if this looks like a component directory
174
+ docs_dir = item / "docs"
175
+ if docs_dir.exists() and docs_dir.is_dir():
176
+ # Determine component type from subdirectory name
177
+ sub_component_type = item.name
178
+ if sub_component_type not in ["resource", "data_source", "function"]:
179
+ # Fall back to parent component type if not a recognized type
180
+ sub_component_type = component_type
181
+
182
+ # This appears to be an individual component
183
+ bundle = PlatingBundle(
184
+ name=item.name, # Use the directory name as component name
185
+ plating_dir=item, # Point to the individual component directory
186
+ component_type=sub_component_type,
187
+ )
188
+ sub_bundles.append(bundle)
189
+
190
+ return sub_bundles
191
+
192
+ def _determine_component_type(self, plating_dir: Path) -> str:
193
+ """Determine component type from the .plating directory path."""
194
+ path_parts = plating_dir.parts
195
+
196
+ if "resources" in path_parts:
197
+ return "resource"
198
+ elif "data_sources" in path_parts:
199
+ return "data_source"
200
+ elif "functions" in path_parts:
201
+ return "function"
202
+ else:
203
+ # Default to resource if unclear
204
+ return "resource"
205
+
206
+
207
+ # 🍲🥄📄🪄