Gen3SchemaDev 2.6.0__tar.gz → 2.6.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 (24) hide show
  1. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/PKG-INFO +3 -12
  2. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/README.md +2 -11
  3. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/pyproject.toml +1 -1
  4. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/cli.py +1 -68
  5. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/converter.py +8 -13
  6. gen3schemadev-2.6.2/src/gen3schemadev/refs.py +74 -0
  7. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/program.yaml +1 -2
  8. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/project.yaml +2 -4
  9. gen3schemadev-2.6.0/src/gen3schemadev/refs.py +0 -262
  10. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/LICENSE +0 -0
  11. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/__init__.py +0 -0
  12. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/ddvis.py +0 -0
  13. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/__init__.py +0 -0
  14. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/gen3_template.py +0 -0
  15. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/input_schema.py +0 -0
  16. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/_definitions.yaml +0 -0
  17. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/_settings.yaml +0 -0
  18. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/_terms.yaml +0 -0
  19. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/core_metadata_collection.yaml +0 -0
  20. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/schema/schema_templates/gen3_metaschema.yml +0 -0
  21. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/utils.py +0 -0
  22. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/validators/input_validator.py +0 -0
  23. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/validators/metaschema_validator.py +0 -0
  24. {gen3schemadev-2.6.0 → gen3schemadev-2.6.2}/src/gen3schemadev/validators/rule_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Gen3SchemaDev
3
- Version: 2.6.0
3
+ Version: 2.6.2
4
4
  Summary: Tool for data modelling in Gen3
5
5
  License: Apache 2.0
6
6
  License-File: LICENSE
@@ -35,18 +35,9 @@ This repository aims to provide the documentation, learning materials, and softw
35
35
  - [Guide to creating your first dictionary](docs/gen3schemadev/first_dictionary.md)
36
36
  - [Troubleshooting](docs/gen3schemadev/troubleshooting.md)
37
37
 
38
- ### Fixing `$ref` properties in an existing dictionary
38
+ ### Null description placeholders
39
39
 
