refdef 0.1.0__tar.gz → 0.2.0__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 (168) hide show
  1. {refdef-0.1.0 → refdef-0.2.0}/.gitignore +1 -0
  2. refdef-0.2.0/.python-version +1 -0
  3. {refdef-0.1.0 → refdef-0.2.0}/DESIGN.md +8 -4
  4. {refdef-0.1.0 → refdef-0.2.0}/PKG-INFO +1 -1
  5. {refdef-0.1.0 → refdef-0.2.0}/RULES.md +2 -2
  6. {refdef-0.1.0 → refdef-0.2.0}/docs/CHANGELOG.md +5 -0
  7. {refdef-0.1.0 → refdef-0.2.0}/docs/api-snapshot.txt +5 -0
  8. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/class-table.md +8 -0
  9. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/number.md +11 -0
  10. {refdef-0.1.0 → refdef-0.2.0}/docs/llms.txt +4 -3
  11. {refdef-0.1.0 → refdef-0.2.0}/pyproject.toml +1 -1
  12. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/__init__.py +2 -1
  13. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/numbering.py +19 -3
  14. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/scheme.py +25 -3
  15. {refdef-0.1.0 → refdef-0.2.0}/tests/test_check.py +62 -0
  16. {refdef-0.1.0 → refdef-0.2.0}/tests/test_llms.py +7 -1
  17. {refdef-0.1.0 → refdef-0.2.0}/tests/test_numbering.py +45 -1
  18. {refdef-0.1.0 → refdef-0.2.0}/uv.lock +1 -1
  19. refdef-0.1.0/.python-version +0 -1
  20. {refdef-0.1.0 → refdef-0.2.0}/.gitattributes +0 -0
  21. {refdef-0.1.0 → refdef-0.2.0}/.github/workflows/ci.yml +0 -0
  22. {refdef-0.1.0 → refdef-0.2.0}/.github/workflows/docs.yml +0 -0
  23. {refdef-0.1.0 → refdef-0.2.0}/.github/workflows/publish.yml +0 -0
  24. {refdef-0.1.0 → refdef-0.2.0}/AGENTS.md +0 -0
  25. {refdef-0.1.0 → refdef-0.2.0}/CLAUDE.md +0 -0
  26. {refdef-0.1.0 → refdef-0.2.0}/LICENSE +0 -0
  27. {refdef-0.1.0 → refdef-0.2.0}/README.md +0 -0
  28. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/build.md +0 -0
  29. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/check.md +0 -0
  30. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/index.md +0 -0
  31. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/plc.md +0 -0
  32. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/print.md +0 -0
  33. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/read.md +0 -0
  34. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/sort.md +0 -0
  35. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/terminals.md +0 -0
  36. {refdef-0.1.0 → refdef-0.2.0}/docs/guide/tree.md +0 -0
  37. {refdef-0.1.0 → refdef-0.2.0}/docs/index.md +0 -0
  38. {refdef-0.1.0 → refdef-0.2.0}/justfile +0 -0
  39. {refdef-0.1.0 → refdef-0.2.0}/mkdocs.yml +0 -0
  40. {refdef-0.1.0 → refdef-0.2.0}/scripts/build_site.py +0 -0
  41. {refdef-0.1.0 → refdef-0.2.0}/scripts/design_guard.py +0 -0
  42. {refdef-0.1.0 → refdef-0.2.0}/scripts/family_compare.py +0 -0
  43. {refdef-0.1.0 → refdef-0.2.0}/scripts/family_files.txt +0 -0
  44. {refdef-0.1.0 → refdef-0.2.0}/scripts/gen_render_corpus.py +0 -0
  45. {refdef-0.1.0 → refdef-0.2.0}/scripts/gen_table.py +0 -0
  46. {refdef-0.1.0 → refdef-0.2.0}/scripts/release_guard.py +0 -0
  47. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/__main__.py +0 -0
  48. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/_api.py +0 -0
  49. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/_check.py +0 -0
  50. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/_heads.py +0 -0
  51. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/_parse.py +0 -0
  52. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/_render.py +0 -0
  53. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/designation.py +0 -0
  54. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/issues.py +0 -0
  55. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/level.py +0 -0
  56. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/order.py +0 -0
  57. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/plc.py +0 -0
  58. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/py.typed +0 -0
  59. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/tables/__init__.py +0 -0
  60. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/tables/standard.py +0 -0
  61. {refdef-0.1.0 → refdef-0.2.0}/src/refdef/terminal.py +0 -0
  62. {refdef-0.1.0 → refdef-0.2.0}/tests/conftest.py +0 -0
  63. {refdef-0.1.0 → refdef-0.2.0}/tests/corpus/addresses.txt +0 -0
  64. {refdef-0.1.0 → refdef-0.2.0}/tests/corpus/render.txt +0 -0
  65. {refdef-0.1.0 → refdef-0.2.0}/tests/corpus/strings.txt +0 -0
  66. {refdef-0.1.0 → refdef-0.2.0}/tests/family.py +0 -0
  67. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/check_aspect_off.py +0 -0
  68. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/check_duplicate.py +0 -0
  69. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/check_letter_count.py +0 -0
  70. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/check_never_letter.py +0 -0
  71. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/check_unknown_class.py +0 -0
  72. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_bad_label.py +0 -0
  73. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_dot_level.py +0 -0
  74. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_empty.py +0 -0
  75. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_empty_level.py +0 -0
  76. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_empty_name.py +0 -0
  77. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_empty_pin.py +0 -0
  78. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_empty_top.py +0 -0
  79. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_free_label.py +0 -0
  80. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_leading_zero.py +0 -0
  81. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_lower_case.py +0 -0
  82. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_no_sign.py +0 -0
  83. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_number_too_large.py +0 -0
  84. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_unsupported_suffix.py +0 -0
  85. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_whitespace.py +0 -0
  86. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/issues/parse_zero_number.py +0 -0
  87. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_levels_list.py +0 -0
  88. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_levels_not_level.py +0 -0
  89. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_top_ampersand.py +0 -0
  90. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_top_colon.py +0 -0
  91. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_top_gt.py +0 -0
  92. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_top_not_str.py +0 -0
  93. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_top_outer_space.py +0 -0
  94. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/designation_top_semicolon.py +0 -0
  95. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_aspect_not_aspect.py +0 -0
  96. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_code_with_digit.py +0 -0
  97. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_coded_and_label.py +0 -0
  98. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_empty.py +0 -0
  99. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_angle.py +0 -0
  100. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_coded_shape_k1.py +0 -0
  101. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_coded_shape_lower.py +0 -0
  102. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_colon.py +0 -0
  103. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_digits_only.py +0 -0
  104. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_dot.py +0 -0
  105. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_outer_space.py +0 -0
  106. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_label_sign_char.py +0 -0
  107. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_letters_without_number.py +0 -0
  108. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_lower_case_code.py +0 -0
  109. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_non_ascii_code.py +0 -0
  110. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_number_and_label.py +0 -0
  111. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_number_bool.py +0 -0
  112. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_number_float.py +0 -0
  113. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_number_too_large.py +0 -0
  114. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/level_number_zero.py +0 -0
  115. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/signal_empty_designation.py +0 -0
  116. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/signal_empty_name.py +0 -0
  117. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/signal_name_not_str.py +0 -0
  118. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/signal_name_outer_space.py +0 -0
  119. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/signal_of_not_designation.py +0 -0
  120. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_empty_pin.py +0 -0
  121. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_no_pins.py +0 -0
  122. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_of_not_designation.py +0 -0
  123. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_pin_colon.py +0 -0
  124. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_pin_not_str.py +0 -0
  125. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_pin_outer_space.py +0 -0
  126. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/malformed/terminal_pins_list.py +0 -0
  127. {refdef-0.1.0 → refdef-0.2.0}/tests/fixtures/tables/classes.csv +0 -0
  128. {refdef-0.1.0 → refdef-0.2.0}/tests/fuzz.py +0 -0
  129. {refdef-0.1.0 → refdef-0.2.0}/tests/guide/test_guide.py +0 -0
  130. {refdef-0.1.0 → refdef-0.2.0}/tests/issue_hook.py +0 -0
  131. {refdef-0.1.0 → refdef-0.2.0}/tests/test_agents.py +0 -0
  132. {refdef-0.1.0 → refdef-0.2.0}/tests/test_api.py +0 -0
  133. {refdef-0.1.0 → refdef-0.2.0}/tests/test_boundary.py +0 -0
  134. {refdef-0.1.0 → refdef-0.2.0}/tests/test_boundary_r.py +0 -0
  135. {refdef-0.1.0 → refdef-0.2.0}/tests/test_deps.py +0 -0
  136. {refdef-0.1.0 → refdef-0.2.0}/tests/test_design_guard.py +0 -0
  137. {refdef-0.1.0 → refdef-0.2.0}/tests/test_designation.py +0 -0
  138. {refdef-0.1.0 → refdef-0.2.0}/tests/test_docs.py +0 -0
  139. {refdef-0.1.0 → refdef-0.2.0}/tests/test_family_compare.py +0 -0
  140. {refdef-0.1.0 → refdef-0.2.0}/tests/test_family_rules.py +0 -0
  141. {refdef-0.1.0 → refdef-0.2.0}/tests/test_gen_table.py +0 -0
  142. {refdef-0.1.0 → refdef-0.2.0}/tests/test_lean.py +0 -0
  143. {refdef-0.1.0 → refdef-0.2.0}/tests/test_level.py +0 -0
  144. {refdef-0.1.0 → refdef-0.2.0}/tests/test_licence.py +0 -0
  145. {refdef-0.1.0 → refdef-0.2.0}/tests/test_malformed.py +0 -0
  146. {refdef-0.1.0 → refdef-0.2.0}/tests/test_order.py +0 -0
  147. {refdef-0.1.0 → refdef-0.2.0}/tests/test_parse.py +0 -0
  148. {refdef-0.1.0 → refdef-0.2.0}/tests/test_plc.py +0 -0
  149. {refdef-0.1.0 → refdef-0.2.0}/tests/test_pure.py +0 -0
  150. {refdef-0.1.0 → refdef-0.2.0}/tests/test_pyproject_pins.py +0 -0
  151. {refdef-0.1.0 → refdef-0.2.0}/tests/test_release_guard.py +0 -0
  152. {refdef-0.1.0 → refdef-0.2.0}/tests/test_render.py +0 -0
  153. {refdef-0.1.0 → refdef-0.2.0}/tests/test_roundtrip.py +0 -0
  154. {refdef-0.1.0 → refdef-0.2.0}/tests/test_site.py +0 -0
  155. {refdef-0.1.0 → refdef-0.2.0}/tests/test_sketch_cases.py +0 -0
  156. {refdef-0.1.0 → refdef-0.2.0}/tests/test_sketch_rd-3.py +0 -0
  157. {refdef-0.1.0 → refdef-0.2.0}/tests/test_sketch_rd-4.py +0 -0
  158. {refdef-0.1.0 → refdef-0.2.0}/tests/test_sketch_rd-5.py +0 -0
  159. {refdef-0.1.0 → refdef-0.2.0}/tests/test_sketch_rd-6b.py +0 -0
  160. {refdef-0.1.0 → refdef-0.2.0}/tests/test_sketch_rd2.py +0 -0
  161. {refdef-0.1.0 → refdef-0.2.0}/tests/test_standalone.py +0 -0
  162. {refdef-0.1.0 → refdef-0.2.0}/tests/test_surface.py +0 -0
  163. {refdef-0.1.0 → refdef-0.2.0}/tests/test_table.py +0 -0
  164. {refdef-0.1.0 → refdef-0.2.0}/tests/test_table_version.py +0 -0
  165. {refdef-0.1.0 → refdef-0.2.0}/tests/test_tree.py +0 -0
  166. {refdef-0.1.0 → refdef-0.2.0}/tests/test_tree_properties.py +0 -0
  167. {refdef-0.1.0 → refdef-0.2.0}/tests/test_vocab.py +0 -0
  168. {refdef-0.1.0 → refdef-0.2.0}/tests/test_wording.py +0 -0
