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_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
@@ -0,0 +1,13 @@
1
+ # declarative prompts, state what should exist.
2
+
3
+ class Html:
4
+ '''
5
+
6
+ '''
7
+ pass
8
+
9
+ class Css:
10
+ '''
11
+
12
+ '''
13
+
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
+ ![the general idea](./docs/workflow1.png)
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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ libspec = libspec.cli:main
3
+ libspec-mcp = libspec.mcp_server:main