ycleptic 2.2.2__tar.gz → 2.3.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 (67) hide show
  1. {ycleptic-2.2.2 → ycleptic-2.3.0}/.gitignore +1 -0
  2. {ycleptic-2.2.2 → ycleptic-2.3.0}/CHANGELOG.md +11 -0
  3. {ycleptic-2.2.2 → ycleptic-2.3.0}/PKG-INFO +1 -1
  4. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.rst +1 -0
  5. ycleptic-2.3.0/docs/source/api/ycleptic.speccheck.rst +7 -0
  6. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/the_base_config.rst +15 -1
  7. ycleptic-2.3.0/docs/source/usage/yclept_check-spec.rst +72 -0
  8. ycleptic-2.3.0/docs/source/usage.rst +17 -0
  9. {ycleptic-2.2.2 → ycleptic-2.3.0}/pyproject.toml +1 -1
  10. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set.py +149 -3
  11. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/__init__.py +2 -2
  12. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/cli.py +22 -0
  13. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/errors.py +13 -0
  14. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/makedoc.py +3 -0
  15. ycleptic-2.3.0/ycleptic/speccheck.py +161 -0
  16. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/stringthings.py +3 -1
  17. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/walkers.py +15 -2
  18. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/yclept.py +21 -1
  19. ycleptic-2.2.2/docs/source/usage.rst +0 -16
  20. {ycleptic-2.2.2 → ycleptic-2.3.0}/.envrc +0 -0
  21. {ycleptic-2.2.2 → ycleptic-2.3.0}/.github/workflows/ci.yaml +0 -0
  22. {ycleptic-2.2.2 → ycleptic-2.3.0}/.github/workflows/release.yaml +0 -0
  23. {ycleptic-2.2.2 → ycleptic-2.3.0}/.readthedocs.yaml +0 -0
  24. {ycleptic-2.2.2 → ycleptic-2.3.0}/LICENSE +0 -0
  25. {ycleptic-2.2.2 → ycleptic-2.3.0}/README.md +0 -0
  26. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/Makefile +0 -0
  27. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/make.bat +0 -0
  28. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/requirements.txt +0 -0
  29. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/_static/css/custom.css +0 -0
  30. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/about.rst +0 -0
  31. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/API.rst +0 -0
  32. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.cli.rst +0 -0
  33. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.dictthings.rst +0 -0
  34. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.errors.rst +0 -0
  35. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.makedoc.rst +0 -0
  36. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.resources.rst +0 -0
  37. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.stringthings.rst +0 -0
  38. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.walkers.rst +0 -0
  39. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/api/ycleptic.yclept.rst +0 -0
  40. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/changelog.rst +0 -0
  41. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/conf.py +0 -0
  42. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/index.rst +0 -0
  43. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/installation.rst +0 -0
  44. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/roadmap.md +0 -0
  45. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/class.rst +0 -0
  46. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/interactive_help.rst +0 -0
  47. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/quickstart.rst +0 -0
  48. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/resource_file.rst +0 -0
  49. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/the_user_config.rst +0 -0
  50. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/yclept_config-help.rst +0 -0
  51. {ycleptic-2.2.2 → ycleptic-2.3.0}/docs/source/usage/yclept_makedoc.rst +0 -0
  52. {ycleptic-2.2.2 → ycleptic-2.3.0}/pytest.ini +0 -0
  53. {ycleptic-2.2.2 → ycleptic-2.3.0}/scripts/release.sh +0 -0
  54. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/__init__.py +0 -0
  55. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/conftest.py +0 -0
  56. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/README.md +0 -0
  57. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/mypackage/__init__.py +0 -0
  58. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/mypackage/config.py +0 -0
  59. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/mypackage/data/__init__.py +0 -0
  60. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/mypackage/data/base.yaml +0 -0
  61. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/mypackage/docs/source/intro.rst +0 -0
  62. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/mypackage/otherstuff/stuff.py +0 -0
  63. {ycleptic-2.2.2 → ycleptic-2.3.0}/tests/test_set/test_package/rootdir/setup.py +0 -0
  64. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/dictthings.py +0 -0
  65. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/resources/__init__.py +0 -0
  66. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/resources/example_base.yaml +0 -0
  67. {ycleptic-2.2.2 → ycleptic-2.3.0}/ycleptic/src/__init__.py +0 -0
@@ -85,6 +85,7 @@ tests/test_set/console-out.txt
85
85
  tests/test_set/ydoc.rst
86
86
  tests/test_set/ydoc/
87
87
  tests/test_set/rcfile.yaml
88
+ tests/test_set/spec-check-base.yaml
88
89
 
89
90
  # PyBuilder
90
91
  target/