@@ -9,3 +9,4 @@ dist/
9
9
  .site-src/
10
10
  .site/
11
11
  .refdef/
12
+ .cache/
@@ -0,0 +1 @@
1
+ 3.15.1
@@ -4,7 +4,7 @@ refdef is a small Python library for reference designations: the tags that name
4
4
 
5
5
  ## Purpose
6
6
 
7
- A designation names an occurrence of a thing by its place in one or more trees: what it does (function, `=`), what it is (product, `-`) and where it sits (location, `+`). Every electrical engineer who scripts, generates or checks drawings, lists and PLC configurations in Python meets these tags, and every tool passes them around as strings and then re-splits, re-sorts and re-checks them in slightly different ways. refdef gives the tag one structured value, one strict way to print it, one lenient way to read it, and the operations a tool needs on top: relative paths, common prefixes, free-number allocation, natural sort, a class-code table and a check that reports issues. It has no runtime dependency, does no I/O and holds no global state.
7
+ A designation names an occurrence of a thing by its place in one or more trees: what it does (function, `=`), what it is (product, `-`) and where it sits (location, `+`). Every electrical engineer who scripts, generates or checks drawings, lists and PLC configurations in Python meets these tags, and every tool passes them around as strings and then re-splits, re-sorts and re-checks them in slightly different ways. refdef gives the tag one structured value, one strict way to print it, one lenient way to read it, and the operations a tool needs on top: relative paths, common prefixes, free-number allocation, natural sort, a class-code table and a check that reports issues. It also numbers any run of positive integers, such as terminals on a rail, cores of a cable or screw positions, by the same lowest-free rule. It has no runtime dependency, does no I/O and holds no global state.
8
8
 
