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/__init__.py
ADDED
libspec/cli.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
libspec - unified CLI for spec-driven development.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
init Initialize a new spec directory
|
|
6
|
+
build <spec_file.py> -o <output_dir> Build XML spec + source_map.json
|
|
7
|
+
diff <build_dir> Diff the two latest XML specs
|
|
8
|
+
query <source_map.json> [term] Query source map for LLM context
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import importlib.util
|
|
13
|
+
import inspect
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# init
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def cmd_init(args):
|
|
24
|
+
spec_dir = os.path.abspath("spec")
|
|
25
|
+
if os.path.exists(spec_dir):
|
|
26
|
+
print(f"Error: Directory '{spec_dir}' already exists. Bailing.")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
os.makedirs(spec_dir)
|
|
30
|
+
|
|
31
|
+
with open(os.path.join(spec_dir, "__init__.py"), "w") as f:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
with open(os.path.join(spec_dir, "main_spec.py"), "w") as f:
|
|
35
|
+
f.write('"""\n')
|
|
36
|
+
f.write('main spec\n')
|
|
37
|
+
f.write('"""\n\n')
|
|
38
|
+
f.write('from libspec import Spec\n')
|
|
39
|
+
f.write('from . import app\n\n')
|
|
40
|
+
f.write('class MainSpec(Spec):\n')
|
|
41
|
+
f.write(' def modules(self):\n')
|
|
42
|
+
f.write(' return [app]\n\n')
|
|
43
|
+
f.write('if __name__ == "__main__":\n')
|
|
44
|
+
f.write(' MainSpec().write_xml("spec-build")\n')
|
|
45
|
+
|
|
46
|
+
with open(os.path.join(spec_dir, "app.py"), "w") as f:
|
|
47
|
+
f.write('"""\n')
|
|
48
|
+
f.write('Features and requirements\n')
|
|
49
|
+
f.write('"""\n\n')
|
|
50
|
+
f.write('from .err import Feat, Req\n\n')
|
|
51
|
+
f.write('class App(Req):\n')
|
|
52
|
+
f.write(' \'\'\'This program should emit the\n')
|
|
53
|
+
f.write(' string "Hello, world!" to the terminal.\n')
|
|
54
|
+
f.write(' \'\'\'\n\n')
|
|
55
|
+
f.write('class CmdLine(Feat):\n')
|
|
56
|
+
f.write(' \'\'\'\n')
|
|
57
|
+
f.write(' This program does not take any command line arguments.\n')
|
|
58
|
+
f.write(' \'\'\'\n')
|
|
59
|
+
|
|
60
|
+
with open(os.path.join(spec_dir, "err.py"), "w") as f:
|
|
61
|
+
f.write('"""\n')
|
|
62
|
+
f.write('Error and requirement base classes.\n')
|
|
63
|
+
f.write('"""\n\n')
|
|
64
|
+
f.write('from libspec import Ctx, Feature, Requirement\n\n')
|
|
65
|
+
f.write('class Err(Ctx):\n')
|
|
66
|
+
f.write(' \'\'\'It is important that error handling be done excellently. If a\n')
|
|
67
|
+
f.write(' function can fail, then it needs to do so in the most elegant way\n')
|
|
68
|
+
f.write(' possible. Error reporting, handling, exceptions and all aspects\n')
|
|
69
|
+
f.write(' of failure must be taken to extreme. It should be possible to\n')
|
|
70
|
+
f.write(' understand the program by reading the error messages.\n')
|
|
71
|
+
f.write(' \'\'\'\n\n')
|
|
72
|
+
f.write('# Use multiple inheritance to endow Feature and Requirement specs with\n')
|
|
73
|
+
f.write('# disciplined error handling guidance from above.\n\n')
|
|
74
|
+
f.write('class Feat(Err, Feature): pass\n')
|
|
75
|
+
f.write('class Req(Err, Requirement): pass\n')
|
|
76
|
+
|
|
77
|
+
print(f"Initialized empty spec directory in {spec_dir}")
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# build
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def cmd_build(args):
|
|
84
|
+
from libspec.spec import Spec, module_specs
|
|
85
|
+
|
|
86
|
+
spec_file = os.path.abspath(args.spec_file)
|
|
87
|
+
if not os.path.exists(spec_file):
|
|
88
|
+
print(f"Error: {spec_file} does not exist.")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
# Calculate module name relative to the current working directory, if possible.
|
|
92
|
+
# This allows relative imports (e.g. from . import app) to work correctly when
|
|
93
|
+
# the spec is in a subdirectory (like spec/main_spec.py).
|
|
94
|
+
cwd = os.getcwd()
|
|
95
|
+
if spec_file.startswith(cwd):
|
|
96
|
+
rel_path = os.path.relpath(spec_file, cwd)
|
|
97
|
+
module_name = os.path.splitext(rel_path)[0].replace(os.path.sep, '.')
|
|
98
|
+
root_dir = cwd
|
|
99
|
+
else:
|
|
100
|
+
# Fallback: just use the spec's directory
|
|
101
|
+
root_dir = os.path.dirname(spec_file)
|
|
102
|
+
module_name = os.path.splitext(os.path.basename(spec_file))[0]
|
|
103
|
+
|
|
104
|
+
if root_dir not in sys.path:
|
|
105
|
+
sys.path.insert(0, root_dir)
|
|
106
|
+
|
|
107
|
+
# Import the module dynamically. Since we mapped the file path to a
|
|
108
|
+
# dotted module name (e.g. 'spec.main_spec'), python's built-in import
|
|
109
|
+
# system correctly sets __package__ and handles relative imports.
|
|
110
|
+
import importlib
|
|
111
|
+
try:
|
|
112
|
+
module = importlib.import_module(module_name)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
print(f"Error loading spec file: {e}")
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
# First try: find an explicit Spec subclass (write_xml + source map built-in)
|
|
118
|
+
explicit_spec = None
|
|
119
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
120
|
+
if obj.__module__ == module_name and issubclass(obj, Spec) and obj is not Spec:
|
|
121
|
+
explicit_spec = obj
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
output_dir = args.output or "spec-build"
|
|
125
|
+
|
|
126
|
+
if explicit_spec:
|
|
127
|
+
explicit_spec().write_xml(output_dir)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Fallback: auto-discover all Ctx subclasses via module_specs()
|
|
131
|
+
specs = module_specs(module)
|
|
132
|
+
if not specs:
|
|
133
|
+
print(f"Error: No spec classes found in {spec_file}.")
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
# Build an ad-hoc Spec that wraps the discovered components
|
|
137
|
+
class _ModuleSpec(Spec):
|
|
138
|
+
def modules(self_inner):
|
|
139
|
+
return [module]
|
|
140
|
+
|
|
141
|
+
_ModuleSpec().write_xml(output_dir)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# diff
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def cmd_diff(args):
|
|
149
|
+
from libspec.spec_diff import generate_patch
|
|
150
|
+
generate_patch(args.build_dir)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# query
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def get_query_results(data, query, list_all):
|
|
158
|
+
lines = []
|
|
159
|
+
if list_all:
|
|
160
|
+
components = sorted(set(item.get("component", "Unknown") for item in data))
|
|
161
|
+
lines.append(f"Components ({len(components)}):")
|
|
162
|
+
for c in components:
|
|
163
|
+
lines.append(f" {c}")
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
if not query:
|
|
167
|
+
return "Please provide a query term or use --list."
|
|
168
|
+
|
|
169
|
+
q = query.lower()
|
|
170
|
+
results = [item for item in data if q in item.get("component", "").lower()]
|
|
171
|
+
|
|
172
|
+
if not results:
|
|
173
|
+
return f"No results found for '{query}'."
|
|
174
|
+
|
|
175
|
+
for idx, item in enumerate(results):
|
|
176
|
+
if idx > 0:
|
|
177
|
+
lines.append("-" * 40)
|
|
178
|
+
lines.append(f"Component: {item.get('component', 'Unknown')}")
|
|
179
|
+
|
|
180
|
+
py_spec = item.get("python_spec")
|
|
181
|
+
if py_spec:
|
|
182
|
+
lines.append(f"Python Spec: {py_spec.get('file', '')}:{py_spec.get('start_line', '')}-{py_spec.get('end_line', '')} ({py_spec.get('target', '')})")
|
|
183
|
+
|
|
184
|
+
xml_spec = item.get("xml_spec")
|
|
185
|
+
if xml_spec:
|
|
186
|
+
lines.append(f"XML Spec: {xml_spec.get('file', '')}:{xml_spec.get('line', '')}")
|
|
187
|
+
|
|
188
|
+
gen_code = item.get("generated_code", [])
|
|
189
|
+
if gen_code:
|
|
190
|
+
lines.append("Generated Code:")
|
|
191
|
+
for gc in gen_code:
|
|
192
|
+
lines.append(f" - {gc.get('file', '')}:{gc.get('line', '')}")
|
|
193
|
+
return "\n".join(lines)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _do_query(data, query, list_all):
|
|
197
|
+
res = get_query_results(data, query, list_all)
|
|
198
|
+
if res == "Please provide a query term or use --list.":
|
|
199
|
+
print(res)
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
print(res)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_query(args):
|
|
205
|
+
if not os.path.exists(args.source_map):
|
|
206
|
+
print(f"Error: {args.source_map} does not exist.")
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
with open(args.source_map, 'r', encoding='utf-8') as f:
|
|
211
|
+
data = json.load(f)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
print(f"Error reading source map: {e}")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
|
|
216
|
+
_do_query(data, args.query, args.list)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Entry point
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def main():
|
|
224
|
+
parser = argparse.ArgumentParser(
|
|
225
|
+
prog="libspec",
|
|
226
|
+
description="libspec — spec-driven development toolkit",
|
|
227
|
+
)
|
|
228
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
229
|
+
subparsers.required = True
|
|
230
|
+
|
|
231
|
+
# init
|
|
232
|
+
p_init = subparsers.add_parser("init", help="Initialize a new spec directory")
|
|
233
|
+
p_init.set_defaults(func=cmd_init)
|
|
234
|
+
|
|
235
|
+
# build
|
|
236
|
+
p_build = subparsers.add_parser("build", help="Build XML spec and source map from a Python spec file")
|
|
237
|
+
p_build.add_argument("spec_file", help="Path to the Python spec file (must contain a Spec subclass)")
|
|
238
|
+
p_build.add_argument("-o", "--output", metavar="DIR", default="spec-build",
|
|
239
|
+
help="Output directory (default: spec-build)")
|
|
240
|
+
p_build.set_defaults(func=cmd_build)
|
|
241
|
+
|
|
242
|
+
# diff
|
|
243
|
+
p_diff = subparsers.add_parser("diff", help="Diff the two latest XML specs in a build directory")
|
|
244
|
+
p_diff.add_argument("build_dir", help="Directory containing XML spec files")
|
|
245
|
+
p_diff.set_defaults(func=cmd_diff)
|
|
246
|
+
|
|
247
|
+
# query
|
|
248
|
+
p_query = subparsers.add_parser("query", help="Query the source map for LLM context")
|
|
249
|
+
p_query.add_argument("source_map", help="Path to source_map.json")
|
|
250
|
+
p_query.add_argument("query", nargs="?", help="Component name or keyword to search for")
|
|
251
|
+
p_query.add_argument("--list", action="store_true", help="List all components")
|
|
252
|
+
p_query.set_defaults(func=cmd_query)
|
|
253
|
+
|
|
254
|
+
args = parser.parse_args()
|
|
255
|
+
args.func(args)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
libspec/err.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UnimplementedMethodError(NotImplementedError):
|
|
5
|
+
"""
|
|
6
|
+
Custom exception for unimplemented methods that automatically
|
|
7
|
+
includes the method name and class name using introspection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message=None):
|
|
11
|
+
# Get the frame that called this exception
|
|
12
|
+
frame = inspect.currentframe().f_back
|
|
13
|
+
|
|
14
|
+
# Get the method name
|
|
15
|
+
method_name = frame.f_code.co_name
|
|
16
|
+
|
|
17
|
+
# Get the class name by inspecting 'self' or 'cls' in the frame's local variables
|
|
18
|
+
class_name = None
|
|
19
|
+
local_vars = frame.f_locals
|
|
20
|
+
|
|
21
|
+
if 'self' in local_vars:
|
|
22
|
+
class_name = local_vars['self'].__class__.__name__
|
|
23
|
+
elif 'cls' in local_vars:
|
|
24
|
+
class_name = local_vars['cls'].__name__
|
|
25
|
+
|
|
26
|
+
# Build the error message
|
|
27
|
+
if class_name:
|
|
28
|
+
auto_message = f"Method '{method_name}' is not implemented in class '{class_name}'"
|
|
29
|
+
else:
|
|
30
|
+
auto_message = f"Method '{method_name}' is not implemented"
|
|
31
|
+
|
|
32
|
+
# Use custom message if provided, otherwise use auto-generated one
|
|
33
|
+
final_message = f"{auto_message}. {message}" if message else auto_message
|
|
34
|
+
|
|
35
|
+
super().__init__(final_message)
|
|
36
|
+
|
|
37
|
+
|
libspec/mcp_server.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server entry point for libspec.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from libspec.cli import get_query_results
|
|
12
|
+
|
|
13
|
+
mcp = FastMCP("libspec")
|
|
14
|
+
|
|
15
|
+
@mcp.tool()
|
|
16
|
+
def libspec_query(query: str = None, source_map: str = None, list_all: bool = False) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Query the libspec source map for LLM context.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
query: Component name or keyword to search for
|
|
22
|
+
source_map: Path to the source_map.json file (defaults to ./spec-build/source_map.json)
|
|
23
|
+
list_all: List all components in the source map
|
|
24
|
+
"""
|
|
25
|
+
if not source_map:
|
|
26
|
+
source_map = os.path.join(os.getcwd(), "spec-build", "source_map.json")
|
|
27
|
+
if not os.path.exists(source_map):
|
|
28
|
+
return f"Error: source map '{source_map}' does not exist."
|
|
29
|
+
try:
|
|
30
|
+
with open(source_map, 'r', encoding='utf-8') as f:
|
|
31
|
+
data = json.load(f)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
return f"Error reading source map: {e}"
|
|
34
|
+
|
|
35
|
+
return get_query_results(data, query, list_all)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def libspec_build(spec_file: str = None, output_dir: str = "spec-build") -> str:
|
|
40
|
+
"""
|
|
41
|
+
Build the XML spec and source map from a Python spec file.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
spec_file: Path to the main python spec file. If omitted, attempts to auto-discover in the current directory.
|
|
45
|
+
output_dir: Output directory (default is 'spec-build')
|
|
46
|
+
"""
|
|
47
|
+
if not spec_file:
|
|
48
|
+
import glob
|
|
49
|
+
candidates = glob.glob(os.path.join(os.getcwd(), "*_spec.py")) + glob.glob(os.path.join(os.getcwd(), "spec.py")) + glob.glob(os.path.join(os.getcwd(), "spec", "*_spec.py"))
|
|
50
|
+
if not candidates:
|
|
51
|
+
return "Error: Could not auto-discover a spec_file. Please provide one."
|
|
52
|
+
spec_file = candidates[0]
|
|
53
|
+
|
|
54
|
+
cmd = [sys.executable, "-m", "libspec.cli", "build", spec_file, "-o", output_dir]
|
|
55
|
+
try:
|
|
56
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
57
|
+
return f"Successfully built {spec_file} to {output_dir}.\n{res.stdout}"
|
|
58
|
+
except subprocess.CalledProcessError as e:
|
|
59
|
+
return f"Error building spec:\n{e.stderr}\n{e.stdout}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def libspec_diff(build_dir: str = "spec-build") -> str:
|
|
64
|
+
"""
|
|
65
|
+
Diff the two latest XML specs in a build directory.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
build_dir: Directory containing XML spec files (default is 'spec-build')
|
|
69
|
+
"""
|
|
70
|
+
cmd = [sys.executable, "-m", "libspec.cli", "diff", build_dir]
|
|
71
|
+
try:
|
|
72
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
73
|
+
return res.stdout or "No changes detected."
|
|
74
|
+
except subprocess.CalledProcessError as e:
|
|
75
|
+
return f"Error running diff:\n{e.stderr}\n{e.stdout}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
mcp.run(transport='stdio')
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
libspec/query_map.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Deprecated: use `libspec query` instead."""
|
|
2
|
+
|
|
3
|
+
# This module's logic has moved to libspec.cli.
|
|
4
|
+
# Kept as a shim so that `python -m libspec.query_map` still works.
|
|
5
|
+
|
|
6
|
+
from libspec.cli import cmd_query, main as _cli_main
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
"""Backwards-compatible entry point — delegates to `libspec query`."""
|
|
13
|
+
# Prepend 'query' so the unified CLI parser is satisfied
|
|
14
|
+
sys.argv.insert(1, 'query')
|
|
15
|
+
_cli_main()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|