@@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.3.0] - 2026-08-13
9
+
10
+ ### Added
11
+ - Base config specifications are now checked when they are loaded, and any declaration ycleptic ignores is reported: a key outside the recognized set (`options:` where `choices:` was meant), an unrecognized `type:` name (`string` instead of `str`), an attribute with no declared type, and `choices` on a non-`str` attribute, which is not enforced. Such a declaration silently does nothing, so an attribute its author believes is constrained may in fact accept any value. Findings are issued as a `YclepticSpecWarning` and the config still loads; `Yclept(..., strict_spec=True)` raises `YclepticError` instead
12
+ - `yclept check-spec <base.yaml>` reports the same findings from the command line and exits nonzero when any are found, so it can gate a CI run
13
+ - `ycleptic.speccheck.check_base_spec`, which returns the findings as a list of strings
14
+
15
+ ### Fixed
16
+ - `yclept make-doc` now renders an attribute's `choices` as "Allowed values" in the generated documentation. Interactive help has always shown them, so generated docs and interactive help disagreed about what the schema allowed
17
+ - An attribute with no declared `type` now reports a clean error naming the attribute, instead of raising `KeyError: 'type'`
18
+
8
19
  ## [2.2.2] - 2026-08-13
9
20
 
10
21
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ycleptic
3
- Version: 2.2.2
3
+ Version: 2.3.0
4
4
  Summary: A YAML-format configuration generator
5
5
  Project-URL: Source, https://github.com/cameronabrams/ycleptic
6
6
  Project-URL: Documentation, https://ycleptic.readthedocs.io/en/latest/
@@ -24,6 +24,7 @@ Submodules
24
24
  ycleptic.dictthings
25
25
  ycleptic.errors
26
26
  ycleptic.makedoc
27
+ ycleptic.speccheck
27
28
  ycleptic.stringthings
28
29
  ycleptic.walkers
29
30
  ycleptic.yclept
@@ -0,0 +1,7 @@
1
+ ycleptic.speccheck module
2
+ =========================
3
+
4
+ .. automodule:: ycleptic.speccheck
5
+ :members:
6
+ :show-inheritance:
7
+ :undoc-members:
@@ -127,10 +127,24 @@ There are several other keys an attribute may have:
127
127
 
128
128
  1. ``default``: the default value (or values) assigned to the attribute when the user declares it but provides no value.
129
129
  2. ``required``: a boolean. If ``True``, the attribute must be declared (and if it is nested, all its antecedent attributes must be declared too). If ``False``, no defaults are assigned: the user need not declare the attribute at all, but declaring it without providing a value is an error.
130
- 3. ``choices``: a list of allowed values; if the user gives a value that is not in the list, an error occurs. For ``str`` attributes the comparison honors ``case_sensitive`` (below).
130
+ 3. ``choices``: a list of allowed values; if the user gives a value that is not in the list, an error occurs. This is currently enforced for ``str`` attributes only, and the comparison honors ``case_sensitive`` (below); on an attribute of any other type the list has no effect. The allowed values appear in interactive help and in the output of ``yclept make-doc``.
131
131
  4. ``case_sensitive``: for ``str`` attributes only; a boolean that defaults to ``True``. When ``False``, the user's value is matched against ``choices`` case-insensitively and stored in casefolded (lower-case) form.
132
132
  5. ``docs``: a block that enriches the output of ``yclept make-doc``. It may contain ``title`` and ``text`` strings and a YAML-format ``example`` showing the attribute in use. See :ref:`base_config_docs_key` below.
133
133
 
134
+ .. warning::
135
+
136
+ A base config is data, and ycleptic reads only the keys listed above. Any
137
+ other key is discarded without comment, so a misspelling leaves you with a
138
+ declaration that does nothing --- writing ``options:`` instead of
139
+ ``choices:`` yields an attribute that looks constrained but accepts any
140
+ value. The same is true of the type names: ``type: string`` is not
141
+ ``type: str``, and an attribute with an unrecognized type is left entirely
142
+ unvalidated, its ``choices`` unenforced and its ``default`` never applied.
143
+
144
+ Constructing a :class:`~ycleptic.yclept.Yclept` reports these as a
145
+ :class:`~ycleptic.errors.YclepticSpecWarning`, and ``yclept check-spec``
146
+ reports them from the command line. See :ref:`usage_yclept_check_spec`.
147
+
134
148
  .. _base_config_docs_key:
135
149
 
136
150
  Enriching generated documentation with ``docs``
