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_diff.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from lxml import etree
|
|
5
|
+
from xmldiff import main
|
|
6
|
+
|
|
7
|
+
def get_latest_xml_files(directory):
|
|
8
|
+
path = Path(directory)
|
|
9
|
+
xml_files = list(path.glob('*.xml'))
|
|
10
|
+
if len(xml_files) == 0:
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
file_info = []
|
|
14
|
+
import datetime
|
|
15
|
+
for f in xml_files:
|
|
16
|
+
try:
|
|
17
|
+
tree = etree.parse(str(f))
|
|
18
|
+
root = tree.getroot()
|
|
19
|
+
date_str = root.get('date-created')
|
|
20
|
+
if date_str:
|
|
21
|
+
file_info.append((date_str, f))
|
|
22
|
+
else:
|
|
23
|
+
# Fallback to file mtime formatted as ISO string
|
|
24
|
+
mtime = os.path.getmtime(str(f))
|
|
25
|
+
dt = datetime.datetime.fromtimestamp(mtime).astimezone()
|
|
26
|
+
file_info.append((dt.isoformat(), f))
|
|
27
|
+
except Exception:
|
|
28
|
+
mtime = os.path.getmtime(str(f))
|
|
29
|
+
dt = datetime.datetime.fromtimestamp(mtime).astimezone()
|
|
30
|
+
file_info.append((dt.isoformat(), f))
|
|
31
|
+
|
|
32
|
+
# Sort by the timestamp (date-created string or mtime)
|
|
33
|
+
file_info.sort(key=lambda x: x[0])
|
|
34
|
+
|
|
35
|
+
if len(file_info) == 1:
|
|
36
|
+
# Only one spec exists — bootstrap case; diff against a null spec
|
|
37
|
+
return None, file_info[-1][1]
|
|
38
|
+
|
|
39
|
+
return file_info[-2][1], file_info[-1][1]
|
|
40
|
+
|
|
41
|
+
def resolve_component(root, xpath):
|
|
42
|
+
"""
|
|
43
|
+
Find the closest parent <specification> and return its type or name.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
nodes = root.xpath(xpath)
|
|
47
|
+
if not nodes:
|
|
48
|
+
return "General System"
|
|
49
|
+
|
|
50
|
+
node = nodes[0]
|
|
51
|
+
# Traverse up to find 'specification'
|
|
52
|
+
curr = node
|
|
53
|
+
while curr is not None:
|
|
54
|
+
if curr.tag == 'specification':
|
|
55
|
+
spec_type = curr.get('type')
|
|
56
|
+
if spec_type:
|
|
57
|
+
return f"[{spec_type}]"
|
|
58
|
+
# Fallback to title in context
|
|
59
|
+
titles = curr.xpath('.//title/text()')
|
|
60
|
+
if titles:
|
|
61
|
+
return f"[{titles[0]}]"
|
|
62
|
+
return "[Specification]"
|
|
63
|
+
curr = curr.getparent()
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return "Core System"
|
|
67
|
+
|
|
68
|
+
def to_human_readable(action, root):
|
|
69
|
+
"""
|
|
70
|
+
Translates a raw xmldiff Action into a human-readable string.
|
|
71
|
+
Returns None if the action is purely structural 'noise'.
|
|
72
|
+
"""
|
|
73
|
+
action_type = type(action).__name__
|
|
74
|
+
|
|
75
|
+
# Target node resolution
|
|
76
|
+
target_path = getattr(action, 'node', getattr(action, 'target', '/'))
|
|
77
|
+
try:
|
|
78
|
+
nodes = root.xpath(target_path)
|
|
79
|
+
tag = nodes[0].tag if nodes else "unknown"
|
|
80
|
+
except Exception:
|
|
81
|
+
tag = "unknown"
|
|
82
|
+
|
|
83
|
+
# Filter out structural boilerplate
|
|
84
|
+
if action_type == 'InsertNode' and tag in ['specification_set', 'specification', 'source', 'context', 'notes', 'description', 'req_id', 'title']:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
if action_type == 'UpdateTextIn':
|
|
88
|
+
text = (action.text or "").strip().replace('\n', ' ')
|
|
89
|
+
|
|
90
|
+
if tag == 'description':
|
|
91
|
+
return f"Updated description: \"{text}\""
|
|
92
|
+
if tag == 'notes':
|
|
93
|
+
return f"Updated requirements: \"{text}\""
|
|
94
|
+
if tag == 'req_id':
|
|
95
|
+
return f"Updated cross-reference ID: {text}"
|
|
96
|
+
if tag == 'title':
|
|
97
|
+
return f"Updated title: {text}"
|
|
98
|
+
return f"Updated {tag} text to \"{text}\""
|
|
99
|
+
|
|
100
|
+
if action_type == 'InsertAttrib':
|
|
101
|
+
name = action.name
|
|
102
|
+
value = action.value
|
|
103
|
+
if name == 'type':
|
|
104
|
+
return f"Defined component type as '{value}'"
|
|
105
|
+
if name == 'file':
|
|
106
|
+
return f"Set source file to '{value}'"
|
|
107
|
+
if name == 'lines':
|
|
108
|
+
return f"Set source lines to {value}"
|
|
109
|
+
if name == 'target':
|
|
110
|
+
return f"Set tracking target to '{value}'"
|
|
111
|
+
return f"Added attribute {name}='{value}'"
|
|
112
|
+
|
|
113
|
+
if action_type == 'InsertNode':
|
|
114
|
+
return f"Added new {tag} element"
|
|
115
|
+
|
|
116
|
+
if action_type == 'DeleteNode':
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
if action_type == 'MoveNode':
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
if action_type == 'UpdateAttrib':
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
if action_type == 'RenameNode':
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Fallback for other actions
|
|
131
|
+
return str(action).replace('\n', '\\n')
|
|
132
|
+
|
|
133
|
+
NULL_SPEC_XML = """<?xml version='1.0' encoding='utf-8'?>
|
|
134
|
+
<specification_set date-created="" />"""
|
|
135
|
+
|
|
136
|
+
def generate_patch(dir_arg):
|
|
137
|
+
files = get_latest_xml_files(dir_arg)
|
|
138
|
+
if not files:
|
|
139
|
+
print("Error: No XML spec files found in the directory.")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
old_file, new_file = files
|
|
143
|
+
|
|
144
|
+
if old_file is None:
|
|
145
|
+
# Bootstrap case: first spec ever — diff against an empty null spec
|
|
146
|
+
print(f"Diffing State: <null spec> -> {new_file.name}")
|
|
147
|
+
print("=" * 60)
|
|
148
|
+
import tempfile, io
|
|
149
|
+
with tempfile.NamedTemporaryFile(suffix='.xml', mode='w', delete=False) as tmp:
|
|
150
|
+
tmp.write(NULL_SPEC_XML)
|
|
151
|
+
tmp_path = tmp.name
|
|
152
|
+
try:
|
|
153
|
+
new_tree = etree.parse(str(new_file))
|
|
154
|
+
new_root = new_tree.getroot()
|
|
155
|
+
diffs = main.diff_files(tmp_path, str(new_file))
|
|
156
|
+
finally:
|
|
157
|
+
os.unlink(tmp_path)
|
|
158
|
+
else:
|
|
159
|
+
print(f"Diffing State: {old_file.name} -> {new_file.name}")
|
|
160
|
+
print("=" * 60)
|
|
161
|
+
try:
|
|
162
|
+
new_tree = etree.parse(str(new_file))
|
|
163
|
+
new_root = new_tree.getroot()
|
|
164
|
+
except Exception as e:
|
|
165
|
+
print(f"Error parsing XML: {e}")
|
|
166
|
+
return
|
|
167
|
+
diffs = main.diff_files(str(old_file), str(new_file))
|
|
168
|
+
if not diffs:
|
|
169
|
+
print("No changes detected.")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
groups = {}
|
|
173
|
+
for op in diffs:
|
|
174
|
+
target_path = getattr(op, 'node', getattr(op, 'target', '/'))
|
|
175
|
+
component = resolve_component(new_root, target_path)
|
|
176
|
+
|
|
177
|
+
readable = to_human_readable(op, new_root)
|
|
178
|
+
if readable:
|
|
179
|
+
if component not in groups:
|
|
180
|
+
groups[component] = []
|
|
181
|
+
groups[component].append(readable)
|
|
182
|
+
|
|
183
|
+
for component, descriptions in groups.items():
|
|
184
|
+
print(f"\nCOMPONENT: {component}")
|
|
185
|
+
for desc in descriptions:
|
|
186
|
+
print(f" - {desc}")
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
# Delegate to the unified CLI: `libspec diff <dir>`
|
|
190
|
+
import sys as _sys
|
|
191
|
+
from libspec.cli import main as _cli_main
|
|
192
|
+
_sys.argv.insert(1, 'diff')
|
|
193
|
+
_cli_main()
|
libspec/specweb.py
ADDED
libspec/user_story.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from libspec import Feature
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# https://github.com/github/spec-kit/blob/main/templates/spec-template.md
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
class UserStory(Feature):
|
|
8
|
+
'''
|
|
9
|
+
User Story - {brief-title} (Priority: {priority})
|
|
10
|
+
|
|
11
|
+
{user-journey}
|
|
12
|
+
|
|
13
|
+
Why this priority: {explanation}
|
|
14
|
+
|
|
15
|
+
Independent Test: [Describe how this can be tested independently -
|
|
16
|
+
e.g., "Can be fully tested by [specific action] and delivers
|
|
17
|
+
[specific value]"]
|
|
18
|
+
|
|
19
|
+
Acceptance Scenarios:
|
|
20
|
+
|
|
21
|
+
Given [initial state], When [action], Then [expected outcome]
|
|
22
|
+
Given [initial state], When [action], Then [expected outcome]
|
|
23
|
+
'''
|
|
24
|
+
|
libspec/util.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import difflib
|
|
4
|
+
|
|
5
|
+
def easy_hash(text):
|
|
6
|
+
return hashlib.md5(text.encode()).hexdigest()
|
|
7
|
+
|
|
8
|
+
def fqn(obj):
|
|
9
|
+
return f"{type(obj).__module__}.{type(obj).__qualname__}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def diff_two_latest(dirpath):
|
|
13
|
+
files = [p for p in Path(dirpath).iterdir() if p.is_file()]
|
|
14
|
+
if len(files) < 2:
|
|
15
|
+
raise ValueError("Need at least two files")
|
|
16
|
+
|
|
17
|
+
latest = sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)[:2]
|
|
18
|
+
newer, older = latest[0], latest[1]
|
|
19
|
+
|
|
20
|
+
with older.open() as f1, newer.open() as f2:
|
|
21
|
+
return "".join(difflib.unified_diff(
|
|
22
|
+
f1.readlines(),
|
|
23
|
+
f2.readlines(),
|
|
24
|
+
fromfile=str(older),
|
|
25
|
+
tofile=str(newer),
|
|
26
|
+
))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: libspec
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Spec-driven development library for LLM-assisted coding
|
|
5
|
+
Author-email: "Derek A. Rhodes" <physci@gmail.com>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/drhodes/libspec
|
|
8
|
+
Project-URL: Repository, https://github.com/drhodes/libspec
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/drhodes/libspec/issues
|
|
10
|
+
Keywords: specification,llm,codegen,spec-driven,development
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: jinja2>=3.1.6
|
|
21
|
+
Requires-Dist: lxml>=6.0.2
|
|
22
|
+
Requires-Dist: mcp>=1.27.0
|
|
23
|
+
Requires-Dist: xmldiff>=2.7.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# libspec
|
|
27
|
+
|
|
28
|
+
`libspec` is a library for **Specification Driven Development** in
|
|
29
|
+
Python. Similar in spirit to object relation mapping (ORM), libspec
|
|
30
|
+
uses an Object Specification Mapping. Instead of generating SQL, this
|
|
31
|
+
tool generates specs geared towards code generation with LLMs.
|
|
32
|
+
|
|
33
|
+
Context is managed by diff'ing specs and using source maps with and
|
|
34
|
+
associated agent that can cross reference the specs and generated
|
|
35
|
+
code. The developer workflow is incremental and exploratory, less like
|
|
36
|
+
gambling and more like delegating.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Example Spec
|
|
42
|
+
|
|
43
|
+
Here is a piece of a spec used to declare a small web app for managing
|
|
44
|
+
a locker system
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from libspec import Requirement, RestMixin, Feature
|
|
48
|
+
from .view_types import PublicView, AdminView
|
|
49
|
+
|
|
50
|
+
class Django(Requirement):
|
|
51
|
+
'''Setup a Django environment using 6.0.2, it should use sqlite3 for
|
|
52
|
+
now. Configure REST_FRAMEWORK and djangorestframework.
|
|
53
|
+
Create a django app (with $ ... startapp) called `lockers` and
|
|
54
|
+
wire it up. It should include an example model, view and urls to
|
|
55
|
+
get started.
|
|
56
|
+
|
|
57
|
+
Please ensure that the models are added to admin interface and
|
|
58
|
+
REST API is exposed.
|
|
59
|
+
'''
|
|
60
|
+
|
|
61
|
+
class Model(RestMixin, Requirement):
|
|
62
|
+
'''ensure this django model is added to the admin interface'''
|
|
63
|
+
|
|
64
|
+
class LockerBank(Model):
|
|
65
|
+
'''Represent a physical bank of lockers. Should include a unique
|
|
66
|
+
name.'''
|
|
67
|
+
|
|
68
|
+
class QrCode(Model):
|
|
69
|
+
'''Represent a QR code attached to a locker. Should include a
|
|
70
|
+
unique value, q matching the format in QrCodeRequirement.
|
|
71
|
+
'''
|
|
72
|
+
|
|
73
|
+
class Locker(Model):
|
|
74
|
+
'''Represent an individual locker unit. Should be associated with
|
|
75
|
+
a LockerBank and two QrCodes (one inside, one outside). Should
|
|
76
|
+
track occupancy status and the timestamp of the last inside QR
|
|
77
|
+
code scan. A locker will have a small number that uniquely
|
|
78
|
+
identifies it within a locker bank. When a locker is created, it
|
|
79
|
+
should automatically create the qr codes associated with it.
|
|
80
|
+
'''
|
|
81
|
+
|
|
82
|
+
class GunicornServer(Requirement):
|
|
83
|
+
'''In the Makefile, create a rule to run the gunicorn server on
|
|
84
|
+
port 8000'''
|
|
85
|
+
|
|
86
|
+
class DevelopmentServer(Requirement):
|
|
87
|
+
'''In the Makefile, create a rule to run the development server on
|
|
88
|
+
port 8000'''
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
# The Object Model
|
|
93
|
+
|
|
94
|
+
Each class declares a specification fragment that is optionally a
|
|
95
|
+
Jinja2 template string. More about that later...
|
|
96
|
+
|
|
97
|
+
## Inheritance
|
|
98
|
+
|
|
99
|
+
Inherited specification fragments are prepended to the Base class' doc
|
|
100
|
+
string.
|
|
101
|
+
|
|
102
|
+
## Mixins
|
|
103
|
+
|
|
104
|
+
Mixins help get around the diamond problem. (TODO: write more about this)
|
|
105
|
+
|
|
106
|
+
## Versioning
|
|
107
|
+
|
|
108
|
+
Note that the versioning of `libspec` is still being hammered
|
|
109
|
+
out. Currently, the version of `libspec` appears in the generated XML
|
|
110
|
+
(`libspec-version` field). But, how diffs will be performed on
|
|
111
|
+
different versions is unexplored.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
libspec/__init__.py,sha256=XhjFF8s-kNgHjgbyF9mxQffD7jndc6rPK40tlGaEZq8,165
|
|
2
|
+
libspec/cli.py,sha256=8TqeMRbdUFkexmQtbpkQmIA00VFJsYQuo0xqZcgbShU,9511
|
|
3
|
+
libspec/err.py,sha256=l9lJdeDwCA5j1fsU-ACgbJqnsFHVQGgHrU9aRq-vkRI,1256
|
|
4
|
+
libspec/mcp_server.py,sha256=t_kFJjUXQlQmAI_ikiypVGZh2rSgZdJhoaskf-xc3ow,2783
|
|
5
|
+
libspec/query_map.py,sha256=3pAzvDEyHILzwee8hV_iqwRPF3f72d1HxIdBNMO-LCg,485
|
|
6
|
+
libspec/spec.py,sha256=vQEtY_RvrKU7GkAlVquR2nvQLkesbxz5hdSPOwyaXtY,19343
|
|
7
|
+
libspec/spec_diff.py,sha256=4pOAHAi7deVxzy0NvyGIwE1OS1dOKhIIiEY0YGsWmdk,6203
|
|
8
|
+
libspec/specweb.py,sha256=x0Klc9PH56jH27YzCH85FFpN7sy5sEmbbn75bZ-gxqs,129
|
|
9
|
+
libspec/user_story.py,sha256=9wlLL8sNkG_vDvOgf4n_D-dQugDB_7vLL90JdwXaKv4,595
|
|
10
|
+
libspec/util.py,sha256=w67biTN77EOOFgj7k7bBLwHbkvU-ppikeOkin04cFE8,728
|
|
11
|
+
libspec-1.2.0.dist-info/licenses/LICENSE,sha256=5X8cMguM-HmKfS_4Om-eBqM6A1hfbgZf6pfx2G24QFI,35150
|
|
12
|
+
libspec-1.2.0.dist-info/METADATA,sha256=kTyC1Z4TU7p2b928abgWkiWQohT1TpwwItEbXAfHy5A,3815
|
|
13
|
+
libspec-1.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
libspec-1.2.0.dist-info/entry_points.txt,sha256=FrGhkKwt9AS4ClKgshsq5MNNtTDlOOQD94FHVV-G35A,83
|
|
15
|
+
libspec-1.2.0.dist-info/top_level.txt,sha256=8ln_BTbi05Z_9b7ikS5nD8swQGRWw3-M7re_nd_Coag,8
|
|
16
|
+
libspec-1.2.0.dist-info/RECORD,,
|