9
9
  ## The family
10
10
 
@@ -24,13 +24,13 @@ symdef draws a thing, elecdef says what it is, refdef says what it is called; an
24
24
 
25
25
  **Designation.** A frozen tuple of levels and an optional top node (printed `<...>`). The empty designation is the root. Level order is kept as given; nothing re-sorts aspects. The builder is the constructor taking levels and `child(aspect, code, number, label)`, and nothing else: two designations are joined through their levels, `Designation(tag.levels + d.levels)`, and no operator builds a value. It is viewed with `only(*aspects)`, `parent(aspect)` and `last`. A top node, when given, is stripped, non-empty and holds none of `> : ; &`.
26
26
 
27
- **Scheme.** A frozen value passed to every check: the aspects that are on, a `LetterRule` (letter count, letters that never appear) and a `ClassTable`. `STANDARD` is the shipped scheme. `scheme.extend(*entries, aspects=...)` returns a new scheme with project classes overlaid and further aspects on; a project that uses `J` for its board connectors adds `ClassEntry("J", "board connector")` this way.
27
+ **Scheme.** A frozen value passed to every check: the aspects that are on, a `LetterRule` (letter count, letters that never appear) and a `ClassTable`. `STANDARD` is the shipped scheme. `scheme.extend(*entries, aspects=..., never=...)` returns a new scheme with project classes overlaid and further aspects on; a project that uses `J` for its board connectors adds `ClassEntry("J", "board connector")` this way. `never=` bars further letters on top of the scheme's own; a project whose letter rule also bars `A` writes `STANDARD.extend(never={"A"})`. `never` is any iterable of single A-Z letters, stored as a frozenset; a bare `str` or a non-letter is a `TypeError`.
28
28
 
