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.
Files changed (149) hide show
  1. plain_parser-0.1.2/.github/dependabot.yml +27 -0
  2. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.github/workflows/lint-and-test.yml +10 -4
  3. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.github/workflows/publish-dev-to-pypi.yml +29 -13
  4. plain_parser-0.1.2/.github/workflows/publish-to-pypi.yml +171 -0
  5. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/PKG-INFO +1 -2
  6. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/pyproject.toml +1 -2
  7. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/concept_utils.py +49 -20
  8. plain_parser-0.1.2/src/plain_parser/graph.py +164 -0
  9. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/loaders.py +33 -4
  10. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/plain_file.py +28 -17
  11. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/plain_spec.py +1 -1
  12. plain_parser-0.1.2/tests/data/plainfile/plain_source_with_missing_link.plain +9 -0
  13. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/task_manager_with_reference_links.plain +2 -2
  14. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_noconcepts.plain +0 -1
  15. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_redefinition.plain +0 -1
  16. plain_parser-0.1.2/tests/data/plainfileparser/cyclic_definitions_across_import.plain +13 -0
  17. plain_parser-0.1.2/tests/data/plainfileparser/cyclic_definitions_long.plain +11 -0
  18. plain_parser-0.1.2/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain +11 -0
  19. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_example.plain +1 -1
  20. plain_parser-0.1.2/tests/data/templates/malformed_tag.plain +4 -0
  21. plain_parser-0.1.2/tests/data/templates/unterminated_output.plain +3 -0
  22. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_cli.py +44 -40
  23. plain_parser-0.1.2/tests/test_concept_utils.py +179 -0
  24. plain_parser-0.1.2/tests/test_graph.py +130 -0
  25. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_loaders.py +87 -1
  26. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_plainfileparser.py +95 -8
  27. plain_parser-0.1.1.dev2/.github/workflows/publish-to-pypi.yml +0 -117
  28. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.flake8 +0 -0
  29. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/.gitignore +0 -0
  30. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/LICENSE +0 -0
  31. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/README.md +0 -0
  32. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/__init__.py +0 -0
  33. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/cli.py +0 -0
  34. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/exceptions.py +0 -0
  35. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/src/plain_parser/liquid_nodes.py +0 -0
  36. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/__init__.py +0 -0
  37. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/conftest.py +0 -0
  38. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/acceptance_test_binary_resource.plain +0 -0
  39. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/acceptance_test_resource.plain +0 -0
  40. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/base64_resource.plain +0 -0
  41. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/binary.bin +0 -0
  42. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/binary_resource.plain +0 -0
  43. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/code_variable_conflict.plain +0 -0
  44. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/code_variable_template.plain +0 -0
  45. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/missing_resource.plain +0 -0
  46. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/not_plain.md +0 -0
  47. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/notes.md +0 -0
  48. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/requires_binary_resource_base.plain +0 -0
  49. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/requires_binary_resource_top.plain +0 -0
  50. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/subdir/nested.plain +0 -0
  51. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/undefined_concept.plain +0 -0
  52. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/cli/valid.plain +0 -0
  53. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/circular_imports_1.plain +0 -0
  54. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/circular_imports_2.plain +0 -0
  55. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/circular_imports_main.plain +0 -0
  56. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_import_1.plain +0 -0
  57. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_import_2.plain +0 -0
  58. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_import_common.plain +0 -0
  59. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/diamond_imports_main.plain +0 -0
  60. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/import_with_functionalities.plain +0 -0
  61. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/imports_module_with_functionalities.plain +0 -0
  62. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/imports/non_existent_import.plain +0 -0
  63. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/add_new_task_modal_specification.yaml +0 -0
  64. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/duplicate_specification_heading.plain +0 -0
  65. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/empty_functional_specs_section.plain +0 -0
  66. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/invalid_specification_order.plain +0 -0
  67. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/missing_non_functional_requirements.plain +0 -0
  68. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/no_functional_specs_section.plain +0 -0
  69. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/plain_source_with_absolute_link.plain +0 -0
  70. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/plain_source_with_url_link.plain +0 -0
  71. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/task_list_ui_specification.yaml +0 -0
  72. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfile/without_non_functional_requirement.plain +0 -0
  73. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +0 -0
  74. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +0 -0
  75. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +0 -0
  76. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +0 -0
  77. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_definition.plain +0 -0
  78. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_nonconcept.plain +0 -0
  79. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_nondefined.plain +0 -0
  80. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_several_concepts.plain +0 -0
  81. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/concept_validation_valid.plain +0 -0
  82. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/cyclic_definitions.plain +0 -0
  83. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts.plain +0 -0
  84. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_base.plain +0 -0
  85. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +0 -0
  86. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_example.plain +0 -0
  87. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_imported_exported.plain +0 -0
  88. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +0 -0
  89. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_malformed.plain +0 -0
  90. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_missing_definition.plain +0 -0
  91. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +0 -0
  92. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +0 -0
  93. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +0 -0
  94. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_nested.plain +0 -0
  95. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_nested_example.plain +0 -0
  96. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_no_definitions.plain +0 -0
  97. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_not_declared.plain +0 -0
  98. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +0 -0
  99. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_transitive_example.plain +0 -0
  100. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +0 -0
  101. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +0 -0
  102. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/plain_file_parser_with_comments.plain +0 -0
  103. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/plain_file_with_comments_indented.plain +0 -0
  104. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/regular_plain_source.plain +0 -0
  105. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts.plain +0 -0
  106. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_defs.plain +0 -0
  107. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_l1.plain +0 -0
  108. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_l2.plain +0 -0
  109. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_missing.plain +0 -0
  110. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_module.plain +0 -0
  111. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_partial.plain +0 -0
  112. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +0 -0
  113. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/requires_without_definitions_example.plain +0 -0
  114. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/topological_sort.plain +0 -0
  115. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/plainfileparser/topological_sort_not_referenced.plain +0 -0
  116. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_base.plain +0 -0
  117. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_fork_top.plain +0 -0
  118. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_middle.plain +0 -0
  119. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/chain_top.plain +0 -0
  120. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/circular_requires_main.plain +0 -0
  121. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/circular_requires_sub.plain +0 -0
  122. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_1.plain +0 -0
  123. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_2.plain +0 -0
  124. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_common.plain +0 -0
  125. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/diamond_requires_main.plain +0 -0
  126. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/independent_requires_1.plain +0 -0
  127. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/independent_requires_2.plain +0 -0
  128. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/independent_requires_main.plain +0 -0
  129. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/invalid_ancestor_base.plain +0 -0
  130. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/invalid_ancestor_top.plain +0 -0
  131. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/non_existent_require.plain +0 -0
  132. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_1.plain +0 -0
  133. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_2.plain +0 -0
  134. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_common.plain +0 -0
  135. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/requires/normal_requires_main.plain +0 -0
  136. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/sample_base64_image.txt +0 -0
  137. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/simple.plain +0 -0
  138. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/block_level_include.plain +0 -0
  139. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/code_variables.plain +0 -0
  140. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/header.plain +0 -0
  141. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/implement.plain +0 -0
  142. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/implement_2.plain +0 -0
  143. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/template_include.plain +0 -0
  144. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/data/templates/test_hardest_problem.plain +0 -0
  145. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_exceptions.py +0 -0
  146. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_imports.py +0 -0
  147. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_plainfile.py +0 -0
  148. {plain_parser-0.1.1.dev2 → plain_parser-0.1.2}/tests/test_plainspec.py +0 -0
  149. {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
- push:
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
- check:
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
- run: |
45
- curl -LsSf https://astral.sh/uv/install.sh | sh
46
- echo "$HOME/.local/bin" >> $GITHUB_PATH
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: ${{ success() && env.SLACK_WEBHOOK_URL != '' }}
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 -s -X POST "$SLACK_WEBHOOK_URL" \
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: ${{ failure() && env.SLACK_WEBHOOK_URL != '' }}
154
+ if: failure()
155
+ continue-on-error: true
156
+ env:
157
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
144
158
  run: |
145
- curl -s -X POST "$SLACK_WEBHOOK_URL" \
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 "{\"text\":\"*plain-parser*: Publishing dev build to PyPI FAILED :x: (commit \`${{ github.sha }}\`). See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}"
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.1.dev2
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
- concept_graph = nx.DiGraph()
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
- concept_graph.add_edge(node, neighbour)
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
- raise PlainSyntaxError(f"Plain syntax error: {msg}")
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