@@ -0,0 +1,72 @@
1
+ .. _usage_yclept_check_spec:
2
+
3
+ ``yclept check-spec``
4
+ =====================
5
+
6
+ A base config is data, and ycleptic reads only the keys it recognizes. A
7
+ misspelled key or type name is therefore discarded without comment, leaving you
8
+ with a declaration that quietly does nothing. The classic case is writing
9
+ ``options:`` where ycleptic expects ``choices:``: the attribute looks
10
+ constrained, but every value is accepted.
11
+
12
+ ``yclept check-spec`` reads a base config file and reports every such
13
+ declaration:
14
+
15
+ .. code-block:: console
16
+
17
+ $ yclept check-spec base.yaml
18
+ base config base.yaml has 3 declarations ycleptic does not act on:
19
+ - attribute 'fetch->source': unrecognized key 'options' (did you mean 'choices'?); it is ignored
20
+ - attribute 'fetch->format': unrecognized type 'string' (did you mean 'str'?); the attribute is left unvalidated
21
+ - attribute 'run->nsteps': 'choices' is only enforced on 'str' attributes, and this one is 'int'; the allowed values are not applied
22
+
23
+ It exits ``0`` when the spec is clean and ``1`` when anything is reported, so it
24
+ can gate a CI run:
25
+
26
+ .. code-block:: yaml
27
+
28
+ - name: Check the base config spec
29
+ run: yclept check-spec mypackage/data/base.yaml
30
+
31
+ What it looks for
32
+ -----------------
33
+
34
+ 1. Keys in an attribute node outside the recognized set (``name``, ``type``,
35
+ ``text``, ``default``, ``required``, ``choices``, ``case_sensitive``,
36
+ ``attributes``, ``docs``), and keys in a ``docs`` block outside ``title``,
37
+ ``text``, and ``example``.
38
+ 2. ``type`` values outside ``str``, ``int``, ``float``, ``bool``, ``tuple``,
39
+ ``list``, and ``dict``. An unrecognized type name matches no branch of the
40
+ validator, so the attribute gets no handling at all --- including its
41
+ ``choices``, even when ``choices`` is spelled correctly, and including its
42
+ ``default``, which is never applied.
43
+ 3. Attributes with no ``type`` at all.
44
+ 4. ``choices`` on an attribute whose type is not ``str``. Allowed values are
45
+ currently enforced only for strings, so elsewhere the list has no effect.
46
+
47
+ Where a suggestion is obvious, it is offered: ``options`` for ``choices``,
48
+ ``string`` for ``str``, and so on.
49
+
50
+ Checking from Python
51
+ --------------------
52
+
53
+ The same checks run whenever a :class:`~ycleptic.yclept.Yclept` is constructed.
54
+ By default anything found is reported as a
55
+ :class:`~ycleptic.errors.YclepticSpecWarning` and the config still loads, so an
56
+ existing schema keeps working while you clean it up:
57
+
58
+ .. code-block:: python
59
+
60
+ Y = Yclept('base.yaml', userfile='user.yaml')
61
+ # YclepticSpecWarning: base config base.yaml has 1 declaration ycleptic does not act on:
62
+ # - attribute 'fetch->source': unrecognized key 'options' (did you mean 'choices'?); it is ignored
63
+
64
+ Pass ``strict_spec=True`` to make it an error instead:
65
+
66
+ .. code-block:: python
67
+
68
+ Y = Yclept('base.yaml', userfile='user.yaml', strict_spec=True)
69
+ # raises YclepticError
70
+
71
+ :func:`ycleptic.speccheck.check_base_spec` returns the same findings as a list
72
+ of strings if you would rather handle them yourself.
@@ -0,0 +1,17 @@
1
+ Usage
2
+ =====
3
+
4
+ ``Ycleptic`` primarily exposes the :class:`ycleptic.yclept.Yclept` class to inherit in your own applications. It also has a command-line interface with three subcommands: ``yclept config-help`` provides interactive help for the configuration file, ``yclept make-doc`` generates documentation from the base configuration file, and ``yclept check-spec`` reports declarations in a base configuration file that ycleptic ignores.
5
+
6
+ .. toctree::
7
+ :maxdepth: 1
8
+
9
+ usage/quickstart
10
+ usage/class
11
+ usage/the_base_config
12
+ usage/the_user_config
13
+ usage/interactive_help
14
+ usage/resource_file
15
+ usage/yclept_config-help
16
+ usage/yclept_makedoc
17
+ usage/yclept_check-spec
@@ -3,7 +3,7 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
  [project]
5
5
  name = "ycleptic"
6
- version = "2.2.2"
6
+ version = "2.3.0"
7
7
  authors = [
8
8
  { name="Cameron F Abrams", email="cfa22@drexel.edu" },
9
9
  ]
@@ -1,18 +1,56 @@
1
1
  import shutil
2
2
  import unittest
3
- from contextlib import redirect_stdout
3
+ from contextlib import redirect_stdout, redirect_stderr
4
4
  import os
5
5
  import yaml
6
6
 
7
7
  from ycleptic.yclept import Yclept
8
8
  from ycleptic import resources
9
- from ycleptic import YclepticError
10
- from ycleptic.cli import config_help
9
+ from ycleptic import YclepticError, YclepticSpecWarning
10
+ from ycleptic.speccheck import check_base_spec
11
+ from ycleptic.cli import config_help, check_spec
11
12
  from ycleptic.dictthings import special_update
