plain-parser 0.1.1.dev2__tar.gz → 0.1.2__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/.github/dependabot.yml +27 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.github/workflows/lint-and-test.yml +10 -4
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.github/workflows/publish-dev-to-pypi.yml +29 -13
- plain_parser-0.1.2/.github/workflows/publish-to-pypi.yml +171 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/PKG-INFO +1 -2
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/pyproject.toml +1 -2
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/concept_utils.py +49 -20
- plain_parser-0.1.2/src/plain_parser/graph.py +164 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/loaders.py +33 -4
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/plain_file.py +28 -17
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/plain_spec.py +1 -1
- plain_parser-0.1.2/tests/data/plainfile/plain_source_with_missing_link.plain +9 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/task_manager_with_reference_links.plain +2 -2
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_noconcepts.plain +0 -1
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_redefinition.plain +0 -1
- plain_parser-0.1.2/tests/data/plainfileparser/cyclic_definitions_across_import.plain +13 -0
- plain_parser-0.1.2/tests/data/plainfileparser/cyclic_definitions_long.plain +11 -0
- plain_parser-0.1.2/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain +11 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_example.plain +1 -1
- plain_parser-0.1.2/tests/data/templates/malformed_tag.plain +4 -0
- plain_parser-0.1.2/tests/data/templates/unterminated_output.plain +3 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_cli.py +44 -40
- plain_parser-0.1.2/tests/test_concept_utils.py +179 -0
- plain_parser-0.1.2/tests/test_graph.py +130 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_loaders.py +87 -1
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_plainfileparser.py +95 -8
- plain_parser-0.1.1.dev2/.github/workflows/publish-to-pypi.yml +0 -117
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.flake8 +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.gitignore +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/LICENSE +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/README.md +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/__init__.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/cli.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/exceptions.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/liquid_nodes.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/__init__.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/conftest.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/acceptance_test_binary_resource.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/acceptance_test_resource.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/base64_resource.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/binary.bin +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/binary_resource.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/code_variable_conflict.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/code_variable_template.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/missing_resource.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/not_plain.md +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/notes.md +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/requires_binary_resource_base.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/requires_binary_resource_top.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/subdir/nested.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/undefined_concept.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/valid.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/circular_imports_1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/circular_imports_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/circular_imports_main.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_import_1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_import_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_import_common.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_imports_main.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/import_with_functionalities.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/imports_module_with_functionalities.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/non_existent_import.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/add_new_task_modal_specification.yaml +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/duplicate_specification_heading.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/empty_functional_specs_section.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/invalid_specification_order.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/missing_non_functional_requirements.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/no_functional_specs_section.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/plain_source_with_absolute_link.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/plain_source_with_url_link.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/task_list_ui_specification.yaml +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/without_non_functional_requirement.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_definition.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_nonconcept.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_nondefined.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_several_concepts.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_valid.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/cyclic_definitions.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_base.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_example.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_imported_exported.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_malformed.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_missing_definition.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_nested.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_nested_example.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_no_definitions.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_not_declared.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_transitive_example.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/plain_file_parser_with_comments.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/plain_file_with_comments_indented.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/regular_plain_source.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_defs.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_l1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_l2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_missing.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_module.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_partial.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/requires_without_definitions_example.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/topological_sort.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/topological_sort_not_referenced.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_base.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_fork_top.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_middle.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_top.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/circular_requires_main.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/circular_requires_sub.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_common.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_main.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/independent_requires_1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/independent_requires_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/independent_requires_main.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/invalid_ancestor_base.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/invalid_ancestor_top.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/non_existent_require.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_1.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_common.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_main.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/sample_base64_image.txt +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/simple.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/block_level_include.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/code_variables.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/header.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/implement.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/implement_2.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/template_include.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/test_hardest_problem.plain +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_exceptions.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_imports.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_plainfile.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_plainspec.py +0 -0
- {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_requires.py +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "pip"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "weekly"
|
|
7
|
+
open-pull-requests-limit: 3
|
|
8
|
+
commit-message:
|
|
9
|
+
prefix: "chore"
|
|
10
|
+
labels:
|
|
11
|
+
- "dependencies"
|
|
12
|
+
|
|
13
|
+
- package-ecosystem: "github-actions"
|
|
14
|
+
directory: "/"
|
|
15
|
+
schedule:
|
|
16
|
+
interval: "weekly"
|
|
17
|
+
open-pull-requests-limit: 5
|
|
18
|
+
commit-message:
|
|
19
|
+
prefix: "ci"
|
|
20
|
+
labels:
|
|
21
|
+
- "dependencies"
|
|
22
|
+
- "github-actions"
|
|
23
|
+
groups:
|
|
24
|
+
actions-minor-and-patch:
|
|
25
|
+
update-types:
|
|
26
|
+
- "minor"
|
|
27
|
+
- "patch"
|
|
@@ -2,18 +2,24 @@ name: Code Quality & Tests
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
# Pushes to PR branches are covered by pull_request; listening to every push
|
|
5
|
-
# too would run the suite twice per commit.
|
|
6
|
-
|
|
7
|
-
branches: [main]
|
|
5
|
+
# too would run the suite twice per commit. Pushes to main are covered by
|
|
6
|
+
# the publish-dev-to-pypi workflow, which calls this one as a gate.
|
|
8
7
|
pull_request:
|
|
9
8
|
workflow_dispatch:
|
|
9
|
+
# Lets the publish workflows run these checks as a job and depend on it.
|
|
10
|
+
workflow_call:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
10
14
|
|
|
11
15
|
jobs:
|
|
12
|
-
|
|
16
|
+
lint-and-test:
|
|
13
17
|
name: Lint and test
|
|
14
18
|
runs-on: ubuntu-latest
|
|
15
19
|
steps:
|
|
16
20
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
21
|
+
with:
|
|
22
|
+
persist-credentials: false
|
|
17
23
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
18
24
|
with:
|
|
19
25
|
python-version: "3.11"
|
|
@@ -23,8 +23,18 @@ concurrency:
|
|
|
23
23
|
cancel-in-progress: false
|
|
24
24
|
|
|
25
25
|
jobs:
|
|
26
|
+
pre-publish-checks:
|
|
27
|
+
name: Pre-publish checks
|
|
28
|
+
# Publishing is gated on the same checks PRs run: a red main must not
|
|
29
|
+
# ship. Running here rather than as a separate workflow is the only way
|
|
30
|
+
# to make the build job depend on the result.
|
|
31
|
+
uses: ./.github/workflows/lint-and-test.yml
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
|
|
26
35
|
build:
|
|
27
36
|
name: Build dev distribution
|
|
37
|
+
needs: pre-publish-checks
|
|
28
38
|
runs-on: ubuntu-latest
|
|
29
39
|
permissions:
|
|
30
40
|
contents: read
|
|
@@ -37,13 +47,15 @@ jobs:
|
|
|
37
47
|
with:
|
|
38
48
|
# hatch-vcs derives the version from git tags, so we need full history.
|
|
39
49
|
fetch-depth: 0
|
|
50
|
+
persist-credentials: false
|
|
40
51
|
|
|
41
52
|
- name: Install uv
|
|
42
53
|
# uv provisions its own Python (honoring requires-python), so no
|
|
43
54
|
# separate actions/setup-python step is needed.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
56
|
+
with:
|
|
57
|
+
version: "0.12.23"
|
|
58
|
+
enable-cache: false
|
|
47
59
|
|
|
48
60
|
- name: Compute dev version
|
|
49
61
|
id: version
|
|
@@ -55,7 +67,7 @@ jobs:
|
|
|
55
67
|
# run number as the dev counter: it is strictly increasing, so each
|
|
56
68
|
# merge sorts after the last and re-runs never collide.
|
|
57
69
|
run: |
|
|
58
|
-
RAW=$(uvx --from setuptools-scm python -c \
|
|
70
|
+
RAW=$(uvx --from setuptools-scm==10.3.4 python -c \
|
|
59
71
|
"from setuptools_scm import get_version; print(get_version())")
|
|
60
72
|
echo "setuptools-scm reports: $RAW"
|
|
61
73
|
|
|
@@ -100,7 +112,7 @@ jobs:
|
|
|
100
112
|
publish-dev:
|
|
101
113
|
name: Publish dev build to PyPI
|
|
102
114
|
needs: build
|
|
103
|
-
if: needs.build.outputs.should-publish == 'true'
|
|
115
|
+
if: github.ref == 'refs/heads/main' && needs.build.outputs.should-publish == 'true'
|
|
104
116
|
runs-on: ubuntu-latest
|
|
105
117
|
environment:
|
|
106
118
|
name: pypi
|
|
@@ -110,9 +122,6 @@ jobs:
|
|
|
110
122
|
# authenticate this run — no long-lived API token needed.
|
|
111
123
|
id-token: write
|
|
112
124
|
|
|
113
|
-
env:
|
|
114
|
-
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
115
|
-
|
|
116
125
|
steps:
|
|
117
126
|
- name: Download distribution packages
|
|
118
127
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
@@ -128,20 +137,27 @@ jobs:
|
|
|
128
137
|
skip-existing: true
|
|
129
138
|
|
|
130
139
|
- name: Notify Slack (success)
|
|
131
|
-
if:
|
|
140
|
+
if: success()
|
|
141
|
+
continue-on-error: true
|
|
132
142
|
env:
|
|
143
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
133
144
|
VERSION: ${{ needs.build.outputs.version }}
|
|
134
145
|
run: |
|
|
135
146
|
# Spell out the --pre install: a plain "pip install plain-parser" will
|
|
136
147
|
# NOT pick this up, which otherwise reads as a broken publish.
|
|
137
148
|
TEXT="*plain-parser*: dev build \`$VERSION\` published to PyPI :test_tube:. Normal installs are unaffected — try it with \`pip install --pre plain-parser==$VERSION\`."
|
|
138
|
-
curl -
|
|
149
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
139
150
|
-H 'Content-type: application/json' \
|
|
140
151
|
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
141
152
|
|
|
142
153
|
- name: Notify Slack (failure)
|
|
143
|
-
if:
|
|
154
|
+
if: failure()
|
|
155
|
+
continue-on-error: true
|
|
156
|
+
env:
|
|
157
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
144
158
|
run: |
|
|
145
|
-
|
|
159
|
+
RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
|
160
|
+
TEXT="*plain-parser*: Publishing dev build to PyPI FAILED :x: (commit \`$GITHUB_SHA\`). See <$RUN_URL|the run log>."
|
|
161
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
146
162
|
-H 'Content-type: application/json' \
|
|
147
|
-
--data "
|
|
163
|
+
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
# Least privilege by default: jobs get no GITHUB_TOKEN scopes unless they
|
|
8
|
+
# opt in below.
|
|
9
|
+
permissions: {}
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
pre-publish-checks:
|
|
13
|
+
name: Pre-publish checks
|
|
14
|
+
# Publishing is gated on the same checks PRs run: a red main must not
|
|
15
|
+
# ship. Running here rather than as a separate workflow is the only way
|
|
16
|
+
# to make the build job depend on the result.
|
|
17
|
+
uses: ./.github/workflows/lint-and-test.yml
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
|
|
21
|
+
build:
|
|
22
|
+
name: Build distribution
|
|
23
|
+
needs: pre-publish-checks
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
30
|
+
with:
|
|
31
|
+
# hatch-vcs derives the version from git tags, so we need full history.
|
|
32
|
+
fetch-depth: 0
|
|
33
|
+
persist-credentials: false
|
|
34
|
+
|
|
35
|
+
- name: Notify Slack (start)
|
|
36
|
+
continue-on-error: true
|
|
37
|
+
env:
|
|
38
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
39
|
+
TAG: ${{ github.ref_name }}
|
|
40
|
+
ACTOR: ${{ github.actor }}
|
|
41
|
+
run: |
|
|
42
|
+
TEXT="*plain-parser*: Release \`$TAG\` published by *$ACTOR* — building and publishing to PyPI..."
|
|
43
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
44
|
+
-H 'Content-type: application/json' \
|
|
45
|
+
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
46
|
+
|
|
47
|
+
- name: Install uv
|
|
48
|
+
# uv provisions its own Python (honoring requires-python), so no
|
|
49
|
+
# separate actions/setup-python step is needed.
|
|
50
|
+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
51
|
+
with:
|
|
52
|
+
version: "0.12.23"
|
|
53
|
+
enable-cache: false
|
|
54
|
+
|
|
55
|
+
- name: Build package
|
|
56
|
+
# hatch-vcs reads the version straight from the release tag.
|
|
57
|
+
run: |
|
|
58
|
+
uv build
|
|
59
|
+
echo "Built distributions:"
|
|
60
|
+
ls -l dist/
|
|
61
|
+
|
|
62
|
+
- name: Store the distribution packages
|
|
63
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
64
|
+
with:
|
|
65
|
+
name: python-package-distributions
|
|
66
|
+
path: dist/
|
|
67
|
+
|
|
68
|
+
- name: Notify Slack (build failure)
|
|
69
|
+
if: failure()
|
|
70
|
+
continue-on-error: true
|
|
71
|
+
env:
|
|
72
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
73
|
+
TAG: ${{ github.ref_name }}
|
|
74
|
+
run: |
|
|
75
|
+
RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
|
76
|
+
TEXT="*plain-parser*: Build of \`$TAG\` FAILED :x:. Nothing was published to PyPI. See <$RUN_URL|the run log>."
|
|
77
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
78
|
+
-H 'Content-type: application/json' \
|
|
79
|
+
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
80
|
+
|
|
81
|
+
upload-release-assets:
|
|
82
|
+
name: Upload assets to GitHub Release
|
|
83
|
+
needs: build
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
permissions:
|
|
86
|
+
contents: write
|
|
87
|
+
|
|
88
|
+
steps:
|
|
89
|
+
- name: Download distribution packages
|
|
90
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
91
|
+
with:
|
|
92
|
+
name: python-package-distributions
|
|
93
|
+
path: dist/
|
|
94
|
+
|
|
95
|
+
- name: Upload assets to GitHub Release
|
|
96
|
+
env:
|
|
97
|
+
GH_TOKEN: ${{ github.token }}
|
|
98
|
+
GH_REPO: ${{ github.repository }}
|
|
99
|
+
TAG: ${{ github.ref_name }}
|
|
100
|
+
run: |
|
|
101
|
+
gh release upload "$TAG" dist/* --clobber
|
|
102
|
+
|
|
103
|
+
- name: Notify Slack (upload failure)
|
|
104
|
+
if: failure()
|
|
105
|
+
continue-on-error: true
|
|
106
|
+
env:
|
|
107
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
108
|
+
TAG: ${{ github.ref_name }}
|
|
109
|
+
run: |
|
|
110
|
+
RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
|
111
|
+
TEXT="*plain-parser*: Uploading \`$TAG\` to the GitHub Release FAILED :x:. Nothing was published to PyPI. See <$RUN_URL|the run log>."
|
|
112
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
113
|
+
-H 'Content-type: application/json' \
|
|
114
|
+
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
115
|
+
|
|
116
|
+
publish-to-pypi:
|
|
117
|
+
name: Publish to PyPI
|
|
118
|
+
needs: [build, upload-release-assets]
|
|
119
|
+
runs-on: ubuntu-latest
|
|
120
|
+
environment:
|
|
121
|
+
name: pypi
|
|
122
|
+
url: https://pypi.org/p/plain-parser
|
|
123
|
+
permissions:
|
|
124
|
+
# Mint a short-lived OIDC token so PyPI Trusted Publishing can
|
|
125
|
+
# authenticate this run — no long-lived API token needed.
|
|
126
|
+
id-token: write
|
|
127
|
+
|
|
128
|
+
steps:
|
|
129
|
+
- name: Download distribution packages
|
|
130
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
131
|
+
with:
|
|
132
|
+
name: python-package-distributions
|
|
133
|
+
path: dist/
|
|
134
|
+
|
|
135
|
+
- name: Publish to PyPI
|
|
136
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
137
|
+
|
|
138
|
+
- name: Notify Slack (success)
|
|
139
|
+
if: success()
|
|
140
|
+
continue-on-error: true
|
|
141
|
+
env:
|
|
142
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
143
|
+
# GitHub marks the release itself as a pre-release; the tag
|
|
144
|
+
# (v0.2.0rc1) carries the same intent for PEP 440.
|
|
145
|
+
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
|
146
|
+
TAG: ${{ github.ref_name }}
|
|
147
|
+
run: |
|
|
148
|
+
VERSION="${TAG#v}"
|
|
149
|
+
if [ "$IS_PRERELEASE" = "true" ]; then
|
|
150
|
+
# pip and uv skip pre-releases by default, so "--upgrade" would
|
|
151
|
+
# NOT pick this up. Tell people the command that actually works.
|
|
152
|
+
TEXT="*plain-parser*: pre-release \`$TAG\` published to PyPI :test_tube:. Normal installs are unaffected — try it with \`pip install --pre plain-parser==$VERSION\`."
|
|
153
|
+
else
|
|
154
|
+
TEXT="*plain-parser*: \`$TAG\` published to PyPI :white_check_mark:. Install with \`pip install --upgrade plain-parser\`."
|
|
155
|
+
fi
|
|
156
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
157
|
+
-H 'Content-type: application/json' \
|
|
158
|
+
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
159
|
+
|
|
160
|
+
- name: Notify Slack (failure)
|
|
161
|
+
if: failure()
|
|
162
|
+
continue-on-error: true
|
|
163
|
+
env:
|
|
164
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
165
|
+
TAG: ${{ github.ref_name }}
|
|
166
|
+
run: |
|
|
167
|
+
RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
|
168
|
+
TEXT="*plain-parser*: Publishing \`$TAG\` to PyPI FAILED :x:. See <$RUN_URL|the run log>."
|
|
169
|
+
curl -sS --fail -X POST "$SLACK_WEBHOOK_URL" \
|
|
170
|
+
-H 'Content-type: application/json' \
|
|
171
|
+
--data "$(jq -n --arg t "$TEXT" '{text:$t}')"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plain-parser
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling", "hatch-vcs"]
|
|
2
|
+
requires = ["hatchling>=1.32,<2", "hatch-vcs>=0.5,<0.6"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -20,7 +20,6 @@ dependencies = [
|
|
|
20
20
|
"python-liquid2>=0.4.0,<0.5",
|
|
21
21
|
"mistletoe>=1.6.0,<2",
|
|
22
22
|
"python-frontmatter>=1.3.0,<2",
|
|
23
|
-
"networkx>=3.6.1,<4",
|
|
24
23
|
"pyyaml>=6,<7",
|
|
25
24
|
]
|
|
26
25
|
|
|
@@ -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 definitons:\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
|