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/__init__.py +5 -0
- libspec/cli.py +259 -0
- libspec/err.py +37 -0
- libspec/mcp_server.py +82 -0
- libspec/query_map.py +19 -0
- libspec/spec.py +591 -0
- libspec/spec_diff.py +193 -0
- libspec/specweb.py +13 -0
- libspec/user_story.py +24 -0
- libspec/util.py +26 -0
- libspec-1.2.0.dist-info/METADATA +111 -0
- libspec-1.2.0.dist-info/RECORD +16 -0
- libspec-1.2.0.dist-info/WHEEL +5 -0
- libspec-1.2.0.dist-info/entry_points.txt +3 -0
- libspec-1.2.0.dist-info/licenses/LICENSE +675 -0
- libspec-1.2.0.dist-info/top_level.txt +1 -0
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]
|