40
- JSON Schema draft-04 (which Gen3 uses) ignores any keyword sitting as a direct sibling of `$ref`, so properties written as `{description: ..., $ref: _definitions.yaml#/X}` lose their description in the data-dictionary viewer. The `fix-refs` command rewrites such properties in place, moving the `$ref` into an `allOf` list so annotations survive resolution:
41
-
42
- ```bash
43
- gen3schemadev fix-refs -y path/to/dictionary --dry-run # preview changes
44
- gen3schemadev fix-refs -y path/to/dictionary # rewrite in place
45
- ```
46
-
47
- The command is idempotent and never touches bare refs, refs already inside `allOf`/`anyOf`/`oneOf`, the `properties: {$ref: ...}` merge construct, or `_definitions.yaml`/`_terms.yaml`/`_settings.yaml`.
48
-
49
- Both `fix-refs` and `validate` also warn about `description: null` placeholders anywhere in the dictionary (commonly in `_definitions.yaml`): the Gen3 metaschema requires descriptions to be strings, and such placeholders fail metaschema validation once exposed through bare or `allOf`-wrapped refs. Remove the null keys to resolve the warning.
40
+ `gen3schemadev validate` warns about `description: null` placeholders anywhere in the dictionary (commonly in `_definitions.yaml`'s enum definitions). The Gen3 metaschema requires descriptions to be strings; null placeholders cause "No Description" in the data-dictionary viewer and metaschema validation failures that surface on resolved node schemas, far from the offending definition. Remove the null `description` keys to resolve the warning.
50
41
 
51
42
 
52
43
  ## Deep dive into Gen3 Data Modelling
@@ -12,18 +12,9 @@ This repository aims to provide the documentation, learning materials, and softw
12
12
  - [Guide to creating your first dictionary](docs/gen3schemadev/first_dictionary.md)
13
13
  - [Troubleshooting](docs/gen3schemadev/troubleshooting.md)
14
14
 
15
- ### Fixing `$ref` properties in an existing dictionary
15
+ ### Null description placeholders
16
16
 
17
- JSON Schema draft-04 (which Gen3 uses) ignores any keyword sitting as a direct sibling of `$ref`, so properties written as `{description: ..., $ref: _definitions.yaml#/X}` lose their description in the data-dictionary viewer. The `fix-refs` command rewrites such properties in place, moving the `$ref` into an `allOf` list so annotations survive resolution:
18
-
19
- ```bash
20
- gen3schemadev fix-refs -y path/to/dictionary --dry-run # preview changes
21
- gen3schemadev fix-refs -y path/to/dictionary # rewrite in place
22
- ```
23
-
24
- The command is idempotent and never touches bare refs, refs already inside `allOf`/`anyOf`/`oneOf`, the `properties: {$ref: ...}` merge construct, or `_definitions.yaml`/`_terms.yaml`/`_settings.yaml`.
25
-
26
- Both `fix-refs` and `validate` also warn about `description: null` placeholders anywhere in the dictionary (commonly in `_definitions.yaml`): the Gen3 metaschema requires descriptions to be strings, and such placeholders fail metaschema validation once exposed through bare or `allOf`-wrapped refs. Remove the null keys to resolve the warning.
17
+ `gen3schemadev validate` warns about `description: null` placeholders anywhere in the dictionary (commonly in `_definitions.yaml`'s enum definitions). The Gen3 metaschema requires descriptions to be strings; null placeholders cause "No Description" in the data-dictionary viewer and metaschema validation failures that surface on resolved node schemas, far from the offending definition. Remove the null `description` keys to resolve the warning.
27
18
 
28
19
 
29
20
  ## Deep dive into Gen3 Data Modelling
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "Gen3SchemaDev"
3
- version = "2.6.0"
3
+ version = "2.6.2"
4
4
  description = "Tool for data modelling in Gen3"
5
5
  authors = [
6
6
  {name = "JoshuaHarris391",email = "harjo391@gmail.com"}
@@ -20,13 +20,7 @@ from gen3schemadev.validators.metaschema_validator import validate_schema_with_m
20
20
  from importlib.metadata import version
21
21
  from gen3schemadev.ddvis import visualise_with_docker
22
22
  from gen3schemadev.validators.rule_validator import RuleValidator
23
- from gen3schemadev.refs import (
24
- WRAPPED,
25
- SKIP_REASONS,
26
- fix_yaml_dir,
27
- find_null_descriptions,
28
- scan_dir_null_descriptions,
29
- )
23
+ from gen3schemadev.refs import find_null_descriptions
30
24
 
31
25
 
32
26
  def print_null_description_warning(hits):
@@ -168,27 +162,6 @@ def main():
168
162
  help="Set logging level to DEBUG"
169
163
  )
170
164
 
171
- # Create 'fix-refs' subcommand
172
- fixrefs_parser = subparsers.add_parser(
173
- "fix-refs",
174
- help="Rewrite properties with '$ref' siblings into draft-04-safe allOf form (in place)"
175
- )
176
- fixrefs_parser.add_argument(
177
- "-y", "--yamldir",
178
- required=True,
179
- help="Directory of Gen3 YAML files to rewrite in place (recursive)"
180
- )
181
- fixrefs_parser.add_argument(
182
- "--dry-run",
183
- action="store_true",
184
- help="Report what would change without writing files"
185
- )
186
- fixrefs_parser.add_argument(
187
- "--debug",
188
- action="store_true",
189
- help="Set logging level to DEBUG"
190
- )
191
-
192
165
  args = parser.parse_args()
193
166
 
194
167
  # Handle case where no command is provided
@@ -361,45 +334,5 @@ def main():
361
334
  output_path = args.output or "input_example.yaml"
362
335
  write_yaml(init_yaml, output_path)
363
336
 
364
- elif args.command == "fix-refs":
365
- if not os.path.isdir(args.yamldir):
366
- print(f"ERROR: Directory not found: {args.yamldir}", file=sys.stderr)
367
- sys.exit(1)
368
-
369
- if args.dry_run:
370
- print(f"Dry run: scanning {args.yamldir} (no files will be written)")
371
- else:
372
- print(f"Fixing $ref sibling annotations in: {args.yamldir}")
373
-
374
- reports = fix_yaml_dir(args.yamldir, dry_run=args.dry_run)
375
-
376
- total_wrapped = 0
377
- files_changed = 0
378
- for report in reports:
379
- print(report["path"])
380
- if report["skipped_file"]:
381
- print(f" skipped file ({report['skipped_file']})")
382
- continue
383
- if not report["changes"]:
384
- print(" no $ref properties found")
385
- continue
386
- for prop_name, action, preserved in report["changes"]:
387
- if action == WRAPPED:
388
- total_wrapped += 1
389
- print(f" wrapped: {prop_name} (preserved: {', '.join(preserved)})")
390
- else:
391
- print(f" skipped ({SKIP_REASONS[action]}): {prop_name}")
392
- if report["rewritten"] or (args.dry_run and any(c[1] == WRAPPED for c in report["changes"])):
393
- files_changed += 1
394
-
395
- files_unchanged = len(reports) - files_changed
396
- verb = "would be wrapped" if args.dry_run else "wrapped"
397
- print(f"\nSummary: {total_wrapped} properties {verb} in {files_changed} files; "
398
- f"{files_unchanged} files unchanged.")
399
-
400
- # Wrapping is what exposes null 'description' placeholders in shared
401
- # definitions to metaschema validation, so surface them here too.
402
- print_null_description_warning(scan_dir_null_descriptions(args.yamldir))
403
-
404
337
  if __name__ == "__main__":
405
338
  main()
@@ -17,8 +17,6 @@ from dataclasses import dataclass, asdict
17
17
  from typing import Protocol, runtime_checkable, Dict, Any, List
18
18
  import logging
19
19
 
20
- from gen3schemadev.refs import wrap_ref_siblings
21
-
22
20
 
23
21
  logger = logging.getLogger(__name__)
24
22
 
@@ -490,11 +488,11 @@ def format_datetime(prop_dict: dict) -> dict:
490
488
  Formats a property dictionary with a 'type' of 'datetime' to use a $ref
491
489
  to the Gen3 _definitions.yaml#/datetime definition.
492
490
 
493
- Annotations such as 'description' are preserved by wrapping the $ref in
494
- an allOf list: under JSON Schema draft-04, keywords sitting as direct
495
- siblings of $ref are ignored during resolution, so they must live beside
496
- allOf instead. A datetime property with no annotations becomes a bare
497
- $ref. If the property is not of type 'datetime', it is returned unchanged.
491
+ Annotations such as 'description' are preserved as siblings of the $ref:
492
+ Gen3's resolver merges sibling keys over the referenced definition, so
493
+ the property's own description survives resolution. A datetime property
494
+ with no annotations becomes a bare $ref. If the property is not of type
495
+ 'datetime', it is returned unchanged.
498
496
 
499
497
  Example input:
500
498
  {
@@ -508,9 +506,7 @@ def format_datetime(prop_dict: dict) -> dict:
508
506
  {
509
507
  "collection_date": {
510
508
  "description": "Date and time of collection (datetime)",
511
- "allOf": [
512
- {"$ref": "_definitions.yaml#/datetime"}
513
- ]
509
+ "$ref": "_definitions.yaml#/datetime"
514
510
  }
515
511
  }
516
512
 
@@ -532,9 +528,8 @@ def format_datetime(prop_dict: dict) -> dict:
532
528
  value = prop_dict[first_key]
533
529
 
534
530
  if isinstance(value, dict) and value.get('type') == 'datetime':
535
- ref_prop = {k: v for k, v in value.items() if k != 'type'}
536
- ref_prop['$ref'] = "_definitions.yaml#/datetime"
537
- formatted_props, _ = wrap_ref_siblings(ref_prop, first_key)
531
+ formatted_props = {k: v for k, v in value.items() if k != 'type'}
532
+ formatted_props['$ref'] = "_definitions.yaml#/datetime"
538
533
  else:
539
534
  formatted_props = value
540
535
 
@@ -0,0 +1,74 @@
1
+ """Diagnostics for ``$ref`` usage in Gen3 data dictionaries.
2
+
3
+ Gen3's resolver merges a property's sibling keys over the referenced
4
+ definition, so writing annotations next to a ``$ref`` is the normal,
5
+ working form::
6
+
7
+ atrial_fibrillation:
8
+ description: "Self-reported atrial fibrillation."
9
+ $ref: "_definitions.yaml#/enum_yes_no"
10
+
11
+ The real hazard is a ``description: null`` placeholder inside a shared
12
+ definition (commonly the enum definitions in ``_definitions.yaml``): the
13
+ Gen3 metaschema requires ``description`` to be a string, and the null shows
14
+ up as "No Description" in the data-dictionary viewer and fails metaschema
15
+ validation on resolved node schemas — far from the definition that carries
16
+ it. ``find_null_descriptions`` reports every such placeholder by path so
17
+ the root cause is named up front.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _COMBINATORS = ("allOf", "anyOf", "oneOf")
27
+
28
+
29
+ def has_ref(prop) -> bool:
30
+ """
31
+ Return True if a property definition references another schema, either
32
+ via a top-level ``$ref`` or via a ``$ref`` inside an allOf/anyOf/oneOf
33
+ list item.
34
+ """
35
+ if not isinstance(prop, dict):
36
+ return False
37
+ if "$ref" in prop:
38
+ return True
39
+ for combinator in _COMBINATORS:
40
+ items = prop.get(combinator)
41
+ if isinstance(items, list):
42
+ if any(isinstance(item, dict) and "$ref" in item for item in items):
43
+ return True
44
+ return False
45
+
46
+
47
+ def find_null_descriptions(node, path: str = "") -> list:
48
+ """
49
+ Recursively find every ``description`` key whose value is null.
50
+
51
+ The Gen3 metaschema requires ``description`` to be a string, so a
52
+ ``description: null`` placeholder (common in generated shared
53
+ definitions) is invalid. Because the property's own description merges
54
+ over the definition's during resolution, the null often stays hidden
55
+ until it surfaces on some resolved node schema — where the resulting
56
+ metaschema failure points far away from the definition that caused it.
57
+
58
+ Returns a list of dotted paths to each offender, with list items shown
59
+ as ``[i]`` (e.g. ``"enum_yes_no.description"``,
60
+ ``"properties.status.anyOf[0].description"``). Non-dict/list input
61
+ yields an empty list.
62
+ """
63
+ hits = []
64
+ if isinstance(node, dict):
65
+ for key, value in node.items():
66
+ key_path = f"{path}.{key}" if path else str(key)
67
+ if key == "description" and value is None:
68
+ hits.append(key_path)
69
+ else:
70
+ hits.extend(find_null_descriptions(value, key_path))
71
+ elif isinstance(node, list):
72
+ for i, item in enumerate(node):
73
+ hits.extend(find_null_descriptions(item, f"{path}[{i}]"))
74
+ return hits
@@ -24,9 +24,8 @@ properties:
24
24
  type:
25
25
  type: string
26
26
  id:
27
+ $ref: _definitions.yaml#/UUID
27
28
  systemAlias: node_id
28
- allOf:
29
- - $ref: _definitions.yaml#/UUID
30
29
  name:
31
30
  type: string
32
31
  description: Full name/title of the program.
@@ -35,10 +35,9 @@ properties:
35
35
  type:
36
36
  type: string
37
37
  id:
38
+ $ref: _definitions.yaml#/UUID
38
39
  systemAlias: node_id
39
40
  description: UUID for the project.
40
- allOf:
41
- - $ref: _definitions.yaml#/UUID
42
41
  name:
43
42
  type: string
44
43
  description: Display name/brief description for the project.
@@ -63,11 +62,10 @@ properties:
63
62
  description: The ID of the source providing support/grant resources.
64
63
  type: string
65
64
  programs:
65
+ $ref: _definitions.yaml#/to_one
66
66
  description: 'Indicates that the Study is logically part of the indicated Study.
67
67
 
68
68
  '
69
- allOf:
70
- - $ref: _definitions.yaml#/to_one
71
69
  state:
72
70
  description: 'The possible states a project can be in. All but `open` are
73
71
 
@@ -1,262 +0,0 @@
1
- """Utilities for making ``$ref`` properties safe under JSON Schema draft-04.
2
-
3
- Gen3 compiles data dictionaries with draft-04 semantics, where any keyword
4
- sitting as a direct sibling of ``$ref`` is ignored during resolution. A
5
- property written as::
6
-
7
- atrial_fibrillation:
8
- description: "Self-reported atrial fibrillation."
9
- $ref: "_definitions.yaml#/enum_yes_no"
10
-
11
- loses its ``description`` (and ``termDef``, ``term``, etc.) when resolved by
12
- strict tooling such as dictionaryutils, so the data-dictionary viewer shows
13
- "No Description". The fix is structural: keep the annotations at the top
14
- level and move the ``$ref`` into an ``allOf`` list::
15
-
16
- atrial_fibrillation:
17
- description: "Self-reported atrial fibrillation."
18
- allOf:
19
- - $ref: "_definitions.yaml#/enum_yes_no"
20
-
21
- This module is the single source of truth for that transformation. It is
22
- used by the schema generator (``converter.format_datetime``), the rule
23
- validator, and the ``gen3schemadev fix-refs`` CLI command.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import fnmatch
29
- import logging
30
- import os
31
-
32
- logger = logging.getLogger(__name__)
33
-
34
- # Actions recorded for each property examined by the transform.
35
- WRAPPED = "wrapped"
36
- BARE_REF = "bare-ref"
37
- ALREADY_WRAPPED = "already-wrapped"
38
- NO_REF = "no-ref"
39
- PROPERTIES_MERGE = "properties-merge"
40
-
41
- # Human-readable reasons for skipped properties, keyed by action.
42
- SKIP_REASONS = {
43
- BARE_REF: "bare $ref, no siblings to preserve",
44
- ALREADY_WRAPPED: "already wrapped in allOf/anyOf/oneOf",
45
- PROPERTIES_MERGE: "$ref key in 'properties' is a Gen3 properties-merge",
46
- }
47
-
48
- _COMBINATORS = ("allOf", "anyOf", "oneOf")
49
-
50
-
51
- def has_ref(prop) -> bool:
52
- """
53
- Return True if a property definition references another schema, either
54
- via a top-level ``$ref`` or via a ``$ref`` inside an allOf/anyOf/oneOf
55
- list item.
56
- """
57
- if not isinstance(prop, dict):
58
- return False
59
- if "$ref" in prop:
60
- return True
61
- for combinator in _COMBINATORS:
62
- items = prop.get(combinator)
63
- if isinstance(items, list):
64
- if any(isinstance(item, dict) and "$ref" in item for item in items):
65
- return True
66
- return False
67
-
68
-
69
- def wrap_ref_siblings(prop, prop_name: str = "") -> "tuple":
70
- """
71
- Rewrite a single property definition so sibling annotations survive
72
- draft-04 ``$ref`` resolution.
73
-
74
- Returns a ``(new_prop, action)`` tuple. The transformation only fires
75
- when the property dict has a top-level ``$ref`` alongside at least one
76
- other key; in that case the siblings keep their original order and the
77
- ``$ref`` moves into an ``allOf`` list appended last. All other shapes
78
- are returned unchanged:
79
-
80
- - not a dict, or no ``$ref`` anywhere -> ``NO_REF``
81
- - ``$ref`` only inside allOf/anyOf/oneOf -> ``ALREADY_WRAPPED`` (idempotent)
82
- - ``{"$ref": ...}`` with no siblings -> ``BARE_REF`` (nothing is lost)
83
- """
84
- if not isinstance(prop, dict) or not has_ref(prop):
85
- return prop, NO_REF
86
-
87
- if "$ref" not in prop:
88
- return prop, ALREADY_WRAPPED
89
-
90
- if len(prop) == 1:
91
- return prop, BARE_REF
92
-
93
- new_prop = {k: v for k, v in prop.items() if k != "$ref"}
94
- if isinstance(new_prop.get("allOf"), list):
95
- # Rare edge: the property already has an allOf next to the $ref.
96
- # Append rather than overwrite so existing constraints are kept.
97
- new_prop["allOf"] = new_prop["allOf"] + [{"$ref": prop["$ref"]}]
98
- else:
99
- new_prop["allOf"] = [{"$ref": prop["$ref"]}]
100
- logger.debug(f"Wrapped $ref of property '{prop_name}' in allOf.")
101
- return new_prop, WRAPPED
102
-
103
-
104
- def fix_schema(schema: dict) -> "tuple":
105
- """
106
- Apply ``wrap_ref_siblings`` to every property of a node schema.
107
-
108
- Only the direct values of ``schema["properties"]`` are examined:
109
- sibling-``$ref`` annotations occur at the property level in Gen3 node
110
- schemas, and a shallow walk cannot accidentally rewrite the Gen3
111
- properties-merge construct (a ``$ref`` *key* directly inside the
112
- ``properties`` block), which is recorded as ``PROPERTIES_MERGE`` and
113
- left untouched.
114
-
115
- Returns ``(new_schema, changes)`` where changes is a list of
116
- ``(prop_name, action, preserved_keys)`` tuples. ``preserved_keys`` names
117
- the sibling annotation keys kept for ``WRAPPED`` entries and is an empty
118
- tuple otherwise.
119
- """
120
- changes = []
121
- props = schema.get("properties") if isinstance(schema, dict) else None
122
- if not isinstance(props, dict):
123
- return schema, changes
124
-
125
- new_props = {}
126
- for prop_name, prop in props.items():
127
- if prop_name == "$ref":
128
- new_props[prop_name] = prop
129
- changes.append((prop_name, PROPERTIES_MERGE, ()))
130
- continue
131
- new_prop, action = wrap_ref_siblings(prop, prop_name)
132
- new_props[prop_name] = new_prop
133
- preserved = ()
134
- if action == WRAPPED:
135
- preserved = tuple(k for k in prop.keys() if k != "$ref")
136
- if action != NO_REF:
137
- changes.append((prop_name, action, preserved))
138
-
139
- new_schema = dict(schema)
140
- new_schema["properties"] = new_props
141
- return new_schema, changes
142
-
143
-
144
- def find_null_descriptions(node, path: str = "") -> list:
145
- """
146
- Recursively find every ``description`` key whose value is null.
147
-
148
- The Gen3 metaschema requires ``description`` to be a string, so a
149
- ``description: null`` placeholder (common in generated shared
150
- definitions) is invalid. With a direct ``$ref`` the resolver happens to
151
- mask it — the referencing property's own description merges over the
152
- definition's null — but a bare or allOf-wrapped ref exposes the null in
153
- the resolved node schema, where metaschema validation fails far away
154
- from the definition that caused it.
155
-
156
- Returns a list of dotted paths to each offender, with list items shown
157
- as ``[i]`` (e.g. ``"enum_yes_no.description"``,
158
- ``"properties.status.anyOf[0].description"``). Non-dict/list input
159
- yields an empty list.
160
- """
161
- hits = []
162
- if isinstance(node, dict):
163
- for key, value in node.items():
164
- key_path = f"{path}.{key}" if path else str(key)
165
- if key == "description" and value is None:
166
- hits.append(key_path)
167
- else:
168
- hits.extend(find_null_descriptions(value, key_path))
169
- elif isinstance(node, list):
170
- for i, item in enumerate(node):
171
- hits.extend(find_null_descriptions(item, f"{path}[{i}]"))
172
- return hits
173
-
174
-
175
- def scan_dir_null_descriptions(yaml_dir: str) -> list:
176
- """
177
- Scan every YAML file under ``yaml_dir`` (recursively) for null-valued
178
- ``description`` keys.
179
-
180
- Unlike ``fix_yaml_dir``, underscore files (``_definitions.yaml``,
181
- ``_terms.yaml``, ...) are INCLUDED — shared definitions are where the
182
- null placeholders usually live, and where they do the most damage
183
- because every referencing property inherits them on resolution.
184
-
185
- Returns ``"relpath: dotted.path"`` strings, one per offender.
186
- """
187
- from gen3schemadev.utils import load_yaml
188
-
189
- hits = []
190
- for root, _dirs, files in os.walk(yaml_dir):
191
- for fname in sorted(files):
192
- if not (fnmatch.fnmatch(fname, "*.yaml") or fnmatch.fnmatch(fname, "*.yml")):
193
- continue
194
- path = os.path.join(root, fname)
195
- rel_path = os.path.relpath(path, yaml_dir)
196
- schema = load_yaml(path)
197
- for hit in find_null_descriptions(schema):
198
- hits.append(f"{rel_path}: {hit}")
199
- return hits
200
-
201
-
202
- def fix_yaml_dir(yaml_dir: str, dry_run: bool = False) -> list:
203
- """
204
- Rewrite every YAML node schema under ``yaml_dir`` (recursively) so that
205
- ``$ref`` sibling annotations survive draft-04 resolution.
206
-
207
- Files whose basename starts with an underscore (``_definitions.yaml``,
208
- ``_terms.yaml``, ``_settings.yaml``) are skipped entirely: adding
209
- structure or descriptions to shared definitions would force them onto
210
- every referencing property. Files with no changes are never rewritten,
211
- which keeps the operation idempotent. With ``dry_run=True`` nothing is
212
- written at all.
213
-
214
- Returns a list of per-file report dicts::
215
-
216
- {"path": <relative path>, "skipped_file": <reason or None>,
217
- "changes": [(prop_name, action, preserved_keys), ...],
218
- "rewritten": <bool>}
219
- """
220
- from gen3schemadev.utils import load_yaml, write_yaml
221
-
222
- reports = []
223
- for root, _dirs, files in os.walk(yaml_dir):
224
- for fname in sorted(files):
225
- if not (fnmatch.fnmatch(fname, "*.yaml") or fnmatch.fnmatch(fname, "*.yml")):
226
- continue
227
- path = os.path.join(root, fname)
228
- rel_path = os.path.relpath(path, yaml_dir)
229
-
230
- if fname.startswith("_"):
231
- reports.append({
232
- "path": rel_path,
233
- "skipped_file": "definitions/terms/settings file",
234
- "changes": [],
235
- "rewritten": False,
236
- })
237
- continue
238
-
239
- schema = load_yaml(path)
240
- if not isinstance(schema, dict):
241
- reports.append({
242
- "path": rel_path,
243
- "skipped_file": "not a mapping, no properties to fix",
244
- "changes": [],
245
- "rewritten": False,
246
- })
247
- continue
248
-
249
- new_schema, changes = fix_schema(schema)
250
- wrapped = [c for c in changes if c[1] == WRAPPED]
251
- rewritten = False
252
- if wrapped and not dry_run:
253
- write_yaml(new_schema, path)
254
- rewritten = True
255
-
256
- reports.append({
257
- "path": rel_path,
258
- "skipped_file": None,
259
- "changes": changes,
260
- "rewritten": rewritten,
261
- })
262
- return reports
File without changes