12
13
  from ycleptic.stringthings import oxford, generate_footer, dict_to_rst_yaml_block
13
14
 
14
15
  BFILE = os.path.join(os.path.dirname(resources.__file__), 'example_base.yaml')
15
16
 
17
+ # A base spec exercising every silent-typo trap the spec checker looks for.
18
+ BAD_SPEC_YAML = """
19
+ attributes:
20
+ - name: a_opts
21
+ type: str
22
+ text: uses options instead of choices
23
+ options: [red, green]
24
+ default: red
25
+ - name: a_string
26
+ type: string
27
+ text: uses type string instead of str
28
+ choices: [red, green]
29
+ default: red
30
+ - name: an_int
31
+ type: int
32
+ text: choices on a non-str attribute
33
+ choices: [1, 2, 3]
34
+ default: 1
35
+ - name: nested
36
+ type: dict
37
+ text: a nested block
38
+ attributes:
39
+ - name: deep
40
+ type: str
41
+ text: a misspelled key one level down
42
+ chioces: [red, green]
43
+ docs:
44
+ titel: a misspelled docs key
45
+ - name: untyped
46
+ text: no type declared at all
47
+ """
48
+
49
+ # The one trap above that stops a load outright, so tests can drop it.
50
+ UNTYPED_ATTRIBUTE = """ - name: untyped
51
+ text: no type declared at all
52
+ """
53
+
16
54
  EXAMPLE1_YAML = """
17
55
  attribute_2:
18
56
  - attribute_2b:
@@ -306,6 +344,112 @@ base|attribute_2->attribute_2a
306
344
  with open('console-out.txt', 'r') as f:
307
345
  self.assertIn('! quit', f.read())
308
346
 
347
+ # ------------------------------------------------------------------
348
+ # Base-spec validation
349
+ # ------------------------------------------------------------------
350
+
351
+ def test_shipped_example_base_spec_is_clean(self):
352
+ """The shipped example must not trip its own spec checker."""
353
+ with open(BFILE, 'r') as f:
354
+ base = yaml.safe_load(f)
355
+ self.assertEqual(check_base_spec(base), [])
356
+
357
+ def test_spec_check_flags_options_instead_of_choices(self):
358
+ """'options' is read by nothing, so a schema using it is unvalidated."""
359
+ base = yaml.safe_load(BAD_SPEC_YAML)
360
+ problems = check_base_spec(base)
361
+ opt = [p for p in problems if "unrecognized key 'options'" in p]
362
+ self.assertEqual(len(opt), 1)
363
+ self.assertIn("attribute 'a_opts'", opt[0])
364
+ self.assertIn("did you mean 'choices'?", opt[0])
365
+
366
+ def test_spec_check_flags_string_instead_of_str(self):
367
+ """'string' matches no branch, so the attribute gets no string handling."""
368
+ base = yaml.safe_load(BAD_SPEC_YAML)
369
+ problems = check_base_spec(base)
370
+ typ = [p for p in problems if "unrecognized type 'string'" in p]
371
+ self.assertEqual(len(typ), 1)
372
+ self.assertIn("attribute 'a_string'", typ[0])
373
+ self.assertIn("did you mean 'str'?", typ[0])
374
+
375
+ def test_spec_check_flags_choices_on_non_str(self):
376
+ """'choices' is enforced only on str attributes; elsewhere it is inert."""
377
+ base = yaml.safe_load(BAD_SPEC_YAML)
378
+ problems = check_base_spec(base)
379
+ ch = [p for p in problems if "'choices' is only enforced" in p]
380
+ self.assertEqual(len(ch), 1)
381
+ self.assertIn("attribute 'an_int'", ch[0])
382
+
383
+ def test_spec_check_reports_nested_path_and_docs_keys(self):
384
+ """Problems name the full attribute path, including inside a docs block."""
385
+ base = yaml.safe_load(BAD_SPEC_YAML)
386
+ problems = check_base_spec(base)
387
+ self.assertTrue(
388
+ any("attribute 'nested->deep': unrecognized key 'chioces'" in p for p in problems)
389
+ )
390
+ self.assertTrue(any("'titel' in its 'docs' block" in p for p in problems))
391
+
392
+ def test_spec_check_flags_missing_type(self):
393
+ base = yaml.safe_load(BAD_SPEC_YAML)
394
+ problems = check_base_spec(base)
395
+ self.assertTrue(any("attribute 'untyped': no 'type' declared" in p for p in problems))
396
+
397
+ def test_bad_spec_warns_by_default(self):
398
+ """A questionable spec still loads, but says so."""
399
+ specfile = 'spec-check-base.yaml'
400
+ with open(specfile, 'w') as f:
401
+ f.write(BAD_SPEC_YAML.replace(UNTYPED_ATTRIBUTE, ''))
402
+ with self.assertWarns(YclepticSpecWarning) as cm:
403
+ Y = Yclept(specfile, userdict={'a_opts': 'red'})
404
+ self.assertIn('does not act on', str(cm.warning))
405
+ self.assertEqual(Y['user']['a_opts'], 'red')
406
+
407
+ def test_attribute_with_no_type_reports_cleanly(self):
408
+ """A spec attribute with no declared type is an error, not a KeyError."""
409
+ specfile = 'spec-check-base.yaml'
410
+ with open(specfile, 'w') as f:
411
+ f.write(BAD_SPEC_YAML)
412
+ with self.assertWarns(YclepticSpecWarning):
413
+ with self.assertRaises(YclepticError) as cm:
414
+ Yclept(specfile, userdict={})
415
+ self.assertIn('declares no type', str(cm.exception))
416
+
417
+ def test_bad_spec_raises_under_strict_spec(self):
418
+ specfile = 'spec-check-base.yaml'
419
+ with open(specfile, 'w') as f:
420
+ f.write(BAD_SPEC_YAML)
421
+ with self.assertRaises(YclepticError) as cm:
422
+ Yclept(specfile, userdict={}, strict_spec=True)
423
+ self.assertIn("unrecognized key 'options'", str(cm.exception))
424
+
425
+ def test_good_spec_neither_warns_nor_raises(self):
426
+ import warnings
427
+
428
+ with warnings.catch_warnings():
429
+ warnings.simplefilter('error', YclepticSpecWarning)
430
+ Yclept(BFILE, strict_spec=True)
431
+
432
+ def test_check_spec_cli_reports_and_exits_nonzero(self):
433
+ """The check-spec subcommand gates CI on a clean base spec."""
434
+ from argparse import Namespace
435
+
436
+ specfile = 'spec-check-base.yaml'
437
+ with open(specfile, 'w') as f:
438
+ f.write(BAD_SPEC_YAML)
439
+ with open('console-out.txt', 'w') as f:
440
+ with redirect_stderr(f):
441
+ with self.assertRaises(SystemExit) as cm:
442
+ check_spec(Namespace(config=specfile))
443
+ self.assertEqual(cm.exception.code, 1)
444
+ with open('console-out.txt', 'r') as f:
445
+ self.assertIn("unrecognized key 'options'", f.read())
446
+
447
+ with open('console-out.txt', 'w') as f:
448
+ with redirect_stdout(f):
449
+ check_spec(Namespace(config=BFILE))
450
+ with open('console-out.txt', 'r') as f:
451
+ self.assertIn('no unrecognized keys or types', f.read())
452
+
309
453
  def test_makedoc(self):
310
454
  Y = Yclept(BFILE)
311
455
  Y.make_doctree('ydoc')
@@ -323,6 +467,8 @@ Single-valued attributes:
323
467
 
324
468
  * ``attribute_5``: This is a description of Attribute 5
325
469
 
470
+ Allowed values: ``a``, ``b``, ``c``
471
+
326
472
 
327
473
 
328
474
  Subattributes:
@@ -12,6 +12,6 @@ except PackageNotFoundError:
12
12
  logging.getLogger(__name__).addHandler(logging.NullHandler())
13
13
 
14
14
  from ycleptic.yclept import Yclept
15
- from ycleptic.errors import YclepticError
15
+ from ycleptic.errors import YclepticError, YclepticSpecWarning
16
16
 
17
- __all__ = ['Yclept', 'YclepticError']
17
+ __all__ = ['Yclept', 'YclepticError', 'YclepticSpecWarning']
@@ -6,9 +6,11 @@ Command-line interface for ycleptic
6
6
 
7
7
  from __future__ import annotations
8
8
  import sys
9
+ import yaml
9
10
  from .yclept import Yclept
10
11
  import argparse as ap
11
12
  import textwrap
13
+ from .speccheck import check_base_spec, format_problems
12
14
  from .stringthings import oxford, banner_message
13
15
  from .errors import YclepticError
14
16
 
@@ -44,18 +46,35 @@ def config_help(args):
44
46
  )
45
47
 
46
48
 
49
+ def check_spec(args):
50
+ """
51
+ Reports any declarations in a base config file that ycleptic ignores.
52
+ """
53
+ with open(args.config, 'r') as f:
54
+ base = yaml.safe_load(f)
55
+ problems = check_base_spec(base)
56
+ if not problems:
57
+ print(f'{args.config}: no unrecognized keys or types')
58
+ return
59
+ print(format_problems(problems, args.config), file=sys.stderr)
60
+ sys.exit(1)
61
+
62
+
47
63
  def cli():
48
64
  commands = {
49
65
  'make-doc': makedoc,
50
66
  'config-help': config_help,
67
+ 'check-spec': check_spec,
51
68
  }
52
69
  helps = {
53
70
  'make-doc': 'Makes a sphinx/rtd-style doctree from the base config file provided and, optionally, a root node',
54
71
  'config-help': 'Help on a base config file',
72
+ 'check-spec': 'Reports declarations in a base config file that ycleptic ignores',
55
73
  }
56
74
  descs = {
57
75
  'make-doc': 'If you provide the name of a base configuration file for your app, and optionally, a root attribute, this command will generate a sphinx/rtd-style doctree',
58
76
  'config-help': 'If you provide the name of a base configuration file for your app, you can use this command to explore it the way a user would in your app',
77
+ 'check-spec': 'Reads a base configuration file and reports every key or type name ycleptic does not recognize, each of which is silently ignored when configs are validated. Exits nonzero if any are found, so it can gate a CI run',
59
78
  }
60
79
  parser = ap.ArgumentParser(
61
80
  description=textwrap.dedent(banner_message), formatter_class=ap.RawDescriptionHelpFormatter
@@ -84,6 +103,9 @@ def cli():
84
103
  choices=['paragraph', 'comment', 'rubric', 'note', 'raw-html'],
85
104
  help='footer style for the generated documentation; one of "paragraph", "comment", "rubric", "note", or "raw-html"; default %(default)s',
86
105
  )
106
+ command_parsers['check-spec'].add_argument(
107
+ 'config', type=str, default=None, help='input base configuration file in YAML format'
108
+ )
87
109
  command_parsers['config-help'].add_argument(
88
110
  'config', type=str, default=None, help='input base configuration file in YAML format'
89
111
  )
@@ -17,3 +17,16 @@ class YclepticError(Exception):
17
17
  catches :class:`YclepticError` and reports it as a clean, traceback-free
18
18
  error message.
19
19
  """