29
29
  **Parse and render.** `str(d)` prints strictly: every level with its sign, no padding, no separators. `render(d, pad=, dotted=, bare=)` adds the dialects a caller may want: `pad={"W": 4}` prints `-W0001`; `dotted=(Aspect.PRODUCT,)` prints a number-only sub-level as `-W25.1`; `bare=True` drops the first sign. `parse(text, bare=)` reads leniently and returns `Parsed(value, issues)`: lower-case code letters are raised, leading zeros dropped, a dot read as a sub-level, text before the first sign taken as the aspect the caller names in `bare` (a required keyword: which aspect a signless tag means is the caller's convention), anything that is not letters and a number kept verbatim as a label, each with a named issue. Nothing is coerced to a default letter or number, and nothing raises for content. `parse` knows shape only; `check(designations, scheme)` holds every scheme rule: an aspect that is off, a letter count, a letter that never appears, an unknown class, a duplicate. An `Issue` is a code from a closed enum of 21 (16 from `parse` and `parse_address`, 5 from `check`, listed in R6), a subject and a message; the caller decides what is fatal. A digit run of zero or over nine digits reads as a label with an issue.
30
30
 
31
31
  **Tree arithmetic.** Every operation works per aspect. `d.within(root)` holds when, for each aspect both share, the root's levels are a head of `d`'s; an aspect missing from either side constrains nothing; a root with a top node matches only that top. `d.relative_to(root)` drops the matched heads and keeps the rest in order, so `=U1=P1+ER+C1-K1` relative to the unit root `=U1+ER` is `=P1+C1-K1`, and a cable `=U1-W1` with no location prints `-W1` inside that unit. `Designation.common(*ds)` is the longest shared head per aspect, ordered as the first argument; every argument is within it. Units as atoms is this: a unit's own document prints each tag relative to the unit's root.
32
32
 
33
- **Numbering.** `allocate(aspect, code, taken, reserved, first=1)` returns the lowest free number: the first `n` from `first` whose level is in neither collection. Gaps refill unless the caller lists them as reserved. `number_group(aspect, codes, taken, reserved)` numbers a sequence of codes in one go and feeds each result back into `taken`. refdef owns no store of retired numbers; what forms a sibling group and what stays reserved are the caller's facts.
33
+ **Numbering.** `allocate(aspect, code, taken, reserved, first=1)` returns the lowest free number: the first `n` from `first` whose level is in neither collection. Gaps refill unless the caller lists them as reserved. `lowest_free(taken, reserved=(), *, first=1)` is the same rule over plain ints: the first `n` from `first` in neither collection. `reserved` holds numbers never handed out, retired or kept back, and the caller stores them; refdef stores none. `number_group(aspect, codes, taken, reserved)` numbers a sequence of codes in one go and feeds each result back into `taken`. refdef owns no store of retired numbers; what forms a sibling group and what stays reserved are the caller's facts.
34
34
 
35
35
  **Sorting.** `natural_key(text, case_sensitive=True)` orders digit runs by value and text runs as text, numbers first, with the raw text as the last tie-break (`X01` before `X1`). `sort_key(d, aspects=, case_sensitive=True)` is one pair per level, aspect rank then the natural key of the level text; `aspects` is a required keyword, since the order of aspects is the caller's convention, and an unlisted aspect ranks after the listed ones.
36
36
 
@@ -46,7 +46,7 @@ The table has its own version line, independent of the library version. A moved,
46
46
 
47
47
  ## The public surface
48
48
 
49
- `refdef.__all__`: `Aspect`, `Level`, `Designation`, `Terminal`, `Signal`, `Scheme`, `ClassEntry`, `STANDARD`, `parse`, `Parsed`, `render`, `check`, `Issue`, `IssueCode`, `allocate`, `number_group`, `natural_key`, `sort_key`. `refdef.plc.__all__`: `Memory`, `Size`, `Address`, `ChannelTag`, `parse_address`. `ClassTable` and `LetterRule` are reached through `Scheme`. An application imports these names and nothing below them. Every surface name has a docstring of one summary line and at most two lines of why, an entry in the generated API reference and a runnable example in the guide (family rule F9). No surface class overloads an operator; one spelling builds, one reads, one prints (F15). `python -m refdef api` prints the two `__all__` tuples with every surface signature; `docs/api-snapshot.txt` holds that output byte for byte, and a change to it is a reviewed design change (family rules F8 and F12).
49
+ `refdef.__all__`: `Aspect`, `Level`, `Designation`, `Terminal`, `Signal`, `Scheme`, `ClassEntry`, `STANDARD`, `parse`, `Parsed`, `render`, `check`, `Issue`, `IssueCode`, `allocate`, `lowest_free`, `number_group`, `natural_key`, `sort_key`. `refdef.plc.__all__`: `Memory`, `Size`, `Address`, `ChannelTag`, `parse_address`. `ClassTable` and `LetterRule` are reached through `Scheme`. An application imports these names and nothing below them. Every surface name has a docstring of one summary line and at most two lines of why, an entry in the generated API reference and a runnable example in the guide (family rule F9). No surface class overloads an operator; one spelling builds, one reads, one prints (F15). `python -m refdef api` prints the two `__all__` tuples with every surface signature; `docs/api-snapshot.txt` holds that output byte for byte, and a change to it is a reviewed design change (family rules F8 and F12).
50
50
 
51
51
  ## Docs and agents
52
52
 
@@ -54,6 +54,10 @@ The docs site is GitHub Pages, built with Zensical from `docs/`, with the API re
54
54
 
55
55
  Two files are written for agents (family rule F16). `llms.txt` at the docs site root lists, in plain text, what refdef is, the URL of each docs page, the surface names with one line each, and the rules in one line each. `AGENTS.md` in the repository root names the docs site, the surface command (`python -m refdef api`) and snapshot (`docs/api-snapshot.txt`), the guide, `DESIGN.md` and `RULES.md`; it says which files change only with a design review (`DESIGN.md`, `RULES.md`, `docs/api-snapshot.txt`, `src/refdef/tables/`, `tests/corpus/`) and that a need the surface does not meet is a reviewed design change and never a workaround. The docs build is a gate: a surface name without an example, or an example that does not run, fails it (F9).
56
56
 
57
+ ## 0.2.0 surface (2026-10-23)
58
+
59
+ `lowest_free(taken, reserved=(), *, first=1)` is the lowest free number over plain ints; a `first` below 1 is a `TypeError`. `Scheme.extend(never=...)` bars further letters as a union with the scheme's own and lifts none; it takes any iterable of single A-Z letters and stores a frozenset. A bare `str` (so "IO" never splits quietly) and any non-letter raise `TypeError`.
60
+
57
61
  ## Out of scope
58
62
 
59
63
  refdef does not hold: the meaning of a pin or terminal number, a device's recommended class code, a drawing of anything, a store of retired numbers or a numbering file, document designations and their kind codes, signal-name rules, wire-number rules, vendor PLC dialects, the three-letter class level, other code sets, translations. Each is either another library's fact or a later, reviewed addition.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: refdef
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Reference designations as strict, typed, immutable Python values: parse, check, print, number and order
5
5
  Project-URL: Repository, https://github.com/OleJBondahl/refdef
6
6
  Author: Ole Johan Bondahl
@@ -8,10 +8,10 @@ These rules bind every implementation and every later change of refdef. The fami
8
8
  - **R2. The shapes that construct.** A `Level` has upper-case letters with a number, a number alone, or a stripped non-empty label without `= - + % # : ; & < > .`; a number is an `int` from 1 to 999_999_999; a label is never shaped like a code (ASCII letters, then digits worth 1 to 999_999_999, such as `K1`, `k01` or `7`), because `-K1` must read back as a coded level (`K0` and `K1234567890` are labels); a `Designation.top` is empty or stripped, non-empty and without `> : ; &`; a `Terminal` has at least one pin, each stripped, non-empty and without `:`; a `Signal.name` is stripped and non-empty, and a `Signal.of` is `None` or a designation with at least one level or a top (an empty `Signal.of` is refused, because `;N` must read back to the same value); any other shape is malformed and raises `TypeError` at construction (F3). GATE: `tests/test_malformed.py` holds one fixture per clause (the labels `K1`, `k01` and `7`, the numbers 0 and 1_000_000_000, and an empty `Signal.of` among them) and asserts `TypeError`; `tests/test_level.py` asserts every value that constructs prints and parses back to itself (R1).
9
9
  - **R3. Strict render.** `str()` prints every level with its sign and never pads, dots or drops a sign; every dialect (`pad`, `dotted`, `bare`) is an explicit `render` option, and a change to any printed form moves the golden. GATE: `tests/test_render.py` asserts `str(v) == render(v)` over the round-trip corpus, compares `render` of `tests/corpus/strings.txt` under each option to `tests/corpus/render.txt` line by line, and asserts each option changes only the documented characters; `scripts/design_guard.py` watches `tests/corpus/` (F12).
10
10
  - **R4. Lenient parse with issues.** `parse` and `parse_address` return a value and an issue tuple for any string, every lenient repair (case, leading zero, dot, missing sign, free label, unsupported suffix) is matched by an issue with its code, and no default letter or number is ever substituted; a digit run that is zero or longer than nine digits (the bound of R2) reads as a label with an issue. GATE: `tests/test_parse.py` feeds a fuzz corpus (random text, every sign alone, empty, whitespace) and asserts no exception, and asserts one issue per repair against a table of input, value and codes.
11
- - **R5. Shape in parse, scheme in check.** `parse` takes no scheme, every rule that needs one (aspect on, letter count, never-letters, unknown class by `code[:depth]`, duplicate) lives in `check`, and no function reads a scheme or table other than through its argument. GATE: `tests/test_parse.py` asserts `parse`'s signature has no `scheme` parameter; `tests/test_check.py` has one case per check-family `IssueCode`, asserts `parse` never emits them, and an AST test in it fails the name `STANDARD` or an import from `refdef.tables` anywhere under `src/refdef` outside `scheme.py` and `__init__.py`.
11
+ - **R5. Shape in parse, scheme in check.** `parse` takes no scheme, every rule that needs one (aspect on, letter count, never-letters (`Scheme.extend(never=)` takes any iterable of single A-Z letters and refuses a bare `str`), unknown class by `code[:depth]`, duplicate) lives in `check`, and no function reads a scheme or table other than through its argument. GATE: `tests/test_parse.py` asserts `parse`'s signature has no `scheme` parameter; `tests/test_check.py` has one case per check-family `IssueCode`, asserts `parse` never emits them, and an AST test in it fails the name `STANDARD` or an import from `refdef.tables` anywhere under `src/refdef` outside `scheme.py` and `__init__.py`.
12
12
  - **R6. An issue is a code, a subject and a message.** `Issue` has exactly these three fields, no severity, and `IssueCode` is a closed enum of exactly 21 codes that every emitted issue uses: the parse family (16) `EMPTY`, `NO_SIGN`, `EMPTY_LEVEL`, `EMPTY_TOP`, `WHITESPACE`, `LOWER_CASE`, `LEADING_ZERO`, `ZERO_NUMBER`, `NUMBER_TOO_LARGE`, `FREE_LABEL`, `BAD_LABEL`, `DOT_LEVEL`, `EMPTY_PIN`, `EMPTY_NAME`, `UNSUPPORTED_SUFFIX`, `NOT_ADDRESS` (the last only from `parse_address`), and the check family (5) `ASPECT_OFF`, `LETTER_COUNT`, `NEVER_LETTER`, `UNKNOWN_CLASS`, `DUPLICATE`; the values are the lower-case names. GATE: `tests/test_check.py` asserts the field names of `Issue`, that the `IssueCode` names equal these 21, and that every issue any test produces has a code in `IssueCode`.
13
13
  - **R7. Per-aspect tree arithmetic.** `within`, `relative_to` (on `Designation`, `Terminal` and `Signal`) and `common` follow the rule in `DESIGN.md` exactly, and every argument is within `common(*ds)`. GATE: `tests/test_tree.py` holds the unit-root, cable, mixed-order and top-node cases for all three types, and a property test asserting `a.within(common(a, b))`, `len(b.relative_to(common(a, b)).levels) == len(b.levels) - len(common(a, b).levels)` and `Terminal(d, p).relative_to(r).of == d.relative_to(r)` for random values.
14
- - **R8. Numbering is pure.** `allocate` returns the lowest free number from `first` given `taken` and `reserved`, `number_group` equals `allocate` applied in sequence with each result added to `taken`, and refdef holds no numbering store. GATE: `tests/test_numbering.py` asserts `allocate` against a brute-force search and `number_group` against the sequential fold, for random inputs.
14
+ - **R8. Numbering is pure.** `allocate` returns the lowest free number from `first` given `taken` and `reserved`, `lowest_free` does the same over plain ints, `number_group` equals `allocate` applied in sequence with each result added to `taken`, and refdef holds no numbering store. GATE: `tests/test_numbering.py` asserts `allocate` and `lowest_free` against a brute-force search, `lowest_free` against `allocate` over one class, and `number_group` against the sequential fold, for random inputs.
15
15
  - **R9. One natural order.** `natural_key` orders digit runs by value and text runs as text, numbers before text, with the raw text as the last tie-break; `sort_key` is one `(aspect rank, natural_key(level.text))` pair per level, with the aspect order a required keyword and an unlisted aspect ranked after the listed ones in enum order. GATE: `tests/test_order.py` asserts the fixed cases (`-W2 < -W10`, `X01 < X1`, numbers before text), a property test that `sorted` by `sort_key` is a total order consistent with `natural_key` on single-level designations, and that `sort_key` has no default for `aspects`.
16
16
  - **R10. The class table is versioned.** `ClassTable.version` is refdef's own line, bumps whenever the entry tuple changes, and the changelog names each moved code. GATE: `tests/test_table_version.py` hashes the entry tuple and compares it with the hash stored beside `version`, failing when the entries change and the version does not; the changelog line is review.
17
17
  - **R11. One PLC grammar in the core.** `refdef.plc` holds only the direct-address form (`%` + memory area + optional size + dotted path, `*` for incomplete), with `as_bit` the one normalisation, and every vendor dialect is its own module behind its own type. GATE: `tests/test_plc.py` round-trips `tests/corpus/addresses.txt` through `parse_address` and `str`, asserts `as_bit` changes only a size-less dotted address, and `tests/test_boundary.py` fails a vendor name (`siemens`, `rockwell`, `omron`, `mitsubishi`, `beckhoff`) in `plc.py`.
@@ -5,6 +5,11 @@ Every change to the public surface gets a line under Unreleased until the releas
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.2.0 (2026-10-23)
9
+
10
+ - Add `lowest_free(taken, reserved=(), *, first=1)`: the lowest free number over plain ints.
11
+ - `Scheme.extend` takes `never`: letters barred in addition to the scheme's own, as any iterable of A-Z letters (a bare `str` is a `TypeError`).
12
+
8
13
  ## 0.1.0 (2026-10-22)
9
14
 
10
15
  - Add `Aspect`, `Level`, `Designation`, `Terminal`, `Signal`, `Issue`, `IssueCode`, `Parsed` and `render`.
@@ -56,6 +56,11 @@ check(designations: 'Iterable[Designation]', scheme: 'Scheme') -> 'tuple[Issue,
56
56
 
57
57
  Terminals and signals are not checked: pass their `of`.
58
58
 
59
+ lowest_free(taken: 'Collection[int]', reserved: 'Collection[int]' = (), *, first: 'int' = 1) -> 'int'
60
+ The smallest number from `first` that is in neither `taken` nor `reserved`.
61
+
62
+ `reserved` is never handed out; the caller stores it. `first` below 1 is a TypeError.
63
+
59
64
  natural_key(text: 'str', *, case_sensitive: 'bool' = True) -> 'tuple[Any, ...]'
60
65
  A sort key: digit runs by value, text runs as text, numbers first, then the raw text.
61
66
 
@@ -25,3 +25,11 @@ P = Aspect.PRODUCT
25
25
  print(check([parse("-J1", bare=P).value], j)) # ()
26
26
  print([i.code.value for i in check([parse("-J1", bare=P).value], STANDARD)]) # ['unknown_class']
27
27
  ```
28
+
29
+ `never` bars further letters on top of the scheme's own: the new set is the union, so `I` and `O`
30
+ stay barred and `extend` never lifts a letter. Any iterable of letters works; a bare `str` is a `TypeError`.
31
+
32
+ ```python
33
+ strict = STANDARD.extend(never={"A"})
34
+ print([i.code.value for i in check([parse("-A1", bare=P).value], strict)]) # ['never_letter', 'unknown_class']
35
+ ```
@@ -16,3 +16,14 @@ print([level.text for level in group])
16
16
  ```
17
17
 
18
18
  `first` is at least 1. A reserved level is skipped like a taken one.
19
+
20
+ `lowest_free` is the same rule over plain positive integers, such as terminals on a rail, cores of
21
+ a cable or screw positions.
22
+
23
+ ```python
24
+ from refdef import lowest_free
25
+ print(lowest_free({1, 2}, reserved={3})) # 4
26
+ print(lowest_free({1, 2}, first=10)) # 10
27
+ ```
28
+
29
+ A reserved number is skipped like a taken one; the caller keeps the reserved set.
@@ -30,10 +30,10 @@ R1. For every designation, terminal or signal value `v`, `parse(str(v), bare=a).
30
30
  R2. A `Level` has upper-case letters with a number, a number alone, or a stripped non-empty label without `= - + % # : ; & < > .`; a number is an `int` from 1 to 999_999_999; a label is never shaped like a code (ASCII letters, then digits worth 1 to 999_999_999, such as `K1`, `k01` or `7`), because `-K1` must read back as a coded level (`K0` and `K1234567890` are labels); a `Designation.top` is empty or stripped, non-empty and without `> : ; &`; a `Terminal` has at least one pin, each stripped, non-empty and without `:`; a `Signal.name` is stripped and non-empty, and a `Signal.of` is `None` or a designation with at least one level or a top (an empty `Signal.of` is refused, because `;N` must read back to the same value); any other shape is malformed and raises `TypeError` at construction (F3).
31
31
  R3. `str()` prints every level with its sign and never pads, dots or drops a sign; every dialect (`pad`, `dotted`, `bare`) is an explicit `render` option, and a change to any printed form moves the golden.
32
32
  R4. `parse` and `parse_address` return a value and an issue tuple for any string, every lenient repair (case, leading zero, dot, missing sign, free label, unsupported suffix) is matched by an issue with its code, and no default letter or number is ever substituted; a digit run that is zero or longer than nine digits (the bound of R2) reads as a label with an issue.
33
- R5. `parse` takes no scheme, every rule that needs one (aspect on, letter count, never-letters, unknown class by `code[:depth]`, duplicate) lives in `check`, and no function reads a scheme or table other than through its argument.
33
+ R5. `parse` takes no scheme, every rule that needs one (aspect on, letter count, never-letters (`Scheme.extend(never=)` takes any iterable of single A-Z letters and refuses a bare `str`), unknown class by `code[:depth]`, duplicate) lives in `check`, and no function reads a scheme or table other than through its argument.
34
34
  R6. `Issue` has exactly these three fields, no severity, and `IssueCode` is a closed enum of exactly 21 codes that every emitted issue uses: the parse family (16) `EMPTY`, `NO_SIGN`, `EMPTY_LEVEL`, `EMPTY_TOP`, `WHITESPACE`, `LOWER_CASE`, `LEADING_ZERO`, `ZERO_NUMBER`, `NUMBER_TOO_LARGE`, `FREE_LABEL`, `BAD_LABEL`, `DOT_LEVEL`, `EMPTY_PIN`, `EMPTY_NAME`, `UNSUPPORTED_SUFFIX`, `NOT_ADDRESS` (the last only from `parse_address`), and the check family (5) `ASPECT_OFF`, `LETTER_COUNT`, `NEVER_LETTER`, `UNKNOWN_CLASS`, `DUPLICATE`; the values are the lower-case names.
35
35
  R7. `within`, `relative_to` (on `Designation`, `Terminal` and `Signal`) and `common` follow the rule in `DESIGN.md` exactly, and every argument is within `common(*ds)`.
36
- R8. `allocate` returns the lowest free number from `first` given `taken` and `reserved`, `number_group` equals `allocate` applied in sequence with each result added to `taken`, and refdef holds no numbering store.
36
+ R8. `allocate` returns the lowest free number from `first` given `taken` and `reserved`, `lowest_free` does the same over plain ints, `number_group` equals `allocate` applied in sequence with each result added to `taken`, and refdef holds no numbering store.
37
37
  R9. `natural_key` orders digit runs by value and text runs as text, numbers before text, with the raw text as the last tie-break; `sort_key` is one `(aspect rank, natural_key(level.text))` pair per level, with the aspect order a required keyword and an unlisted aspect ranked after the listed ones in enum order.
38
38
  R10. `ClassTable.version` is refdef's own line, bumps whenever the entry tuple changes, and the changelog names each moved code.
39
39
  R11. `refdef.plc` holds only the direct-address form (`%` + memory area + optional size + dotted path, `*` for incomplete), with `as_bit` the one normalisation, and every vendor dialect is its own module behind its own type.
@@ -49,6 +49,7 @@ Parsed: a value read from text together with its issues.
49
49
  parse: reads text leniently into a value and the issues of each repair, and never raises for content.
50
50
  render: the strict printer with the explicit options pad, dotted and bare.
51
51
  allocate: the lowest free number of a class given what is taken and reserved; stores nothing.
52
+ lowest_free: the lowest free plain int from first given taken and reserved; stores nothing.
52
53
  number_group: levels for several codes, each numbered after the earlier ones; taken is not changed.
53
54
  natural_key: a sort key with digit runs by value, numbers before text, ties on the raw text.
54
55
  sort_key: a designation's key, one pair per level; the aspect order is a required keyword.
@@ -57,7 +58,7 @@ Size: the access size of a direct PLC address (refdef.plc): none, bit, byte, wor
57
58
  Address: a direct PLC address printed %IX0.0, %QW4 or %I* (refdef.plc); as_bit() adds the bit size to a size-less dotted address.
58
59
  ChannelTag: a terminal with an optional address (refdef.plc), printed as the terminal alone.
59
60
  parse_address: reads a direct PLC address (refdef.plc), repairing outer whitespace and leading zeros with an issue each; text that is not one gives None and a NOT_ADDRESS issue.
60
- Scheme: what check tests against: the aspects that are on, a letter rule and a class table; extend returns a new scheme with project classes and aspects.
61
+ Scheme: what check tests against: the aspects that are on, a letter rule and a class table; extend returns a new scheme with project classes, aspects and further barred letters (never: any iterable of A-Z letters, not a str).
61
62
  ClassEntry: one class code with its title, the typical device of the class.
62
63
  STANDARD: the shipped scheme (function, product and location on, one to three letters without I and O, the versioned standard class table).
63
64
  check: tests designations against a scheme and returns an issue for each finding, in order; it reads nothing but its scheme argument.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "refdef"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Reference designations as strict, typed, immutable Python values: parse, check, print, number and order"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -6,7 +6,7 @@ from refdef._render import render
6
6
  from refdef.designation import Designation
7
7
  from refdef.issues import Issue, IssueCode, Parsed
8
8
  from refdef.level import Aspect, Level
9
- from refdef.numbering import allocate, number_group
9
+ from refdef.numbering import allocate, lowest_free, number_group
10
10
  from refdef.order import natural_key, sort_key
11
11
  from refdef.scheme import STANDARD, ClassEntry, Scheme
12
12
  from refdef.terminal import Signal, Terminal
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "Terminal",
26
26
  "allocate",
27
27
  "check",
28
+ "lowest_free",
28
29
  "natural_key",
29
30
  "number_group",
30
31
  "parse",
@@ -5,6 +5,12 @@ from collections.abc import Collection, Sequence
5
5
  from refdef.level import Aspect, Level
6
6
 
7
7
 
8
+ def _check_first(first: int) -> None:
9
+ if not isinstance(first, int) or isinstance(first, bool) or first < 1:
10
+ msg = "first must be an int of at least 1"
11
+ raise TypeError(msg)
12
+
13
+
8
14
  def allocate(
9
15
  aspect: Aspect,
10
16
  code: str,
@@ -17,15 +23,25 @@ def allocate(
17
23
 
18
24
  `first` below 1 is a TypeError. Nothing is stored: the caller passes what is taken.
19
25
  """
20
- if not isinstance(first, int) or isinstance(first, bool) or first < 1:
21
- msg = "first must be an int of at least 1"
22
- raise TypeError(msg)
26
+ _check_first(first)
23
27
  number = first
24
28
  while Level(aspect, code, number) in taken or Level(aspect, code, number) in reserved:
25
29
  number += 1
26
30
  return number
27
31
 
28
32
 
33
+ def lowest_free(taken: Collection[int], reserved: Collection[int] = (), *, first: int = 1) -> int:
34
+ """The smallest number from `first` that is in neither `taken` nor `reserved`.
35
+
36
+ `reserved` is never handed out; the caller stores it. `first` below 1 is a TypeError.
37
+ """
38
+ _check_first(first)
39
+ number = first
40
+ while number in taken or number in reserved:
41
+ number += 1
42
+ return number
43
+
44
+
29
45
  def number_group(
30
46
  aspect: Aspect,
31
47
  codes: Sequence[str],
@@ -44,6 +44,16 @@ class ClassTable:
44
44
  return self.index.get(code[: self.depth])
45
45
 
46
46
 
47
+ def _check_never(never: Iterable[str]) -> frozenset[str]:
48
+ letters = None if isinstance(never, str) else frozenset(never)
49
+ if letters is None or not all(
50
+ isinstance(c, str) and len(c) == 1 and "A" <= c <= "Z" for c in letters
51
+ ):
52
+ msg = "never must be an iterable of upper-case ASCII letters, not a str"
53
+ raise TypeError(msg)
54
+ return letters
55
+
56
+
47
57
  @dataclass(frozen=True)
48
58
  class Scheme:
49
59
  """What `check` tests against: aspects on, a letter rule and a class table.
@@ -56,11 +66,23 @@ class Scheme:
56
66
  letters: LetterRule
57
67
  classes: ClassTable
58
68
 
59
- def extend(self, *entries: ClassEntry, aspects: Iterable[Aspect] = ()) -> Scheme:
60
- """A new scheme with `entries` overlaid by code (later wins) and `aspects` turned on."""
69
+ def extend(
70
+ self,
71
+ *entries: ClassEntry,
72
+ aspects: Iterable[Aspect] = (),
73
+ never: Iterable[str] = (),
74
+ ) -> Scheme:
75
+ """A new scheme with `entries` overlaid by code (later wins) and `aspects` turned on.
76
+
77
+ `never`: any iterable of A-Z letters (not a str) barred on top of the scheme's own.
78
+ """
79
+ barred = _check_never(never)
61
80
  merged = {entry.code: entry for entry in (*self.classes.entries, *entries)}
62
81
  table = replace(self.classes, entries=tuple(merged.values()))
63
- return replace(self, aspects=self.aspects | frozenset(aspects), classes=table)
82
+ letters = replace(self.letters, never=self.letters.never | barred)
83
+ return replace(
84
+ self, aspects=self.aspects | frozenset(aspects), letters=letters, classes=table
85
+ )
64
86
 
65
87
 
66
88
  _TABLE = ClassTable(
@@ -139,6 +139,68 @@ def test_r5_check_reads_the_scheme_it_is_given():
139
139
  assert _codes("-J1") == ["unknown_class"]
140
140
 
141
141
 
142
+ def _never_codes(scheme, text="-A1"):
143
+ value = parse(text, bare=P).value
144
+ assert isinstance(value, Designation)
145
+ return [i.code for i in check([value], scheme)]
146
+
147
+
148
+ def test_r5_extend_never_bars_a_further_letter():
149
+ """R5: `extend(never=)` makes `check` report the letter; STANDARD does not."""
150
+ strict = STANDARD.extend(never=frozenset("A"))
151
+ assert IssueCode.NEVER_LETTER in _never_codes(strict)
152
+ assert IssueCode.NEVER_LETTER not in _never_codes(STANDARD)
153
+
154
+
155
+ def test_r5_extend_never_is_a_union_and_leaves_the_scheme_alone():
156
+ """R5: I and O stay barred, A is added, and the scheme extended is unchanged."""
157
+ strict = STANDARD.extend(never=frozenset("A"))
158
+ assert strict.letters.never == frozenset("IOA")
159
+ assert strict.letters.min_count == STANDARD.letters.min_count
160
+ assert strict.letters.max_count == STANDARD.letters.max_count
161
+ assert STANDARD.letters.never == frozenset("IO")
162
+ assert IssueCode.NEVER_LETTER in _never_codes(strict, "-I1")
163
+
164
+
165
+ def test_r5_extend_never_composes_with_entries_and_aspects():
166
+ """R5: entries and aspects overlay as before when `never` is also given."""
167
+ scheme = STANDARD.extend(
168
+ ClassEntry("J", "board connector"), aspects=(Aspect.TYPE,), never=frozenset("A")
169
+ )
170
+ assert Aspect.TYPE in scheme.aspects
171
+ assert scheme.classes.get("J") == ClassEntry("J", "board connector")
172
+ assert scheme.letters.never == frozenset("IOA")
173
+
174
+
175
+ def test_r5_extend_default_never_keeps_the_letter_rule():
176
+ """R5: no `never` gives the same letter rule."""
177
+ assert STANDARD.extend().letters == STANDARD.letters
178
+
179
+
180
+ def test_r5_extend_never_accepts_a_set_and_a_tuple():
181
+ """R5: a set and a tuple of letters give the same Scheme as a frozenset."""
182
+ want = STANDARD.extend(never=frozenset("QA"))
183
+ assert STANDARD.extend(never={"Q", "A"}) == want
184
+ assert STANDARD.extend(never=("Q", "A")) == want
185
+ assert want.letters.never == frozenset("IOQA")
186
+
187
+
188
+ def test_r5_extend_never_refuses_a_bare_str():
189
+ """R5: "IO" is a TypeError, not two letters; a one-letter str is refused too."""
190
+ for text in ("IO", "A"):
191
+ with pytest.raises(TypeError):
192
+ STANDARD.extend(never=text)
193
+
194
+
195
+ @pytest.mark.parametrize(
196
+ "never", [frozenset({"a"}), frozenset({"AB"}), frozenset({1}), ["a"], ("AB",), 5]
197
+ )
198
+ def test_r5_extend_never_refuses_non_letters(never):
199
+ """R5: a `never` holding anything but single A-Z letters is a TypeError."""
200
+ with pytest.raises(TypeError):
201
+ STANDARD.extend(never=never)
202
+
203
+
142
204
  def test_r5_check_type_errors():
143
205
  """R5: a non-Scheme scheme or a non-Designation item is a TypeError, not an issue."""
144
206
  with pytest.raises(TypeError):
@@ -44,4 +44,10 @@ def test_a_missing_name_and_a_changed_rule_are_found():
44
44
  assert missing_names("parse: x\nSize: y\n", [("m", "parse"), ("m", "Memory")]) == ["m.Memory"]
45
45
  rules = "- **R1. Title.** Body one. GATE: x\n- **R2. Other.** Body two.\n"
46
46
  assert missing_rules("R1. Body one.\nR2. Body 2.\n", rules) == ["R2"]
47
- assert len(RULE.findall(_read("RULES.md"))) == 12
47
+
48
+
49
+ def test_every_rule_heading_parses_and_the_ids_run_from_one():
50
+ rules = _read("RULES.md")
51
+ ids = [rid for rid, _ in RULE.findall(rules)]
52
+ assert len(ids) == len(re.findall(r"^- \*\*R\d+", rules, re.M))
53
+ assert ids == [f"R{n}" for n in range(1, len(ids) + 1)]
@@ -5,7 +5,7 @@ from itertools import count
5
5
 
6
6
  import pytest
7
7
 
8
- from refdef import Aspect, Level, allocate, number_group
8
+ from refdef import Aspect, Level, allocate, lowest_free, number_group
9
9
 
10
10
  P = Aspect.PRODUCT
11
11
 
@@ -66,3 +66,47 @@ def test_allocate_refuses_a_first_below_one_or_not_an_int(first):
66
66
  """R8: `first` is an int of at least 1, else TypeError."""
67
67
  with pytest.raises(TypeError):
68
68
  allocate(P, "K", (), first=first)
69
+
70
+
71
+ def test_lowest_free_equals_brute_force_over_500_random_inputs():
72
+ """R8: lowest_free is the first n from `first` in neither collection; inputs are unchanged."""
73
+ rng = _rng(10)
74
+ for _ in range(500):
75
+ taken = {n for n in range(1, 12) if rng.random() < 0.6}
76
+ reserved = {n for n in range(1, 12) if rng.random() < 0.4}
77
+ first = rng.randint(1, 6)
78
+ expected = next(n for n in count(first) if n not in taken | reserved)
79
+ before = (set(taken), set(reserved))
80
+ assert lowest_free(taken, reserved, first=first) == expected
81
+ assert (taken, reserved) == before
82
+
83
+
84
+ def test_lowest_free_equals_allocate_over_one_class():
85
+ """R8: over one aspect and code, lowest_free of the numbers is allocate of the levels."""
86
+ rng = _rng(11)
87
+ for _ in range(500):
88
+ taken = _levels(rng, P, "K", 12)
89
+ reserved = _levels(rng, P, "K", 12)
90
+ first = rng.randint(1, 6)
91
+ numbers = ({lv.number for lv in s if lv.number is not None} for s in (taken, reserved))
92
+ got = lowest_free(*numbers, first=first)
93
+ assert got == allocate(P, "K", taken, reserved, first=first)
94
+
95
+
96
+ def test_lowest_free_skips_a_reserved_number_like_a_taken_one():
97
+ """R8: a reserved number leaves a gap."""
98
+ assert lowest_free({1, 2}, {3}) == 4
99
+ assert lowest_free(set(), {1}) == 2
100
+
101
+
102
+ def test_lowest_free_of_nothing_is_first():
103
+ """R8: empty input gives `first`."""
104
+ assert lowest_free(()) == 1
105
+ assert lowest_free((), first=10) == 10
106
+
107
+
108
+ @pytest.mark.parametrize("first", [0, -1, True, 1.0, "1"])
109
+ def test_lowest_free_refuses_a_first_below_one_or_not_an_int(first):
110
+ """R8: `first` is an int of at least 1, else TypeError."""
111
+ with pytest.raises(TypeError):
112
+ lowest_free((), first=first)
@@ -321,7 +321,7 @@ wheels = [
321
321
 
322
322
  [[package]]
323
323
  name = "refdef"
324
- version = "0.1.0"
324
+ version = "0.2.0"
325
325
  source = { editable = "." }
326
326
 
327
327
  [package.dev-dependencies]
@@ -1 +0,0 @@
1
- 3.15.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes