libspec 1.2.0__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.
libspec/spec.py ADDED
@@ -0,0 +1,591 @@
1
+ import inspect
2
+ import os
3
+ import ast
4
+ import json
5
+ import argparse
6
+ import xml.etree.ElementTree as ET
7
+ from xml.dom import minidom
8
+ from jinja2 import Environment, meta, Template
9
+ from inspect import signature, cleandoc, isfunction
10
+ from libspec.err import UnimplementedMethodError
11
+ from libspec.util import fqn, easy_hash
12
+
13
+
14
+ class Spec:
15
+ def modules(self):
16
+ raise UnimplementedMethodError()
17
+
18
+ def generate_xml(self):
19
+ """Generate the complete specification as a structured XML document."""
20
+ import datetime
21
+ import importlib.metadata
22
+
23
+ try:
24
+ libspec_version = importlib.metadata.version("libspec")
25
+ except importlib.metadata.PackageNotFoundError:
26
+ libspec_version = "unknown"
27
+
28
+ root = ET.Element("specification_set")
29
+ root.set("libspec-version", libspec_version)
30
+ for mod in self.modules():
31
+ for spec in module_specs(mod):
32
+ root.append(spec.to_xml_element())
33
+
34
+ xml_str = ET.tostring(root, encoding='utf-8')
35
+ reparsed = minidom.parseString(xml_str)
36
+ return reparsed.toprettyxml(indent=" ")
37
+
38
+ def write_xml(self, output_dir):
39
+ """Write the XML specification to a hashed file in the given directory."""
40
+ import datetime
41
+ xml_content = self.generate_xml()
42
+
43
+ h = easy_hash(xml_content)[:20]
44
+
45
+ os.makedirs(output_dir, exist_ok=True)
46
+ filename = f"spec-{h}.xml"
47
+ path = os.path.join(output_dir, filename)
48
+
49
+ # Inject the timestamp right before writing to disk
50
+ date_str = datetime.datetime.now().astimezone().isoformat()
51
+ final_xml = xml_content.replace('<specification_set', f'<specification_set date-created="{date_str}"', 1)
52
+
53
+ with open(path, "w") as f:
54
+ f.write(final_xml)
55
+ print(f"Specification written to {path}")
56
+
57
+ # Generate inline source map
58
+ self.generate_source_map(final_xml, path, output_dir)
59
+
60
+ return path
61
+
62
+ def _get_class_lines(self, file_path, class_name):
63
+ try:
64
+ with open(file_path, 'r', encoding='utf-8') as f:
65
+ source = f.read()
66
+ tree = ast.parse(source)
67
+ except Exception:
68
+ return None, None
69
+ for node in ast.walk(tree):
70
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
71
+ return getattr(node, 'lineno', None), getattr(node, 'end_lineno', None)
72
+ return None, None
73
+
74
+ def _search_workspace_for_id(self, directory, search_id):
75
+ matches = []
76
+ skip_dirs = {'.git', '.venv', '__pycache__', 'node_modules', 'build', 'dist', '.pytest_cache'}
77
+ for root, dirs, files in os.walk(directory):
78
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
79
+ for file in files:
80
+ if file.endswith(('.pyc', '.pyo', '.so', '.dll', '.exe', '.bin', '.xml', '.json', '.out')):
81
+ continue
82
+ path = os.path.join(root, file)
83
+ try:
84
+ with open(path, 'r', encoding='utf-8') as f:
85
+ for lineno, line in enumerate(f, 1):
86
+ if search_id in line:
87
+ matches.append({"file": path, "line": lineno})
88
+ except Exception:
89
+ pass
90
+ return matches
91
+
92
+ def generate_source_map(self, xml_content, xml_path, output_dir):
93
+ from lxml import etree
94
+ try:
95
+ tree = etree.fromstring(xml_content.encode('utf-8'))
96
+ except Exception as e:
97
+ print(f"Error parsing XML for source map: {e}")
98
+ return
99
+
100
+ source_map = []
101
+ workspace_dir = os.getcwd()
102
+
103
+ for spec in tree.xpath('//specification'):
104
+ spec_info = {
105
+ "component": spec.get("type", "Unknown"),
106
+ "python_spec": None,
107
+ "xml_spec": None,
108
+ "generated_code": []
109
+ }
110
+
111
+ if spec.sourceline:
112
+ spec_info["xml_spec"] = {
113
+ "file": str(xml_path),
114
+ "line": spec.sourceline
115
+ }
116
+
117
+ source_elem = spec.find("source")
118
+ if source_elem is not None:
119
+ py_file = source_elem.get("file")
120
+ target = source_elem.get("target")
121
+ if py_file and target:
122
+ start_line, end_line = self._get_class_lines(py_file, target)
123
+ spec_info["python_spec"] = {
124
+ "file": py_file,
125
+ "target": target,
126
+ "start_line": start_line,
127
+ "end_line": end_line
128
+ }
129
+
130
+ search_ids = set()
131
+ if spec.get("type"):
132
+ search_ids.add(spec.get("type"))
133
+
134
+ ctx = spec.find("context")
135
+ if ctx is not None:
136
+ for child in ctx:
137
+ if child.tag in ['req_id', 'feature_name', 'constraint_id', 'model_name', 'api_name', 'title']:
138
+ if child.text:
139
+ search_ids.add(child.text.strip())
140
+
141
+ generated_matches = []
142
+ for search_id in search_ids:
143
+ if len(search_id) > 2:
144
+ matches = self._search_workspace_for_id(workspace_dir, search_id)
145
+ generated_matches.extend(matches)
146
+
147
+ dedup_matches = []
148
+ seen = set()
149
+ for m in generated_matches:
150
+ k = (m["file"], m["line"])
151
+ if k not in seen:
152
+ seen.add(k)
153
+ dedup_matches.append(m)
154
+
155
+ spec_info["generated_code"] = dedup_matches
156
+ source_map.append(spec_info)
157
+
158
+ out_file = os.path.join(output_dir, "source_map.json")
159
+ try:
160
+ with open(out_file, 'w', encoding='utf-8') as f:
161
+ json.dump(source_map, f, indent=2)
162
+ print(f"Source map written to {out_file}")
163
+ except Exception as e:
164
+ print(f"Error writing source map: {e}")
165
+
166
+ def handle_cli(self):
167
+ """Handle command line interface for specification generation."""
168
+ parser = argparse.ArgumentParser(description="libspec CLI")
169
+ parser.add_argument("-o", "--output", help="Output directory for XML specification")
170
+ parser.add_argument("--xml", action="store_true", help="Print XML specification to stdout")
171
+
172
+ args = parser.parse_args()
173
+
174
+ if args.output:
175
+ self.write_xml(args.output)
176
+ elif args.xml:
177
+ print(self.generate_xml())
178
+ else:
179
+ self.generate_xml()
180
+
181
+ class Ctx:
182
+ # No __init__ needed if we use getattr
183
+
184
+ def _get_base_template(self):
185
+ """Collect docstrings from parent classes and merge them into a single template."""
186
+ templates = [] # List to hold cleaned docstrings
187
+
188
+ # Traverse parent classes in the method resolution order,
189
+ # skipping the current class.
190
+
191
+ # (MRO stands for Method Resolution
192
+ # Order. In Python, it's the order in which classes are
193
+ # searched when you call a method or access an attribute on an
194
+ # instance.)
195
+
196
+ for cls in self.__class__.__mro__[1:]:
197
+ if cls in (Ctx, object):
198
+ continue
199
+ if cls.__doc__: # Only process classes that have a docstring
200
+ cleaned = cleandoc(cls.__doc__)
201
+ if cleaned:
202
+ templates.append(cleaned)
203
+
204
+ templates.reverse()
205
+ # Join all collected docstrings with double newlines, or return empty string
206
+ return "\n\n".join(templates) if templates else ""
207
+
208
+ def _get_instance_notes(self):
209
+ """Gets the docstring from the leaf subclass implementation."""
210
+ doc = self.__class__.__doc__
211
+ return cleandoc(doc) if doc else ""
212
+
213
+ def _get_source_info(self, obj=None):
214
+ """Extracts source file and line information."""
215
+ import inspect
216
+ import os
217
+
218
+ target = obj if obj is not None else self.__class__
219
+
220
+ try:
221
+ source_file = inspect.getsourcefile(target)
222
+ if source_file:
223
+ source_file = os.path.abspath(source_file)
224
+
225
+ lines, start_line = inspect.getsourcelines(target)
226
+ end_line = start_line + len(lines) - 1
227
+
228
+ return {
229
+ "file": source_file,
230
+ "start_line": start_line,
231
+ "end_line": end_line,
232
+ "name": target.__name__ if hasattr(target, "__name__") else str(target)
233
+ }
234
+ except (OSError, TypeError):
235
+ return None
236
+
237
+ def ctx(self, template_only=True):
238
+ if getattr(self, '_in_ctx', False):
239
+ return {}
240
+
241
+ self._in_ctx = True
242
+ try:
243
+ return self._do_ctx(template_only)
244
+ finally:
245
+ self._in_ctx = False
246
+
247
+ def _do_ctx(self, template_only=True):
248
+ # Always use the base template to find expected variables
249
+ doc = self._get_base_template()
250
+ notes = self._get_instance_notes()
251
+ combined = f"{doc}\n{notes}"
252
+
253
+ env = Environment()
254
+ ast = env.parse(combined)
255
+ expected_vars = meta.find_undeclared_variables(ast)
256
+
257
+ context = {}
258
+
259
+ # Helper to get member value
260
+ def get_member(var_name):
261
+ method_name = var_name.replace('-', '_')
262
+ if hasattr(self, method_name):
263
+ member = getattr(self, method_name)
264
+ return member() if callable(member) else member
265
+ else:
266
+ src = self._get_source_info()
267
+ loc = f"{src['file']}:{src['start_line']}" if src else "unknown location"
268
+ msg = (
269
+ f"\nThe variable '{{{{{var_name}}}}}' was found in a docstring template for class '{self.__class__.__name__}',\n"
270
+ f"defined at {loc},\n"
271
+ f"but no matching method or attribute '{method_name}' was found.\n\n"
272
+ f"FIX: implement 'def {method_name}(self):' in class '{self.__class__.__name__}' or one of its bases.\n"
273
+ )
274
+ raise AttributeError(msg)
275
+
276
+
277
+
278
+
279
+ # Ensure 'fields' is available if DataSchema is used
280
+ if 'fields' in expected_vars and hasattr(self, 'fields'):
281
+ context['fields'] = self.fields()
282
+
283
+ for var in sorted(expected_vars):
284
+ if var == 'fields': continue
285
+ context[var] = get_member(var)
286
+
287
+ if not template_only:
288
+ # Also capture all other non-private members for XML/structured data
289
+ # dir() is sorted by default, but we'll be explicit for clarity
290
+ for name in sorted(dir(self)):
291
+ if name.startswith('_') or name in ['ctx', 'render', 'render_xml', 'to_xml_element']:
292
+ continue
293
+ if name in ['_get_base_template', '_get_instance_notes', '_get_source_info', '_to_xml_element']:
294
+ continue
295
+ if name in context: # Already added from template
296
+ continue
297
+
298
+ member = getattr(self, name)
299
+ if callable(member):
300
+ try:
301
+ sig = signature(member)
302
+ if len(sig.parameters) == 0:
303
+ context[name] = member()
304
+ except (TypeError, ValueError, UnimplementedMethodError):
305
+ continue
306
+ else:
307
+ context[name] = member
308
+
309
+ return context
310
+
311
+ def _to_xml_element(self, name, value):
312
+ """Recursively convert context data to XML elements."""
313
+ elem = ET.Element(name)
314
+ if isinstance(value, dict):
315
+ # Sort items for deterministic order in XML
316
+ for k in sorted(value.keys()):
317
+ if k in ['start_line', 'end_line']:
318
+ continue
319
+ v = value[k]
320
+ elem.append(self._to_xml_element(str(k).replace('-', '_'), v))
321
+ elif isinstance(value, list):
322
+ for item in value:
323
+ elem.append(self._to_xml_element("item", item))
324
+ else:
325
+ elem.text = str(value)
326
+ return elem
327
+
328
+ def render_xml(self):
329
+ """Render the specification as structured XML."""
330
+ root = self.to_xml_element()
331
+ # Pretty print
332
+ xml_str = ET.tostring(root, encoding='utf-8')
333
+ reparsed = minidom.parseString(xml_str)
334
+ return reparsed.toprettyxml(indent=" ")
335
+
336
+ def to_xml_element(self):
337
+ """Convert the specification to an XML element."""
338
+ root = ET.Element("specification")
339
+ root.set("type", self.__class__.__name__)
340
+
341
+ # Source info
342
+ src = self._get_source_info()
343
+ if src:
344
+ source_elem = ET.SubElement(root, "source")
345
+ source_elem.set("target", src["name"])
346
+ source_elem.set("file", src["file"])
347
+
348
+ # Docstrings
349
+ base_template = self._get_base_template()
350
+ instance_notes = self._get_instance_notes()
351
+
352
+ ctx_data = self.ctx()
353
+ rendered_body = Template(base_template).render(**ctx_data).strip()
354
+
355
+ if rendered_body:
356
+ desc_elem = ET.SubElement(root, "description")
357
+ desc_elem.text = rendered_body
358
+
359
+ if instance_notes:
360
+ notes_elem = ET.SubElement(root, "notes")
361
+ rendered_notes = Template(instance_notes).render(**ctx_data).strip()
362
+ notes_elem.text = rendered_notes
363
+
364
+ # Context data
365
+ context_elem = ET.SubElement(root, "context")
366
+ all_ctx_data = self.ctx(template_only=False)
367
+ # Sort keys to ensure stable XML tag order
368
+ for k in sorted(all_ctx_data.keys()):
369
+ v = all_ctx_data[k]
370
+ context_elem.append(self._to_xml_element(str(k).replace('-', '_'), v))
371
+
372
+ return root
373
+
374
+ class Feature(Ctx):
375
+ '''
376
+ Feature Specification: {{feature_name}}
377
+
378
+ '''
379
+ def feature_name(self):
380
+ return self.__class__.__name__
381
+
382
+ def date(self):
383
+ raise UnimplementedMethodError()
384
+
385
+ def description(self):
386
+ raise UnimplementedMethodError()
387
+
388
+ class Def(Ctx):
389
+ '''
390
+ Definition: {{name}}:
391
+
392
+ '''
393
+ def name(self):
394
+ return fqn(self)
395
+
396
+
397
+ class EdgeCase(Ctx):
398
+ '''
399
+ Edge Case
400
+
401
+ What happens when {{boundary_condition}}?
402
+ How does system handle {{error_scenerio}}?
403
+ '''
404
+ def bounary_condition(self):
405
+ raise UnimplementedMethodError()
406
+ def error_scenerio(self):
407
+ raise UnimplementedMethodError()
408
+
409
+ class Constraint(Ctx):
410
+ """
411
+ CONSTRAINT-ID: {{constraint_id}}
412
+ DESCRIPTION: {{description}}
413
+ ENFORCEMENT: {{enforcement_logic}}
414
+ """
415
+ def constraint_id(self):
416
+ return self.__class__.__name__
417
+
418
+ def description(self):
419
+ return self.__class__.__doc__
420
+
421
+ class Requirement(Ctx):
422
+ """
423
+ Requirement
424
+ TITLE: {{title}}
425
+ REQUIREMENT-ID: {{req_id}}
426
+
427
+ Insert REQUIREMENT-ID into any source code for cross reference purposes.
428
+ """
429
+ def title(self):
430
+ return self.__class__.__name__
431
+ def req_id(self):
432
+ return fqn(self)
433
+
434
+
435
+ class SystemRequirement(Requirement):
436
+ """
437
+ System Requirement: This is a tool level requirement aimed at the
438
+ toolchain supporting the project.
439
+ """
440
+
441
+
442
+ class DataSchema(Ctx):
443
+ """
444
+ DATA-MODEL: {{model_name}}
445
+ FIELDS:
446
+ {% if fields is mapping %}
447
+ {% for name, type_obj in fields.items() %}
448
+ - {{name}}: {{type_obj}}
449
+ {% endfor %}
450
+ {% else %}
451
+ - No fields defined.
452
+ {% endif %}
453
+ """
454
+ def model_name(self):
455
+ return self.__class__.__name__
456
+
457
+ def fields(self):
458
+ return self.__class__.__annotations__
459
+
460
+
461
+ class SQLite3(DataSchema):
462
+ """
463
+ SQLite3 Database.
464
+
465
+ The following schema should be implemented for SQLite3. Write
466
+ tests to ensure the database behaves as expected.
467
+
468
+ The database file should be located at {{dbpath}}
469
+ """
470
+
471
+ class PeeWee(DataSchema):
472
+ """
473
+ Python PeeWee Database.
474
+
475
+ The following schema should be implemented for PeeWee. Write
476
+ tests to ensure the database behaves as expected.
477
+
478
+ The database file should be located at {{dbpath}}
479
+ """
480
+
481
+
482
+ class LeafMethods:
483
+ def methods(self):
484
+ """
485
+ Return only methods declared on the **leaf subclass**, ignoring Ctx or other base classes.
486
+ """
487
+ method_list = []
488
+ cls = self.__class__
489
+
490
+ # Get only methods defined on this class, not inherited
491
+ for name, attr in cls.__dict__.items():
492
+ if name.startswith('_'): # Skip private methods
493
+ continue
494
+ if not isfunction(attr):
495
+ continue
496
+
497
+ sig = signature(attr)
498
+ params = [p for p in sig.parameters.keys() if p != 'self']
499
+ doc = cleandoc(attr.__doc__ or "No description provided")
500
+
501
+ member = getattr(self, name)
502
+
503
+ if callable(member):
504
+ k = member(*[None] * len(params))
505
+ else:
506
+ k = member
507
+
508
+ method_list.append({
509
+ "name": name,
510
+ "params": params,
511
+ "description": doc,
512
+ "result": k,
513
+ "source_ref": self._get_source_info(attr)
514
+ })
515
+
516
+ return method_list
517
+
518
+
519
+ class API(Ctx, LeafMethods):
520
+ """
521
+ API Specification: {{api_name}}
522
+
523
+ Endpoints:
524
+ {% for method in methods %}
525
+ - {{method.name}}({{method.params|join(', ')}})
526
+
527
+ Description: {{method.description}}
528
+ {% endfor %}
529
+
530
+ Constraints:
531
+ {% for constraint in constraints %}
532
+ - {{constraint}}
533
+ {% endfor %}
534
+ """
535
+
536
+ def api_name(self):
537
+ """Name of the API."""
538
+ return self.__class__.__name__
539
+
540
+ def constraints(self):
541
+ """Return a list of strings describing API-level business constraints."""
542
+ return []
543
+
544
+
545
+ class LibraryAPI(API):
546
+ '''
547
+ Library API Version: {{version}}
548
+ This is not a network API, rather this is a library API.
549
+ '''
550
+
551
+
552
+ class RestMixin:
553
+ '''
554
+ Develop a REST API with best practices around this interface
555
+ '''
556
+
557
+
558
+ class CmdLine(Ctx, LeafMethods):
559
+ '''
560
+ Command Line Specification
561
+
562
+ implement these commands:
563
+ {% for method in methods %}
564
+ | {{method.name}}({{method.params|join(', ')}})
565
+ | Description: {{method.description}}
566
+ | {{method.result}}
567
+ {% endfor %}
568
+ '''
569
+
570
+ class Implementation(Requirement):
571
+ '''
572
+ Implementation requirements.
573
+ Implementations must include tests.
574
+
575
+ All files generated by this implementation should live in the
576
+ directory:{{implementation_directory}}
577
+ '''
578
+
579
+ def classes_with_ctx_superclass(module):
580
+ result = []
581
+ for _, obj in inspect.getmembers(module, inspect.isclass):
582
+ # ensure the class is defined in this module (optional but common)
583
+ if obj.__module__ != module.__name__:
584
+ continue
585
+ if issubclass(obj, Ctx) and obj is not Ctx:
586
+ result.append(obj)
587
+ return result
588
+
589
+ def module_specs(mod):
590
+ cs = classes_with_ctx_superclass(mod)
591
+ return [C() for C in cs]