plain-parser 0.1.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.
@@ -0,0 +1,414 @@
1
+ import hashlib
2
+ import json
3
+ import uuid
4
+ from typing import Optional
5
+
6
+ from liquid2.filter import with_context
7
+
8
+ from plain_parser.exceptions import InvalidFridArgument, InvalidLiquidVariableName
9
+
10
+ DEFINITIONS = "definitions"
11
+ NON_FUNCTIONAL_REQUIREMENTS = "implementation reqs"
12
+ TEST_REQUIREMENTS = "test reqs"
13
+ FUNCTIONAL_REQUIREMENTS = "functional specs"
14
+ ACCEPTANCE_TESTS = "acceptance_tests"
15
+ ACCEPTANCE_TEST_HEADING = "acceptance tests"
16
+
17
+ ALLOWED_SPECIFICATION_HEADINGS = [
18
+ DEFINITIONS,
19
+ NON_FUNCTIONAL_REQUIREMENTS,
20
+ TEST_REQUIREMENTS,
21
+ FUNCTIONAL_REQUIREMENTS,
22
+ ACCEPTANCE_TEST_HEADING,
23
+ ]
24
+
25
+ ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS]
26
+
27
+
28
+ def has_acceptance_tests(plain_source_tree) -> bool:
29
+ """Return True if any functional requirement in the plain source defines acceptance tests."""
30
+ functional_requirements = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or []
31
+ for functional_requirement in functional_requirements:
32
+ if functional_requirement.get(ACCEPTANCE_TESTS):
33
+ return True
34
+ return False
35
+
36
+
37
+ def collect_specification_linked_resources(specification, specification_heading, linked_resources_list):
38
+ linked_resources = []
39
+ if "linked_resources" in specification:
40
+ linked_resources.extend(specification["linked_resources"])
41
+
42
+ for resource in linked_resources:
43
+ resource_found = False
44
+ for resource_map in linked_resources_list:
45
+ if resource["text"] == resource_map["text"]:
46
+ if resource["target"] != resource_map["target"]:
47
+ raise Exception(
48
+ f"The file {resource['target']} is linked to multiple linked resources with the same text: {resource['text']}"
49
+ )
50
+ elif resource["target"] == resource_map["target"]:
51
+ if resource["text"] != resource_map["text"]:
52
+ raise Exception(
53
+ f"The linked resource {resource['text']} is linked to multiple files: {resource_map['target']}"
54
+ )
55
+ else:
56
+ continue
57
+
58
+ if resource_found:
59
+ raise Exception(
60
+ "Duplicate linked resource found: " + resource["text"] + " (" + resource["target"] + ")"
61
+ )
62
+
63
+ resource_found = True
64
+ resource_map["sections"].append(specification_heading)
65
+
66
+ if not resource_found:
67
+ linked_resources_list.append(
68
+ {"text": resource["text"], "target": resource["target"], "sections": [specification_heading]}
69
+ )
70
+
71
+
72
+ def collect_linked_resources_in_section(
73
+ section, linked_resources_list, specifications_list, include_acceptance_tests, frid=Optional[str]
74
+ ):
75
+ # When should we collect specification headings in the current section. Either when one of the following holds true:
76
+ # - frid wasn't specified
77
+ # - section has no ID (and that's exactly when it's the root section)
78
+ # - section has ID, frid was specified and the specified frid inside the current section tree
79
+ should_collect_specification_headings_in_current_section = (
80
+ frid is None or "ID" not in section or frid.startswith(section["ID"])
81
+ )
82
+ if should_collect_specification_headings_in_current_section:
83
+ specifications_to_collect = list(set([DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS]))
84
+ if specifications_list:
85
+ specifications_to_collect = list(set(specifications_to_collect) & set(specifications_list))
86
+ for specification_heading in specifications_to_collect:
87
+ if specification_heading in section:
88
+ for requirement in section[specification_heading]:
89
+ collect_specification_linked_resources(requirement, specification_heading, linked_resources_list)
90
+
91
+ if FUNCTIONAL_REQUIREMENTS in section and (
92
+ not specifications_list or FUNCTIONAL_REQUIREMENTS in specifications_list
93
+ ):
94
+ functional_requirement_count = 0
95
+ for requirement in section[FUNCTIONAL_REQUIREMENTS]:
96
+ collect_specification_linked_resources(requirement, FUNCTIONAL_REQUIREMENTS, linked_resources_list)
97
+
98
+ functional_requirement_count += 1
99
+ section_id = section.get("ID", None)
100
+ current_frid = get_current_frid(section_id, functional_requirement_count)
101
+
102
+ if ACCEPTANCE_TESTS in requirement and include_acceptance_tests and (frid is None or frid == current_frid):
103
+ for acceptance_test in requirement[ACCEPTANCE_TESTS]:
104
+ collect_specification_linked_resources(
105
+ acceptance_test, FUNCTIONAL_REQUIREMENTS, linked_resources_list
106
+ )
107
+
108
+ if frid is not None and current_frid == frid:
109
+ # here, we rely on the fact that FRIDs are incrementing. And if we have reached the current FRID, we should
110
+ # not collect any FRIDs after the current one.
111
+ return True
112
+
113
+ if "sections" in section:
114
+ for subsection in section["sections"]:
115
+ if collect_linked_resources_in_section(
116
+ subsection, linked_resources_list, specifications_list, include_acceptance_tests, frid
117
+ ):
118
+ return True
119
+
120
+ return False
121
+
122
+
123
+ # TODO: check if this function can be refactored to return the list of linked resources instead of modifying it in place
124
+ def collect_linked_resources(
125
+ plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid=None
126
+ ):
127
+
128
+ if not isinstance(plain_source_tree, dict):
129
+ raise ValueError("[plain_source_tree must be a dictionary.")
130
+
131
+ if frid is not None:
132
+ functional_requirements = get_frids(plain_source_tree)
133
+ if frid not in functional_requirements:
134
+ raise ValueError(f"frid {frid} does not exist.")
135
+
136
+ result = collect_linked_resources_in_section(
137
+ plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid
138
+ )
139
+
140
+ # Sort linked_resources_list by the "text" field
141
+ linked_resources_list.sort(key=lambda x: x["text"])
142
+
143
+ return result
144
+
145
+
146
+ def get_frids(plain_source_tree):
147
+ if FUNCTIONAL_REQUIREMENTS in plain_source_tree:
148
+ for functional_requirement_id in range(1, len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) + 1):
149
+ if "ID" in plain_source_tree:
150
+ yield plain_source_tree["ID"] + "." + str(functional_requirement_id)
151
+ else:
152
+ yield str(functional_requirement_id)
153
+
154
+ if "sections" in plain_source_tree:
155
+ for section in plain_source_tree["sections"]:
156
+ yield from get_frids(section)
157
+
158
+
159
+ def get_first_frid(plain_source_tree):
160
+ return next(get_frids(plain_source_tree), None)
161
+
162
+
163
+ def get_current_frid(section_id: Optional[str], functional_requirement_count: int) -> str:
164
+ if section_id is None:
165
+ return str(functional_requirement_count)
166
+ else:
167
+ return section_id + "." + str(functional_requirement_count)
168
+
169
+
170
+ def get_next_frid(plain_source_tree, frid):
171
+ functional_requirements = get_frids(plain_source_tree)
172
+ for temp_frid in functional_requirements:
173
+ if temp_frid == frid:
174
+ return next(functional_requirements, None)
175
+
176
+ raise Exception(f"Functionality {frid} does not exist.")
177
+
178
+
179
+ def get_previous_frid(plain_source_tree, frid):
180
+ previous_frid = None
181
+ for temp_frid in get_frids(plain_source_tree):
182
+ if temp_frid == frid:
183
+ return previous_frid
184
+
185
+ previous_frid = temp_frid
186
+
187
+ raise Exception(f"Functionality {frid} does not exist.")
188
+
189
+
190
+ def get_frids_before(plain_source_tree, target_frid: str) -> list[str]:
191
+ """
192
+ Get all FRIDs that appear before the target FRID in the specification.
193
+
194
+ Args:
195
+ plain_source_tree: The plain source tree
196
+ target_frid: The FRID to find predecessors for
197
+
198
+ Returns:
199
+ List of FRIDs that appear before target_frid, in order
200
+ """
201
+ previous_frids = []
202
+ for frid in get_frids(plain_source_tree):
203
+ if frid == target_frid:
204
+ break
205
+ previous_frids.append(frid)
206
+
207
+ return previous_frids
208
+
209
+
210
+ def get_specification_item_markdown(specification_item, code_variables, replace_code_variables):
211
+ markdown = specification_item["markdown"]
212
+ if "code_variables" in specification_item:
213
+ for code_variable in specification_item["code_variables"]:
214
+ if code_variable["name"] in code_variables:
215
+ if code_variables[code_variable["name"]] != code_variable["value"]:
216
+ raise Exception(
217
+ f"Code variable {code_variable['name']} has multiple values: {code_variables[code_variable['name']]} and {code_variable['value']}"
218
+ )
219
+ else:
220
+ code_variables[code_variable["name"]] = code_variable["value"]
221
+
222
+ if replace_code_variables:
223
+ markdown = markdown.replace(f"{{{{ {code_variable['name']} }}}}", code_variable["value"])
224
+
225
+ return markdown
226
+
227
+
228
+ def get_specifications_from_plain_source_tree(
229
+ frid,
230
+ plain_source_tree,
231
+ definitions,
232
+ non_functional_requirements,
233
+ test_requirements,
234
+ functional_requirements,
235
+ acceptance_tests,
236
+ code_variables,
237
+ replace_code_variables,
238
+ section_id=None,
239
+ ):
240
+ return_frid = None
241
+ if FUNCTIONAL_REQUIREMENTS in plain_source_tree and len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) > 0:
242
+ functional_requirement_count = 0
243
+ for functional_requirement in plain_source_tree[FUNCTIONAL_REQUIREMENTS]:
244
+ functional_requirement_count += 1
245
+ if section_id is None:
246
+ current_frid = str(functional_requirement_count)
247
+ else:
248
+ current_frid = section_id + "." + str(functional_requirement_count)
249
+
250
+ functional_requirements.append(
251
+ get_specification_item_markdown(functional_requirement, code_variables, replace_code_variables)
252
+ )
253
+
254
+ if current_frid == frid:
255
+ if ACCEPTANCE_TESTS in functional_requirement:
256
+ for acceptance_test in functional_requirement[ACCEPTANCE_TESTS]:
257
+ acceptance_tests.append(
258
+ get_specification_item_markdown(acceptance_test, code_variables, replace_code_variables)
259
+ )
260
+
261
+ return_frid = current_frid
262
+ break
263
+
264
+ if "sections" in plain_source_tree:
265
+ for section in plain_source_tree["sections"]:
266
+ sub_frid = get_specifications_from_plain_source_tree(
267
+ frid,
268
+ section,
269
+ definitions,
270
+ non_functional_requirements,
271
+ test_requirements,
272
+ functional_requirements,
273
+ acceptance_tests,
274
+ code_variables,
275
+ replace_code_variables,
276
+ section["ID"],
277
+ )
278
+ if sub_frid is not None:
279
+ return_frid = sub_frid
280
+ break
281
+
282
+ if return_frid is not None:
283
+ if DEFINITIONS in plain_source_tree and plain_source_tree[DEFINITIONS] is not None:
284
+ definitions[0:0] = [
285
+ get_specification_item_markdown(specification, code_variables, replace_code_variables)
286
+ for specification in plain_source_tree[DEFINITIONS]
287
+ ]
288
+ if (
289
+ NON_FUNCTIONAL_REQUIREMENTS in plain_source_tree
290
+ and plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] is not None
291
+ ):
292
+ non_functional_requirements[0:0] = [
293
+ get_specification_item_markdown(specification, code_variables, replace_code_variables)
294
+ for specification in plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS]
295
+ ]
296
+ if TEST_REQUIREMENTS in plain_source_tree and plain_source_tree[TEST_REQUIREMENTS] is not None:
297
+ test_requirements[0:0] = [
298
+ get_specification_item_markdown(specification, code_variables, replace_code_variables)
299
+ for specification in plain_source_tree[TEST_REQUIREMENTS]
300
+ ]
301
+
302
+ return return_frid
303
+
304
+
305
+ def get_specifications_for_frid(plain_source_tree, frid, replace_code_variables=True):
306
+ definitions = []
307
+ non_functional_requirements = []
308
+ test_requirements = []
309
+ functional_requirements = []
310
+ acceptance_tests = []
311
+
312
+ code_variables = {}
313
+
314
+ result = get_specifications_from_plain_source_tree(
315
+ frid,
316
+ plain_source_tree,
317
+ definitions,
318
+ non_functional_requirements,
319
+ test_requirements,
320
+ functional_requirements,
321
+ acceptance_tests,
322
+ code_variables,
323
+ replace_code_variables,
324
+ )
325
+ if result is None:
326
+ raise Exception(f"Functionality {frid} does not exist.")
327
+
328
+ specifications = {
329
+ DEFINITIONS: definitions,
330
+ NON_FUNCTIONAL_REQUIREMENTS: non_functional_requirements,
331
+ TEST_REQUIREMENTS: test_requirements,
332
+ FUNCTIONAL_REQUIREMENTS: functional_requirements,
333
+ }
334
+
335
+ if acceptance_tests:
336
+ specifications[ACCEPTANCE_TESTS] = acceptance_tests
337
+
338
+ if code_variables:
339
+ return specifications, code_variables
340
+ else:
341
+ return specifications, None
342
+
343
+
344
+ @with_context
345
+ def code_variable_liquid_filter(value, *, context):
346
+ if len(context.scope) == 0:
347
+ raise Exception("Invalid use of code_variable filter!")
348
+
349
+ if "code_variables" in context.globals:
350
+ code_variables = context.globals["code_variables"]
351
+
352
+ variable = next(iter(context.scope.items()))
353
+
354
+ unique_str = uuid.uuid4().hex
355
+
356
+ code_variables[unique_str] = {variable[0]: value}
357
+
358
+ return unique_str
359
+ else:
360
+
361
+ return value
362
+
363
+
364
+ @with_context
365
+ def prohibited_chars_liquid_filter(value, prohibited_chars, *, context):
366
+ if not isinstance(value, str):
367
+ value = str(value)
368
+
369
+ if len(context.scope) == 0:
370
+ raise Exception("Invalid use of prohibited_chars filter!")
371
+
372
+ variable = next(iter(context.scope.items()))
373
+ variable_name = variable[0]
374
+
375
+ for char in prohibited_chars:
376
+ if char in value:
377
+ raise InvalidLiquidVariableName(
378
+ f"'{char}' is not a valid character for variable '{variable_name}' (value: '{value}')."
379
+ )
380
+
381
+ return value
382
+
383
+
384
+ def hash_text(text):
385
+ return hashlib.sha256(text.encode()).hexdigest()
386
+
387
+
388
+ def get_hash_value(specifications):
389
+ return hash_text(json.dumps(specifications, indent=4))
390
+
391
+
392
+ def get_frids_range(plain_source, start, end=None):
393
+ """Return the FRIDs from ``start`` through ``end`` inclusive, or through the last FRID when ``end`` is None."""
394
+ frids = list(get_frids(plain_source))
395
+
396
+ start = str(start)
397
+
398
+ if start not in frids:
399
+ raise InvalidFridArgument(f"Invalid start functionality ID: {start}. Valid IDs are: {frids}.")
400
+
401
+ if end is not None:
402
+ end = str(end)
403
+ if end not in frids:
404
+ raise InvalidFridArgument(f"Invalid end functionality ID: {end}. Valid IDs are: {frids}.")
405
+
406
+ end_idx = frids.index(end) + 1
407
+ else:
408
+ end_idx = len(frids)
409
+
410
+ start_idx = frids.index(start)
411
+ if start_idx >= end_idx:
412
+ raise InvalidFridArgument(f"Start functionality ID: {start} must be before end functionality ID: {end}.")
413
+
414
+ return frids[start_idx:end_idx]
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: plain-parser
3
+ Version: 0.1.0
4
+ Summary: Parser for ***plain specification files
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Software Development :: Compilers
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: mistletoe<2,>=1.6.0
13
+ Requires-Dist: networkx<4,>=3.6.1
14
+ Requires-Dist: python-frontmatter<2,>=1.3.0
15
+ Requires-Dist: python-liquid2<0.5,>=0.4.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: black==26.5.1; extra == 'dev'
18
+ Requires-Dist: flake8==7.3.0; extra == 'dev'
19
+ Requires-Dist: isort==8.0.1; extra == 'dev'
20
+ Requires-Dist: mypy==2.3.0; extra == 'dev'
21
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # plain-parser
25
+
26
+ Python parser for [`***plain`](https://www.plainlang.org/docs/) specification files.
27
+
28
+ Reads a `.plain` module and its `import` / `requires` chain, resolves Liquid templates,
29
+ validates concepts and linked resources, and returns the marshalled specification tree
30
+ plus the ordered list of functionalities (FRIDs).
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install plain-parser
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from plain_parser import plain_file_parser
42
+
43
+ module_name, plain_source_tree, required_modules = plain_file_parser("my_spec.plain", ["."])
44
+ ```
45
+
46
+ `plain_source_tree` is a dict keyed by section (`definitions`, `implementation reqs`,
47
+ `test reqs`, `functional specs`), each a list of `{"markdown": ..., "linked_resources": [...]}`
48
+ entries. `plain_parser.plain_spec` has helpers for walking functionalities
49
+ (`get_frids`, `get_specifications_for_frid`, `collect_linked_resources`).
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ uv venv --python 3.11 && source .venv/bin/activate
55
+ uv pip install -e ".[dev]"
56
+ pytest
57
+ black . --check && isort . --check-only && flake8 . && mypy src
58
+ ```
59
+
60
+ ## License
61
+
62
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ plain_parser/__init__.py,sha256=r9ld0v4MDuoNA4pv2vY9qEAVcwo6QWS6f5AkYeHdJFQ,675
2
+ plain_parser/concept_utils.py,sha256=BJzddKxrwg2KbV-58R74ihIJKcv-CGA4B5NZyhOixQ8,8100
3
+ plain_parser/exceptions.py,sha256=WcHK5g9K6bsE-ZZBKWl0lZtiMTQ6fif8-zdEx9qjmRw,795
4
+ plain_parser/liquid_nodes.py,sha256=CYmujdPJMK9hLjfFPmnSoKL3Ps4sb-2OOhH-fZUssis,3917
5
+ plain_parser/loaders.py,sha256=5drZ7OOPJQjEitjWL5W5NfJ0CUatxKHU1UOwm_xhKKE,3850
6
+ plain_parser/plain_file.py,sha256=XzLH59y8_Ffv_PzrReSnBkoKF1WIvIyZrZAgOY55zfQ,37104
7
+ plain_parser/plain_spec.py,sha256=31mLXDldi9zOXT9aK3QhD2mbxbph4ArFU_qgWYd2nwc,15309
8
+ plain_parser-0.1.0.dist-info/METADATA,sha256=tY9e9zRf_YpfhtAM4OT25RaRT6Rne5VnimR9Av4mOSE,1899
9
+ plain_parser-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ plain_parser-0.1.0.dist-info/licenses/LICENSE,sha256=QClWKncxgRjNUVOW8Axi7zK9HtsMdJEtUSMA4LEe8fo,1071
11
+ plain_parser-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codeplain Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.