plain-parser 0.1.2.dev5__tar.gz → 0.1.2.dev6__tar.gz
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.
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/PKG-INFO +1 -2
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/pyproject.toml +0 -1
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/concept_utils.py +49 -20
- plain_parser-0.1.2.dev6/src/plain_parser/graph.py +164 -0
- plain_parser-0.1.2.dev6/tests/data/plainfileparser/cyclic_definitions_across_import.plain +13 -0
- plain_parser-0.1.2.dev6/tests/data/plainfileparser/cyclic_definitions_long.plain +11 -0
- plain_parser-0.1.2.dev6/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain +11 -0
- plain_parser-0.1.2.dev6/tests/test_concept_utils.py +179 -0
- plain_parser-0.1.2.dev6/tests/test_graph.py +130 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_plainfileparser.py +41 -1
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/.flake8 +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/.github/workflows/lint-and-test.yml +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/.github/workflows/publish-dev-to-pypi.yml +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/.github/workflows/publish-to-pypi.yml +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/.gitignore +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/LICENSE +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/README.md +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/__init__.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/cli.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/exceptions.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/liquid_nodes.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/loaders.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/plain_file.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/src/plain_parser/plain_spec.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/__init__.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/conftest.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/acceptance_test_binary_resource.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/acceptance_test_resource.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/base64_resource.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/binary.bin +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/binary_resource.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/code_variable_conflict.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/code_variable_template.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/missing_resource.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/not_plain.md +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/notes.md +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/requires_binary_resource_base.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/requires_binary_resource_top.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/subdir/nested.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/undefined_concept.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/cli/valid.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/circular_imports_1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/circular_imports_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/circular_imports_main.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_import_1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_import_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_import_common.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_imports_main.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/import_with_functionalities.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/imports_module_with_functionalities.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/imports/non_existent_import.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/add_new_task_modal_specification.yaml +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/duplicate_specification_heading.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/empty_functional_specs_section.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/invalid_specification_order.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/missing_non_functional_requirements.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/no_functional_specs_section.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/plain_source_with_absolute_link.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/plain_source_with_missing_link.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/plain_source_with_url_link.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/task_list_ui_specification.yaml +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/task_manager_with_reference_links.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfile/without_non_functional_requirement.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_definition.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_noconcepts.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_nonconcept.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_nondefined.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_redefinition.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_several_concepts.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_valid.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/cyclic_definitions.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_base.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_imported_exported.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_malformed.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_missing_definition.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_nested.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_nested_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_no_definitions.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_not_declared.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_transitive_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/plain_file_parser_with_comments.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/plain_file_with_comments_indented.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/regular_plain_source.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_defs.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_l1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_l2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_missing.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_module.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_partial.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/requires_without_definitions_example.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/topological_sort.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/topological_sort_not_referenced.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_base.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_fork_top.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_middle.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_top.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/circular_requires_main.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/circular_requires_sub.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_common.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_main.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/independent_requires_1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/independent_requires_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/independent_requires_main.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/invalid_ancestor_base.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/invalid_ancestor_top.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/non_existent_require.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_1.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_common.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_main.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/sample_base64_image.txt +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/simple.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/block_level_include.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/code_variables.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/header.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/implement.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/implement_2.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/malformed_tag.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/template_include.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/test_hardest_problem.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/data/templates/unterminated_output.plain +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_cli.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_exceptions.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_imports.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_loaders.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_plainfile.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_plainspec.py +0 -0
- {plain_parser-0.1.2.dev5 → plain_parser-0.1.2.dev6}/tests/test_requires.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plain-parser
|
|
3
|
-
Version: 0.1.2.
|
|
3
|
+
Version: 0.1.2.dev6
|
|
4
4
|
Summary: Parser for ***plain specification files
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -10,7 +10,6 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Classifier: Topic :: Software Development :: Compilers
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Requires-Dist: mistletoe<2,>=1.6.0
|
|
13
|
-
Requires-Dist: networkx<4,>=3.6.1
|
|
14
13
|
Requires-Dist: python-frontmatter<2,>=1.3.0
|
|
15
14
|
Requires-Dist: python-liquid2<0.5,>=0.4.0
|
|
16
15
|
Requires-Dist: pyyaml<7,>=6
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from copy import deepcopy
|
|
3
|
+
from graphlib import CycleError, TopologicalSorter
|
|
3
4
|
from typing import Optional
|
|
4
5
|
|
|
5
6
|
import mistletoe.block_token
|
|
6
|
-
import networkx as nx
|
|
7
7
|
|
|
8
|
-
from plain_parser import plain_spec
|
|
8
|
+
from plain_parser import graph, plain_spec
|
|
9
9
|
from plain_parser.exceptions import PlainSyntaxError
|
|
10
10
|
|
|
11
11
|
# These are reserved concept names that are pre-defined and must not be redefined in the definitions section.
|
|
@@ -186,34 +186,63 @@ def build_adjacency_list(definitions: Optional[list[dict]]) -> tuple[dict, dict]
|
|
|
186
186
|
return adjacency_list, concept_definitions
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
MAX_REPORTED_CYCLES = 10
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def format_cycles_error(adjacency_list: dict[str, list[str]], concept_definitions: dict[str, dict]) -> str:
|
|
193
|
+
"""
|
|
194
|
+
List the elementary cycles in the graph, each as a "Cycle N: :A: -> :B: -> :A:" line
|
|
195
|
+
followed by the definition of every concept in the cycle. Arrows point from a definition
|
|
196
|
+
to a concept it mentions, each cycle starts at its earliest-defined concept, and cycles
|
|
197
|
+
are ordered by definition position, so the report reads top to bottom like the file.
|
|
198
|
+
At most MAX_REPORTED_CYCLES are listed; the search stops once one more than that is found.
|
|
199
|
+
"""
|
|
200
|
+
position = {concept: index for index, concept in enumerate(concept_definitions)}
|
|
201
|
+
cycles = []
|
|
202
|
+
# key the graph in definition order so a search cut short by the limit has found the earliest cycles
|
|
203
|
+
by_position = {concept: adjacency_list[concept] for concept in [*concept_definitions, *adjacency_list]}
|
|
204
|
+
for cycle in graph.simple_cycles(by_position, limit=MAX_REPORTED_CYCLES + 1):
|
|
205
|
+
# reverse since simple_cycles edges point from a used concept to the one defined with it
|
|
206
|
+
cycle = cycle[::-1]
|
|
207
|
+
first = min(range(len(cycle)), key=lambda i: position[cycle[i]])
|
|
208
|
+
cycles.append(cycle[first:] + cycle[:first])
|
|
209
|
+
cycles.sort(key=lambda cycle: (position[cycle[0]], len(cycle), [position[c] for c in cycle]))
|
|
210
|
+
|
|
211
|
+
if len(cycles) > MAX_REPORTED_CYCLES:
|
|
212
|
+
cycles = cycles[:MAX_REPORTED_CYCLES]
|
|
213
|
+
lines = [
|
|
214
|
+
f"Plain syntax error: Found more than {MAX_REPORTED_CYCLES} cycles in concept definitions. Cycles are not allowed.",
|
|
215
|
+
f"Showing the first {MAX_REPORTED_CYCLES}.",
|
|
216
|
+
]
|
|
217
|
+
else:
|
|
218
|
+
lines = [f"Plain syntax error: Found {len(cycles)} cycle(s) in concept definitions. Cycles are not allowed."]
|
|
219
|
+
for number, cycle in enumerate(cycles, start=1):
|
|
220
|
+
lines.append("")
|
|
221
|
+
lines.append(f"Cycle {number}: " + " -> ".join(cycle + [cycle[0]]))
|
|
222
|
+
# concepts defined on one line can share a cycle; print that line once
|
|
223
|
+
lines.extend(dict.fromkeys(concept_definitions[concept]["markdown"] for concept in cycle))
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
189
227
|
def sort_definitions(definitions: list[dict]) -> list[dict]:
|
|
190
228
|
if len(definitions) <= 1:
|
|
191
229
|
return
|
|
192
230
|
|
|
193
231
|
adjacency_list, concept_definitions = build_adjacency_list(definitions)
|
|
194
232
|
|
|
195
|
-
|
|
233
|
+
# adjacency_list maps a used concept to the concepts defined in terms of it,
|
|
234
|
+
# so the used concept is a predecessor of each of them
|
|
235
|
+
sorter: TopologicalSorter[str] = TopologicalSorter()
|
|
196
236
|
for node, neighbors in adjacency_list.items():
|
|
237
|
+
sorter.add(node)
|
|
197
238
|
for neighbour in neighbors:
|
|
198
|
-
|
|
199
|
-
if not neighbors:
|
|
200
|
-
concept_graph.add_node(node)
|
|
201
|
-
|
|
202
|
-
if not nx.is_directed_acyclic_graph(concept_graph):
|
|
203
|
-
msg = "Found cycles in the concept graph. Cycles are not allowed. "
|
|
204
|
-
all_cycles = list(nx.simple_cycles(concept_graph))
|
|
205
|
-
|
|
206
|
-
for cycle in all_cycles:
|
|
207
|
-
cyclic_definitions = []
|
|
208
|
-
cyclic_definitions.append(concept_definitions[cycle[0]]["markdown"])
|
|
209
|
-
cyclic_definitions.append(concept_definitions[cycle[1]]["markdown"])
|
|
210
|
-
msg += "Cyclic definitions:\n"
|
|
211
|
-
msg += "\n".join(cyclic_definitions)
|
|
212
|
-
msg += "\n"
|
|
239
|
+
sorter.add(neighbour, node)
|
|
213
240
|
|
|
214
|
-
|
|
241
|
+
try:
|
|
242
|
+
order = list(sorter.static_order())
|
|
243
|
+
except CycleError:
|
|
244
|
+
raise PlainSyntaxError(format_cycles_error(adjacency_list, concept_definitions)) from None
|
|
215
245
|
|
|
216
|
-
order = list(nx.topological_sort(concept_graph))
|
|
217
246
|
if len(order) > 0:
|
|
218
247
|
definition_to_order_idx = {
|
|
219
248
|
plain_spec.hash_text(str(concept_definitions[concept])): idx for idx, concept in enumerate(order)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Cycle search over a directed graph given as a mapping from node to its successors.
|
|
2
|
+
|
|
3
|
+
Nodes that appear only as successors are treated as having no outgoing edges.
|
|
4
|
+
|
|
5
|
+
The search is Johnson's algorithm, written from the pseudocode in the paper, with the
|
|
6
|
+
strongly connected components found by Tarjan's algorithm as given on Wikipedia.
|
|
7
|
+
|
|
8
|
+
- D. B. Johnson, "Finding all the elementary circuits of a directed graph",
|
|
9
|
+
SIAM J. Comput. 4(1):77-84, 1975, doi:10.1137/0204007
|
|
10
|
+
- R. Tarjan, "Depth-first search and linear graph algorithms",
|
|
11
|
+
SIAM J. Comput. 1(2):146-160, 1972, doi:10.1137/0201010
|
|
12
|
+
- https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
|
|
13
|
+
|
|
14
|
+
Both searches keep their own stack of frames instead of recursing, so the depth of a
|
|
15
|
+
tangle is not limited by Python's recursion limit.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import heapq
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from collections.abc import Hashable, Iterable, Iterator, Mapping
|
|
21
|
+
from typing import Optional, TypeVar
|
|
22
|
+
|
|
23
|
+
Node = TypeVar("Node", bound=Hashable)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _successors(graph: Mapping[Node, Iterable[Node]]) -> dict[Node, list[Node]]:
|
|
27
|
+
# a repeated edge would otherwise yield every cycle through it once per repeat
|
|
28
|
+
successors = {node: list(dict.fromkeys(targets)) for node, targets in graph.items()}
|
|
29
|
+
for targets in list(successors.values()):
|
|
30
|
+
for target in targets:
|
|
31
|
+
successors.setdefault(target, [])
|
|
32
|
+
return successors
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def strongly_connected_components(graph: Mapping[Node, Iterable[Node]]) -> list[set[Node]]:
|
|
36
|
+
"""Tarjan: one depth-first walk, each node stamped with its visit index and the lowest
|
|
37
|
+
index it can reach; a node whose lowest reachable index is its own closes a component."""
|
|
38
|
+
successors = _successors(graph)
|
|
39
|
+
index: dict[Node, int] = {}
|
|
40
|
+
lowlink: dict[Node, int] = {}
|
|
41
|
+
stack: list[Node] = []
|
|
42
|
+
on_stack: set[Node] = set()
|
|
43
|
+
components: list[set[Node]] = []
|
|
44
|
+
|
|
45
|
+
def visit(node: Node) -> tuple[Node, Iterator[Node]]:
|
|
46
|
+
index[node] = lowlink[node] = len(index)
|
|
47
|
+
stack.append(node)
|
|
48
|
+
on_stack.add(node)
|
|
49
|
+
return node, iter(successors[node])
|
|
50
|
+
|
|
51
|
+
for root in successors:
|
|
52
|
+
if root in index:
|
|
53
|
+
continue
|
|
54
|
+
frames = [visit(root)]
|
|
55
|
+
while frames:
|
|
56
|
+
node, targets = frames[-1]
|
|
57
|
+
for target in targets:
|
|
58
|
+
if target not in index:
|
|
59
|
+
frames.append(visit(target))
|
|
60
|
+
break
|
|
61
|
+
if target in on_stack:
|
|
62
|
+
lowlink[node] = min(lowlink[node], index[target])
|
|
63
|
+
else:
|
|
64
|
+
frames.pop()
|
|
65
|
+
if frames:
|
|
66
|
+
parent = frames[-1][0]
|
|
67
|
+
lowlink[parent] = min(lowlink[parent], lowlink[node])
|
|
68
|
+
if lowlink[node] == index[node]:
|
|
69
|
+
component: set[Node] = set()
|
|
70
|
+
while True:
|
|
71
|
+
member = stack.pop()
|
|
72
|
+
on_stack.discard(member)
|
|
73
|
+
component.add(member)
|
|
74
|
+
if member == node:
|
|
75
|
+
break
|
|
76
|
+
components.append(component)
|
|
77
|
+
return components
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cycles_through(adjacency: dict[Node, list[Node]], s: Node) -> Iterator[list[Node]]:
|
|
81
|
+
"""Johnson's CIRCUIT: every elementary cycle through `s` in one strongly connected component.
|
|
82
|
+
|
|
83
|
+
A node is blocked while it is on the path, and stays blocked after backtracking until a
|
|
84
|
+
cycle is found through some node it was waiting on."""
|
|
85
|
+
blocked: set[Node] = set()
|
|
86
|
+
blocked_map: dict[Node, set[Node]] = defaultdict(set)
|
|
87
|
+
|
|
88
|
+
def unblock(node: Node) -> None:
|
|
89
|
+
pending = [node]
|
|
90
|
+
while pending:
|
|
91
|
+
current = pending.pop()
|
|
92
|
+
blocked.discard(current)
|
|
93
|
+
pending.extend(waiting for waiting in blocked_map.pop(current, ()) if waiting in blocked)
|
|
94
|
+
|
|
95
|
+
path = [s]
|
|
96
|
+
blocked.add(s)
|
|
97
|
+
frames = [iter(adjacency[s])]
|
|
98
|
+
found_below = [False]
|
|
99
|
+
while frames:
|
|
100
|
+
node = path[-1]
|
|
101
|
+
for target in frames[-1]:
|
|
102
|
+
if target == s:
|
|
103
|
+
yield list(path)
|
|
104
|
+
found_below[-1] = True
|
|
105
|
+
elif target not in blocked:
|
|
106
|
+
path.append(target)
|
|
107
|
+
blocked.add(target)
|
|
108
|
+
frames.append(iter(adjacency[target]))
|
|
109
|
+
found_below.append(False)
|
|
110
|
+
break
|
|
111
|
+
else:
|
|
112
|
+
frames.pop()
|
|
113
|
+
path.pop()
|
|
114
|
+
if found_below.pop():
|
|
115
|
+
unblock(node)
|
|
116
|
+
if found_below:
|
|
117
|
+
found_below[-1] = True
|
|
118
|
+
else:
|
|
119
|
+
for target in adjacency[node]:
|
|
120
|
+
blocked_map[target].add(node)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _all_cycles(graph: Mapping[Node, Iterable[Node]]) -> Iterator[list[Node]]:
|
|
124
|
+
"""Johnson's main loop: every cycle lies inside one strongly connected component, and
|
|
125
|
+
every cycle inside a component either passes through its least node or survives that
|
|
126
|
+
node's removal. So search each component from its least node, drop the node, and split
|
|
127
|
+
what is left into components again. Components are taken least node first, so a caller
|
|
128
|
+
that stops early has seen the cycles through the earliest nodes."""
|
|
129
|
+
successors = _successors(graph)
|
|
130
|
+
for node, targets in successors.items():
|
|
131
|
+
if node in targets:
|
|
132
|
+
yield [node]
|
|
133
|
+
successors = {node: [t for t in targets if t != node] for node, targets in successors.items()}
|
|
134
|
+
position = {node: i for i, node in enumerate(successors)}
|
|
135
|
+
|
|
136
|
+
# components are disjoint, so no two entries share a least position and the sets are never compared
|
|
137
|
+
components: list[tuple[int, set[Node]]] = []
|
|
138
|
+
|
|
139
|
+
def push_components(adjacency: Mapping[Node, Iterable[Node]]) -> None:
|
|
140
|
+
for component in strongly_connected_components(adjacency):
|
|
141
|
+
if len(component) >= 2:
|
|
142
|
+
heapq.heappush(components, (min(map(position.__getitem__, component)), component))
|
|
143
|
+
|
|
144
|
+
push_components(successors)
|
|
145
|
+
while components:
|
|
146
|
+
_, component = heapq.heappop(components)
|
|
147
|
+
s = min(component, key=position.__getitem__)
|
|
148
|
+
adjacency = {n: [t for t in successors[n] if t in component] for n in component}
|
|
149
|
+
yield from _cycles_through(adjacency, s)
|
|
150
|
+
del adjacency[s]
|
|
151
|
+
for targets in adjacency.values():
|
|
152
|
+
if s in targets:
|
|
153
|
+
targets.remove(s)
|
|
154
|
+
push_components(adjacency)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def simple_cycles(graph: Mapping[Node, Iterable[Node]], limit: Optional[int] = None) -> Iterator[list[Node]]:
|
|
158
|
+
"""Yield the elementary cycles of `graph`, each as its nodes in edge order, stopping
|
|
159
|
+
after `limit` cycles when one is given. Self-loops come first; longer cycles follow in
|
|
160
|
+
order of their earliest node in `graph`'s iteration order."""
|
|
161
|
+
for found, cycle in enumerate(_all_cycles(graph), start=1):
|
|
162
|
+
if limit is not None and found > limit:
|
|
163
|
+
return
|
|
164
|
+
yield cycle
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import:
|
|
3
|
+
- required_concepts
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
***definitions***
|
|
7
|
+
- :Concept: is the concept that :DependantConcept: builds on.
|
|
8
|
+
|
|
9
|
+
***implementation reqs***
|
|
10
|
+
- The :Concept: concept must be implemented.
|
|
11
|
+
|
|
12
|
+
***functional specs***
|
|
13
|
+
- Implement the :Concept: concept.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
***definitions***
|
|
2
|
+
- :Order: is a purchase.
|
|
3
|
+
- It is placed by a :Customer:.
|
|
4
|
+
- :Customer: is a buyer with :Order: items.
|
|
5
|
+
- :Receipt: records an :Order:.
|
|
6
|
+
|
|
7
|
+
***implementation reqs***
|
|
8
|
+
- The :Receipt: concept must be implemented.
|
|
9
|
+
|
|
10
|
+
***functional specs***
|
|
11
|
+
- Implement the :Receipt: concept.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from plain_parser import concept_utils
|
|
6
|
+
from plain_parser.exceptions import PlainSyntaxError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def definitions(*markdown: str) -> list[dict]:
|
|
10
|
+
return [{"markdown": line} for line in markdown]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def defined_concepts(sorted_definitions: list[dict]) -> list[str]:
|
|
14
|
+
return [concept_utils.extract_concepts_from_definition(d["markdown"])[0][0] for d in sorted_definitions]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_sort_single_definition_is_unchanged():
|
|
18
|
+
defs = definitions("- :A: is x.")
|
|
19
|
+
concept_utils.sort_definitions(defs)
|
|
20
|
+
assert defined_concepts(defs) == [":A:"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_sort_definitions_without_concept_references_keeps_file_order():
|
|
24
|
+
defs = definitions("- :B: is y.", "- :A: is x.")
|
|
25
|
+
concept_utils.sort_definitions(defs)
|
|
26
|
+
assert defined_concepts(defs) == [":B:", ":A:"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_sort_places_dependency_before_dependant():
|
|
30
|
+
defs = definitions("- :B: uses :A:.", "- :A: is x.")
|
|
31
|
+
concept_utils.sort_definitions(defs)
|
|
32
|
+
assert defined_concepts(defs) == [":A:", ":B:"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_sort_resolves_diamond_dependencies():
|
|
36
|
+
defs = definitions("- :D: uses :B: and :C:.", "- :B: uses :A:.", "- :C: uses :A:.", "- :A: is x.")
|
|
37
|
+
concept_utils.sort_definitions(defs)
|
|
38
|
+
order = defined_concepts(defs)
|
|
39
|
+
assert order.index(":A:") < order.index(":B:") < order.index(":D:")
|
|
40
|
+
assert order.index(":A:") < order.index(":C:") < order.index(":D:")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_sort_follows_references_in_sub_bullets():
|
|
44
|
+
defs = definitions("- :B: is y.\n - it uses :A:.", "- :A: is x.")
|
|
45
|
+
concept_utils.sort_definitions(defs)
|
|
46
|
+
assert defined_concepts(defs) == [":A:", ":B:"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_sort_places_multi_concept_definition_before_its_dependants():
|
|
50
|
+
defs = definitions("- :C: uses :A: and :B:.", "- :A:, :B: are twins.")
|
|
51
|
+
concept_utils.sort_definitions(defs)
|
|
52
|
+
assert defined_concepts(defs) == [":A:", ":C:"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_sort_ignores_self_reference():
|
|
56
|
+
defs = definitions("- :B: uses :A:.", "- :A: is like :A:.")
|
|
57
|
+
concept_utils.sort_definitions(defs)
|
|
58
|
+
assert defined_concepts(defs) == [":A:", ":B:"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_sort_ignores_predefined_concepts():
|
|
62
|
+
defs = definitions("- :B: uses :A: and :Implementation:.", "- :A: is x.")
|
|
63
|
+
concept_utils.sort_definitions(defs)
|
|
64
|
+
assert defined_concepts(defs) == [":A:", ":B:"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_sort_rejects_two_node_cycle_and_lists_both_definitions():
|
|
68
|
+
defs = definitions("- :A: uses :B:.", "- :B: uses :A:.")
|
|
69
|
+
with pytest.raises(PlainSyntaxError, match="Cycles are not allowed") as exc_info:
|
|
70
|
+
concept_utils.sort_definitions(defs)
|
|
71
|
+
assert "- :A: uses :B:." in str(exc_info.value)
|
|
72
|
+
assert "- :B: uses :A:." in str(exc_info.value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_sort_rejects_long_cycle_and_lists_every_definition_on_it():
|
|
76
|
+
defs = definitions("- :A: uses :B:.", "- :B: uses :C:.", "- :C: uses :A:.", "- :D: is on its own.")
|
|
77
|
+
with pytest.raises(PlainSyntaxError) as exc_info:
|
|
78
|
+
concept_utils.sort_definitions(defs)
|
|
79
|
+
message = str(exc_info.value)
|
|
80
|
+
for definition in ("- :A: uses :B:.", "- :B: uses :C:.", "- :C: uses :A:."):
|
|
81
|
+
assert definition in message
|
|
82
|
+
assert "- :D: is on its own." not in message
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_sort_rejects_cycle_through_multi_concept_definition():
|
|
86
|
+
defs = definitions("- :A:, :B: use :C:.", "- :C: uses :B:.")
|
|
87
|
+
with pytest.raises(PlainSyntaxError, match="Cycles are not allowed"):
|
|
88
|
+
concept_utils.sort_definitions(defs)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_sort_rejects_graph_with_two_separate_cycles_and_reports_both():
|
|
92
|
+
defs = definitions("- :A: uses :B:.", "- :B: uses :A:.", "- :X: uses :Y:.", "- :Y: uses :X:.")
|
|
93
|
+
with pytest.raises(PlainSyntaxError, match="Found 2 cycle") as exc_info:
|
|
94
|
+
concept_utils.sort_definitions(defs)
|
|
95
|
+
message = str(exc_info.value)
|
|
96
|
+
assert "Cycle 1: :A: -> :B: -> :A:" in message
|
|
97
|
+
assert "Cycle 2: :X: -> :Y: -> :X:" in message
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_sort_reports_every_elementary_cycle_of_a_tangle():
|
|
101
|
+
defs = definitions(
|
|
102
|
+
"- :Order: is placed by a :Customer: and billed by an :Invoice:.",
|
|
103
|
+
"- :Customer: has :Order: items and :Invoice: items.",
|
|
104
|
+
"- :Invoice: bills an :Order: to a :Customer:.",
|
|
105
|
+
)
|
|
106
|
+
with pytest.raises(PlainSyntaxError, match="Found 5 cycle") as exc_info:
|
|
107
|
+
concept_utils.sort_definitions(defs)
|
|
108
|
+
assert str(exc_info.value).count("Cycle ") == 5
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_sort_caps_the_number_of_reported_cycles():
|
|
112
|
+
defs = definitions(
|
|
113
|
+
"- :Order: is placed by a :Customer:, billed by an :Invoice:, settled by a :Payment:.",
|
|
114
|
+
"- :Customer: has :Order:, :Invoice: and :Payment: items.",
|
|
115
|
+
"- :Invoice: bills an :Order: to a :Customer: and is settled by a :Payment:.",
|
|
116
|
+
"- :Payment: settles an :Invoice: for an :Order: by a :Customer:.",
|
|
117
|
+
)
|
|
118
|
+
with pytest.raises(PlainSyntaxError, match="Found more than 10 cycles") as exc_info:
|
|
119
|
+
concept_utils.sort_definitions(defs)
|
|
120
|
+
message = str(exc_info.value)
|
|
121
|
+
assert "Showing the first 10." in message
|
|
122
|
+
assert message.count("Cycle ") == 10
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_sort_reports_cycle_in_reference_order():
|
|
126
|
+
defs = definitions("- :A: uses :B:.", "- :B: uses :C:.", "- :C: uses :A:.")
|
|
127
|
+
with pytest.raises(PlainSyntaxError) as exc_info:
|
|
128
|
+
concept_utils.sort_definitions(defs)
|
|
129
|
+
assert "Cycle 1: :A: -> :B: -> :C: -> :A:\n- :A: uses :B:.\n- :B: uses :C:.\n- :C: uses :A:." in str(exc_info.value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_sort_rejects_cycle_that_also_references_undefined_concept():
|
|
133
|
+
defs = definitions("- :A: uses :B: and :Ghost:.", "- :B: uses :A:.")
|
|
134
|
+
with pytest.raises(PlainSyntaxError, match="Cycles are not allowed"):
|
|
135
|
+
concept_utils.sort_definitions(defs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def separate_two_cycles(count: int) -> list[str]:
|
|
139
|
+
return [line for i in range(count) for line in (f"- :A{i}: uses :B{i}:.", f"- :B{i}: uses :A{i}:.")]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cycle_lines(message: str) -> list[str]:
|
|
143
|
+
return [line for line in message.splitlines() if line.startswith("Cycle ")]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_sort_reports_exactly_the_maximum_without_truncating():
|
|
147
|
+
with pytest.raises(PlainSyntaxError) as exc_info:
|
|
148
|
+
concept_utils.sort_definitions(definitions(*separate_two_cycles(10)))
|
|
149
|
+
message = str(exc_info.value)
|
|
150
|
+
assert "Found 10 cycle(s)" in message
|
|
151
|
+
assert "Showing" not in message
|
|
152
|
+
assert len(cycle_lines(message)) == 10
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_sort_truncated_report_shows_the_earliest_defined_cycles():
|
|
156
|
+
with pytest.raises(PlainSyntaxError) as exc_info:
|
|
157
|
+
concept_utils.sort_definitions(definitions(*separate_two_cycles(12)))
|
|
158
|
+
lines = cycle_lines(str(exc_info.value))
|
|
159
|
+
assert lines[0] == "Cycle 1: :A0: -> :B0: -> :A0:"
|
|
160
|
+
assert lines[-1] == "Cycle 10: :A9: -> :B9: -> :A9:"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_sort_truncation_follows_definition_order_not_first_mention():
|
|
164
|
+
# :A10: and :A11: are mentioned on the first line but defined last, so their cycles are not among the first 10
|
|
165
|
+
defs = separate_two_cycles(12)
|
|
166
|
+
defs[0] = "- :A0: uses :B0:, :A10: and :A11:."
|
|
167
|
+
with pytest.raises(PlainSyntaxError) as exc_info:
|
|
168
|
+
concept_utils.sort_definitions(definitions(*defs))
|
|
169
|
+
lines = cycle_lines(str(exc_info.value))
|
|
170
|
+
assert lines[-1] == "Cycle 10: :A9: -> :B9: -> :A9:"
|
|
171
|
+
assert not [line for line in lines if re.search(r":A1[01]:", line)]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_sort_prints_a_shared_definition_once_per_cycle():
|
|
175
|
+
defs = definitions("- :A:, :B: use :X: and :Y:.", "- :X: uses :B:.", "- :Y: uses :A:.")
|
|
176
|
+
with pytest.raises(PlainSyntaxError) as exc_info:
|
|
177
|
+
concept_utils.sort_definitions(defs)
|
|
178
|
+
blocks = str(exc_info.value).split("\n\n")[1:]
|
|
179
|
+
assert blocks and all(block.count("- :A:, :B: use :X: and :Y:.") == 1 for block in blocks)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import sys
|
|
3
|
+
from itertools import permutations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from plain_parser import graph
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def canonical(cycles) -> set[tuple]:
|
|
11
|
+
"""Rotate each cycle to start at its smallest node so equal cycles compare equal."""
|
|
12
|
+
result = set()
|
|
13
|
+
for cycle in cycles:
|
|
14
|
+
start = cycle.index(min(cycle))
|
|
15
|
+
result.add(tuple(cycle[start:] + cycle[:start]))
|
|
16
|
+
return result
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def brute_force_cycles(adjacency: dict[int, list[int]]) -> set[tuple]:
|
|
20
|
+
"""Try every node subset in every order; keep the ones that close a loop."""
|
|
21
|
+
nodes = sorted(adjacency)
|
|
22
|
+
found = set()
|
|
23
|
+
for length in range(1, len(nodes) + 1):
|
|
24
|
+
for perm in permutations(nodes, length):
|
|
25
|
+
if all(perm[(i + 1) % length] in adjacency[perm[i]] for i in range(length)):
|
|
26
|
+
found.add(perm)
|
|
27
|
+
return canonical(list(c) for c in found)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_dag_has_no_cycles():
|
|
31
|
+
assert list(graph.simple_cycles({"a": ["b"], "b": ["c"], "c": []})) == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_empty_graph():
|
|
35
|
+
assert list(graph.simple_cycles({})) == []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_self_loop():
|
|
39
|
+
assert list(graph.simple_cycles({"a": ["a"]})) == [["a"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_two_node_cycle():
|
|
43
|
+
assert canonical(graph.simple_cycles({"a": ["b"], "b": ["a"]})) == {("a", "b")}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_targets_only_nodes_are_tolerated():
|
|
47
|
+
assert canonical(graph.simple_cycles({"a": ["b", "ghost"], "b": ["a"]})) == {("a", "b")}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_complete_graph_on_three_nodes_has_five_cycles():
|
|
51
|
+
k3 = {n: [m for m in "abc" if m != n] for n in "abc"}
|
|
52
|
+
assert len(list(graph.simple_cycles(k3))) == 5
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_complete_graph_on_four_nodes_has_twenty_cycles():
|
|
56
|
+
k4 = {n: [m for m in "abcd" if m != n] for n in "abcd"}
|
|
57
|
+
assert len(list(graph.simple_cycles(k4))) == 20
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_two_separate_cycles_are_both_found():
|
|
61
|
+
g = {"a": ["b"], "b": ["a"], "x": ["y"], "y": ["x"], "lone": []}
|
|
62
|
+
assert canonical(graph.simple_cycles(g)) == {("a", "b"), ("x", "y")}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_cycles_are_reported_in_edge_order():
|
|
66
|
+
g = {"a": ["b"], "b": ["c"], "c": ["a"]}
|
|
67
|
+
(cycle,) = list(graph.simple_cycles(g))
|
|
68
|
+
for node, following in zip(cycle, cycle[1:] + cycle[:1]):
|
|
69
|
+
assert following in g[node]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_strongly_connected_components():
|
|
73
|
+
g = {"a": ["b"], "b": ["a", "c"], "c": ["d"], "d": ["c"], "e": []}
|
|
74
|
+
components = graph.strongly_connected_components(g)
|
|
75
|
+
assert sorted(sorted(c) for c in components) == [["a", "b"], ["c", "d"], ["e"]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_limit_stops_after_that_many_cycles():
|
|
79
|
+
k4 = {n: [m for m in "abcd" if m != n] for n in "abcd"}
|
|
80
|
+
assert len(list(graph.simple_cycles(k4, limit=10))) == 10
|
|
81
|
+
assert len(list(graph.simple_cycles(k4, limit=0))) == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_acyclic_appendage_is_never_entered():
|
|
85
|
+
# a 2-cycle with a chain of 40 diamonds hanging off it: exponentially many paths, one cycle
|
|
86
|
+
g = {"a": ["b", "d0"], "b": ["a"]}
|
|
87
|
+
for i in range(40):
|
|
88
|
+
g[f"d{i}"] = [f"l{i}", f"r{i}"]
|
|
89
|
+
g[f"l{i}"] = g[f"r{i}"] = [f"d{i + 1}"]
|
|
90
|
+
g["d40"] = []
|
|
91
|
+
assert canonical(graph.simple_cycles(g)) == {("a", "b")}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_repeated_edge_yields_each_cycle_once():
|
|
95
|
+
assert list(graph.simple_cycles({"a": ["b", "b"], "b": ["a", "a"]})) == [["a", "b"]]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_limit_does_not_enumerate_the_rest():
|
|
99
|
+
# the complete graph on 14 nodes has billions of cycles; only a lazy search returns
|
|
100
|
+
k14 = {n: [m for m in range(14) if m != n] for n in range(14)}
|
|
101
|
+
assert len(list(graph.simple_cycles(k14, limit=11))) == 11
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_separate_cycles_come_in_graph_order():
|
|
105
|
+
g = {f"{name}{i}": [f"{name}{1 - i}"] for name in "pqrst" for i in range(2)}
|
|
106
|
+
assert [cycle[0][0] for cycle in graph.simple_cycles(g)] == list("pqrst")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_cycle_longer_than_the_recursion_limit():
|
|
110
|
+
n = sys.getrecursionlimit() * 2
|
|
111
|
+
(cycle,) = list(graph.simple_cycles({i: [(i + 1) % n] for i in range(n)}))
|
|
112
|
+
assert len(cycle) == n
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_components_of_a_chain_longer_than_the_recursion_limit():
|
|
116
|
+
n = sys.getrecursionlimit() * 2
|
|
117
|
+
assert len(graph.strongly_connected_components({i: [i + 1] for i in range(n)})) == n + 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize("seed", range(200))
|
|
121
|
+
def test_matches_brute_force_on_random_graphs(seed):
|
|
122
|
+
rng = random.Random(seed)
|
|
123
|
+
n = rng.randint(1, 6)
|
|
124
|
+
density = rng.choice([0.2, 0.4, 0.7, 1.0])
|
|
125
|
+
adjacency = {u: [v for v in range(n) if rng.random() < density] for u in range(n)}
|
|
126
|
+
cycles = list(graph.simple_cycles(adjacency))
|
|
127
|
+
assert len(cycles) == len(canonical(cycles)), "a cycle was reported twice"
|
|
128
|
+
assert canonical(cycles) == brute_force_cycles(adjacency)
|
|
129
|
+
earliest = [min(cycle) for cycle in cycles if len(cycle) > 1]
|
|
130
|
+
assert earliest == sorted(earliest)
|