20
+
21
+
22
+ class YclepticSpecWarning(UserWarning):
23
+ """
24
+ Issued when a base config specification contains a declaration ycleptic
25
+ ignores, such as a misspelled key or an unrecognized type name.
26
+
27
+ Such a declaration does nothing, so an attribute its author believes is
28
+ constrained may in fact accept any value. This is a warning rather than an
29
+ error so that existing specifications keep loading; construct
30
+ :class:`~ycleptic.yclept.Yclept` with ``strict_spec=True`` to raise
31
+ :class:`YclepticError` instead.
32
+ """
@@ -97,6 +97,9 @@ def make_doc(
97
97
  default = sv.get('default', None)
98
98
  default_text = f' (default: {default})' if default is not None else ''
99
99
  fp.write(f' * ``{sv["name"]}``: {sv["text"]}{default_text}\n\n')
100
+ if 'choices' in sv:
101
+ allowed = ', '.join(f'``{c}``' for c in sv['choices'])
102
+ fp.write(f' Allowed values: {allowed}\n\n')
100
103
  sv_example = sv.get('docs', {}).get('example', {})
101
104
  if sv_example:
102
105
  fp.write(' Example:\n\n')
@@ -0,0 +1,161 @@
1
+ # Author: Cameron F. Abrams <cfa22@drexel.edu>
2
+
3
+ """
4
+ Validation of the base config specification itself
5
+
6
+ A base config is data, and ycleptic reads only the keys it recognizes. A
7
+ misspelled key or type name is therefore discarded without comment, leaving the
8
+ schema author with a declaration that silently does nothing --- a
9
+ ``choices:`` list that never constrains anything, for instance. The checks
10
+ here look over a base spec as it is loaded and report those declarations.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import difflib
16
+
17
+ #: Keys ycleptic reads from an attribute node of a base config.
18
+ KNOWN_ATTRIBUTE_KEYS = frozenset(
19
+ {
20
+ 'name',
21
+ 'type',
22
+ 'text',
23
+ 'default',
24
+ 'required',
25
+ 'choices',
26
+ 'case_sensitive',
27
+ 'attributes',
28
+ 'docs',
29
+ }
30
+ )
31
+
32
+ #: Keys ycleptic reads from an attribute's ``docs`` block.
33
+ KNOWN_DOCS_KEYS = frozenset({'title', 'text', 'example'})
34
+
35
+ #: Keys ycleptic reads from the top level of a base config.
36
+ KNOWN_TOP_KEYS = frozenset({'attributes', 'docs'})
37
+
38
+ #: Type names ycleptic recognizes.
39
+ KNOWN_TYPES = frozenset({'str', 'int', 'float', 'bool', 'tuple', 'list', 'dict'})
40
+
41
+ # Misspellings common enough to name outright rather than leave to difflib.
42
+ _KEY_ALIASES = {
43
+ 'option': 'choices',
44
+ 'options': 'choices',
45
+ 'allowed': 'choices',
46
+ 'allowed_values': 'choices',
47
+ 'attribute': 'attributes',
48
+ 'description': 'text',
49
+ 'help': 'text',
50
+ 'subattributes': 'attributes',
51
+ }
52
+
53
+ _TYPE_ALIASES = {
54
+ 'array': 'list',
55
+ 'boolean': 'bool',
56
+ 'double': 'float',
57
+ 'integer': 'int',
58
+ 'map': 'dict',
59
+ 'mapping': 'dict',
60
+ 'number': 'float',
61
+ 'object': 'dict',
62
+ 'real': 'float',
63
+ 'sequence': 'list',
64
+ 'string': 'str',
65
+ 'text': 'str',
66
+ }
67
+
68
+
69
+ def _suggest(word, known, aliases):
70
+ """
71
+ Return a ``did you mean`` clause for ``word``, or an empty string.
72
+ """
73
+ if not isinstance(word, str):
74
+ return ''
75
+ alias = aliases.get(word.lower())
76
+ if alias is None:
77
+ close = difflib.get_close_matches(word.lower(), sorted(known), n=1, cutoff=0.7)
78
+ alias = close[0] if close else None
79
+ return f" (did you mean '{alias}'?)" if alias else ''
80
+
81
+
82
+ def _path_str(path: list[str]) -> str:
83
+ return '->'.join(path) if path else 'the top level'
84
+
85
+
86
+ def _check_node(node: dict, path: list[str], problems: list[str]):
87
+ where = f"attribute '{_path_str(path)}'"
88
+
89
+ for key in node:
90
+ if key not in KNOWN_ATTRIBUTE_KEYS:
91
+ problems.append(
92
+ f'{where}: unrecognized key '
93
+ f"'{key}'{_suggest(key, KNOWN_ATTRIBUTE_KEYS, _KEY_ALIASES)}; it is ignored"
94
+ )
95
+
96
+ typ = node.get('type')
97
+ if typ is None:
98
+ problems.append(f"{where}: no 'type' declared")
99
+ elif typ not in KNOWN_TYPES:
100
+ problems.append(
101
+ f"{where}: unrecognized type '{typ}'"
102
+ f'{_suggest(typ, KNOWN_TYPES, _TYPE_ALIASES)}; the attribute is left unvalidated'
103
+ )
104
+ elif 'choices' in node and typ != 'str':
105
+ problems.append(
106
+ f"{where}: 'choices' is only enforced on 'str' attributes, "
107
+ f"and this one is '{typ}'; the allowed values are not applied"
108
+ )
109
+
110
+ docs = node.get('docs')
111
+ if isinstance(docs, dict):
112
+ for key in docs:
113
+ if key not in KNOWN_DOCS_KEYS:
114
+ problems.append(
115
+ f"{where}: unrecognized key '{key}' in its 'docs' block"
116
+ f'{_suggest(key, KNOWN_DOCS_KEYS, {})}; it is ignored'
117
+ )
118
+
119
+ for sub in node.get('attributes', []) or []:
120
+ if isinstance(sub, dict):
121
+ _check_node(sub, path + [str(sub.get('name', '?'))], problems)
122
+
123
+
124
+ def check_base_spec(base: dict) -> list[str]:
125
+ """
126
+ Look over a base config specification and report declarations ycleptic ignores.
127
+
128
+ Parameters
129
+ ----------
130
+ base : dict
131
+ A base config specification, as loaded from a base config file.
132
+
133
+ Returns
134
+ -------
135
+ list of str
136
+ One message per problem found, in the order encountered. Empty if the
137
+ specification uses only keys and type names ycleptic recognizes.
138
+ """
139
+ problems: list[str] = []
140
+ if not isinstance(base, dict):
141
+ return problems
142
+ for key in base:
143
+ if key not in KNOWN_TOP_KEYS:
144
+ problems.append(
145
+ f'the top level: unrecognized key '
146
+ f"'{key}'{_suggest(key, KNOWN_TOP_KEYS, _KEY_ALIASES)}; it is ignored"
147
+ )
148
+ for node in base.get('attributes', []) or []:
149
+ if isinstance(node, dict):
150
+ _check_node(node, [str(node.get('name', '?'))], problems)
151
+ return problems
152
+
153
+
154
+ def format_problems(problems: list[str], basefile: str = '') -> str:
155
+ """
156
+ Render ``problems`` as a single multi-line report.
157
+ """
158
+ src = f' {basefile}' if basefile else ''
159
+ ess = 's' if len(problems) > 1 else ''
160
+ head = f'base config{src} has {len(problems)} declaration{ess} ycleptic does not act on:'
161
+ return '\n'.join([head] + [f' - {p}' for p in problems])
@@ -5,6 +5,8 @@ Various string manipulation functions for ycleptic
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
+
9
+ from typing import NoReturn
8
10
  import yaml
9
11
  from datetime import date
10
12
  from . import __version__
@@ -19,7 +21,7 @@ banner_message = """
19
21
  """.format(__version__)
20
22
 
21
23
 
22
- def raise_clean(ErrorInstance):
24
+ def raise_clean(ErrorInstance) -> NoReturn:
23
25
  """
24
26
  Raises a :class:`~ycleptic.errors.YclepticError` carrying the message of
25
27
  the given exception instance.
@@ -106,6 +106,19 @@ def _scalar_type_ok(typ: str, value) -> bool:
106
106
  return True
107
107
 
108
108
 
109
+ def _declared_type(dx: dict, dname: str) -> str:
110
+ """
111
+ Return the declared type of attribute spec ``dx``, or report its absence.
112
+
113
+ Every attribute must declare a type; without one there is nothing to
114
+ validate the user's value against.
115
+ """
116
+ typ = dx.get('type')
117
+ if typ is None:
118
+ raise_clean(ValueError(f"Attribute '{dx.get('name', '?')}' of '{dname}' declares no type."))
119
+ return typ
120
+
121
+
109
122
  def dwalk(D: dict, I: dict):
110
123
  """
111
124
  Recursively process the user's config-dict I by walking recursively through it
@@ -143,7 +156,7 @@ def dwalk(D: dict, I: dict):
143
156
  dx = D['attributes'][tidx]
144
157
  # logger.debug(f' d {d}')
145
158
  # get its type
146
- typ = dx['type']
159
+ typ = _declared_type(dx, dname)
147
160
  if typ == 'dict' and (d in I and not isinstance(I[d], dict)):
148
161
  raise_clean(
149
162
  ValueError(f"Attribute '{d}' of '{dname}' must be a dict; found {type(I[d])}.")
@@ -261,7 +274,7 @@ def lwalk(D: dict, L: list[dict]):
261
274
  )
262
275
  tidx = tld.index(itemname)
263
276
  dx = D['attributes'][tidx]
264
- typ = dx['type']
277
+ typ = _declared_type(dx, D['name'])
265
278
  if typ in ['str', 'int', 'float']:
266
279
  # because a list attribute indicates an ordered sequence of tasks and we expect each
267
280
  # task to be a dictionary specifying the task and not a single scalar value,
@@ -7,12 +7,16 @@ from __future__ import annotations
7
7
  import logging
8
8
  import sys
9
9
  import textwrap
10
+ import warnings
10
11
  from pathlib import Path
11
12
  import yaml
12
13
  from collections import UserDict
13
14
  from argparse import Namespace
14
15
  from . import __version__
16
+ from .errors import YclepticSpecWarning
15
17
  from .makedoc import make_doc
18
+ from .speccheck import check_base_spec, format_problems
19
+ from .stringthings import raise_clean
16
20
  from .walkers import make_def, mwalk, dwalk
17
21
 
18
22
  logger = logging.getLogger(__name__)
@@ -34,10 +38,20 @@ class Yclept(UserDict):
34
38
  A dictionary of user-defined configurations. Optional; used instead of ``userfile`` when provided.
35
39
  rcfile : str
36
40
  The path to a resource config file that extends the base config. Optional.
41
+ strict_spec : bool
42
+ If True, a base config containing declarations ycleptic ignores --- a
43
+ misspelled key or an unrecognized type name --- raises
44
+ :class:`~ycleptic.errors.YclepticError` instead of issuing a
45
+ :class:`~ycleptic.errors.YclepticSpecWarning`. Optional; defaults to False.
37
46
  """
38
47
 
39
48
  def __init__(
40
- self, basefile: str, userfile: str = '', userdict: dict | None = None, rcfile: str = ''
49
+ self,
50
+ basefile: str,
51
+ userfile: str = '',
52
+ userdict: dict | None = None,
53
+ rcfile: str = '',
54
+ strict_spec: bool = False,
41
55
  ):
42
56
  data = {}
43
57
  with open(basefile, 'r') as f:
@@ -46,6 +60,12 @@ class Yclept(UserDict):
46
60
  with open(rcfile, 'r') as f:
47
61
  rc = yaml.safe_load(f)
48
62
  mwalk(data['base'], rc)
63
+ problems = check_base_spec(data['base'])
64
+ if problems:
65
+ report = format_problems(problems, basefile)
66
+ if strict_spec:
67
+ raise_clean(ValueError(report))
68
+ warnings.warn(report, YclepticSpecWarning, stacklevel=2)
49
69
  super().__init__(data)
50
70
  if userdict is None:
51
71
  userdict = {}
@@ -1,16 +0,0 @@
1
- Usage
2
- =====
3
-
4
- ``Ycleptic`` primarily exposes the :class:`ycleptic.yclept.Yclept` class to inherit in your own applications. It also has a command-line interface with two subcommands: ``yclept config-help`` and ``yclept make-doc``. The former provides interactive help for the configuration file, and the latter generates documentation from the base configuration file.
5
-
6
- .. toctree::
7
- :maxdepth: 1
8
-
9
- usage/quickstart
10
- usage/class
11
- usage/the_base_config
12
- usage/the_user_config
13
- usage/interactive_help
14
- usage/resource_file
15
- usage/yclept_config-help
16
- usage/yclept_makedoc
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