emdbva 0.0.1.dev143__tar.gz → 0.0.1.dev145__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 (55) hide show
  1. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/MANIFEST.in +1 -0
  2. {emdbva-0.0.1.dev143/emdbva.egg-info → emdbva-0.0.1.dev145}/PKG-INFO +1 -1
  3. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145/emdbva.egg-info}/PKG-INFO +1 -1
  4. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/emdbva.egg-info/SOURCES.txt +5 -0
  5. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/setup.py +3 -0
  6. emdbva-0.0.1.dev145/va/audit/__init__.py +13 -0
  7. emdbva-0.0.1.dev145/va/audit/cif_revision.py +324 -0
  8. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/mainva.py +13 -0
  9. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/preparation.py +14 -13
  10. emdbva-0.0.1.dev145/va/schemas/__init__.py +1 -0
  11. emdbva-0.0.1.dev145/va/schemas/emdb_entry_full.schema.json +2931 -0
  12. emdbva-0.0.1.dev145/va/schemas/loader.py +54 -0
  13. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/version.py +1 -1
  14. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/LICENSE +0 -0
  15. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/README.rst +0 -0
  16. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/emdbva.egg-info/dependency_links.txt +0 -0
  17. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/emdbva.egg-info/entry_points.txt +0 -0
  18. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/emdbva.egg-info/requires.txt +0 -0
  19. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/emdbva.egg-info/top_level.txt +0 -0
  20. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/setup.cfg +0 -0
  21. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/PATHS_codon_airflow.py +0 -0
  22. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/__init__.py +0 -0
  23. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/__init__.py +0 -0
  24. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/bars.py +0 -0
  25. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/connected_percentage.py +0 -0
  26. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/contour_level_predicator.py +0 -0
  27. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/emda_mmcc.py +0 -0
  28. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/emringer.py +0 -0
  29. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/inclusion.py +0 -0
  30. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/map_data_validation.py +0 -0
  31. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/overlap_percentage.py +0 -0
  32. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/phaserandomization.py +0 -0
  33. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/phenix_cc.py +0 -0
  34. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/phenix_mm.py +0 -0
  35. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/projections.py +0 -0
  36. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/qscore.py +0 -0
  37. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/residue_locres.py +0 -0
  38. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/resmap.py +0 -0
  39. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/smoc.py +0 -0
  40. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/strudel.py +0 -0
  41. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/surfaces.py +0 -0
  42. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/metrics/threedfsc.py +0 -0
  43. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/prepareandrun_codon_airflow.py +0 -0
  44. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/qscores.csv +0 -0
  45. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/Checker.py +0 -0
  46. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/ChimeraxViews.py +0 -0
  47. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/MapProcessor.py +0 -0
  48. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/Model.py +0 -0
  49. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/__init__.py +0 -0
  50. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/cl_weights.pth +0 -0
  51. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/log_utils.py +0 -0
  52. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/misc.py +0 -0
  53. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/rescolor.py +0 -0
  54. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/utils/stars.py +0 -0
  55. {emdbva-0.0.1.dev143 → emdbva-0.0.1.dev145}/va/validationanalysis.py +0 -0
@@ -1,5 +1,6 @@
1
1
  include va/qscores.csv
2
2
  include va/utils/cl_weights.pth
3
+ recursive-include va/schemas *.json
3
4
  exclude va/symmetry.py
4
5
  exclude va/onedepva.py
5
6
  exclude va/PATHS.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: emdbva
3
- Version: 0.0.1.dev143
3
+ Version: 0.0.1.dev145
4
4
  Summary: CryoEM validation toolkit
5
5
  Home-page: https://test.pypi.org/project/va/
6
6
  Author: Zhe Wang
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: emdbva
3
- Version: 0.0.1.dev143
3
+ Version: 0.0.1.dev145
4
4
  Summary: CryoEM validation toolkit
5
5
  Home-page: https://test.pypi.org/project/va/
6
6
  Author: Zhe Wang
@@ -16,6 +16,8 @@ va/prepareandrun_codon_airflow.py
16
16
  va/qscores.csv
17
17
  va/validationanalysis.py
18
18
  va/version.py
19
+ va/audit/__init__.py
20
+ va/audit/cif_revision.py
19
21
  va/metrics/__init__.py
20
22
  va/metrics/bars.py
21
23
  va/metrics/connected_percentage.py
@@ -36,6 +38,9 @@ va/metrics/smoc.py
36
38
  va/metrics/strudel.py
37
39
  va/metrics/surfaces.py
38
40
  va/metrics/threedfsc.py
41
+ va/schemas/__init__.py
42
+ va/schemas/emdb_entry_full.schema.json
43
+ va/schemas/loader.py
39
44
  va/utils/Checker.py
40
45
  va/utils/ChimeraxViews.py
41
46
  va/utils/MapProcessor.py
@@ -19,6 +19,9 @@ setup(
19
19
  name="emdbva",
20
20
  version=__version__,
21
21
  packages=find_packages(),
22
+ package_data={
23
+ 'va.schemas': ['*.json'],
24
+ },
22
25
  exclude_package_data={
23
26
  'va': ['test/*', 'core/*', 'proc/*', 'validation/*', 'test_data/*'],
24
27
  },
@@ -0,0 +1,13 @@
1
+ """Audit helpers used to make conservative VA execution decisions."""
2
+
3
+ from va.audit.cif_revision import (
4
+ audit_model_cifs_for_preparation,
5
+ should_run_va_for_cif,
6
+ write_cif_revision_audit_report,
7
+ )
8
+
9
+ __all__ = [
10
+ 'audit_model_cifs_for_preparation',
11
+ 'should_run_va_for_cif',
12
+ 'write_cif_revision_audit_report',
13
+ ]
@@ -0,0 +1,324 @@
1
+ """Gemmi-based audit revision checks for model CIF files."""
2
+
3
+ import os
4
+ import re
5
+ import json
6
+
7
+ import gemmi
8
+
9
+
10
+ _CATEGORY = '_pdbx_audit_revision_details'
11
+ _FIELDS = (
12
+ 'ordinal',
13
+ 'revision_ordinal',
14
+ 'data_content_type',
15
+ 'provider',
16
+ 'type',
17
+ 'description',
18
+ 'details',
19
+ )
20
+
21
+ DEFAULT_SKIP_RULES = [
22
+ {
23
+ 'type': 'Remediation',
24
+ 'description': 'Metalloprotein remediation',
25
+ 'decision': 'skip',
26
+ 'reason_code': 'metalloprotein_remediation',
27
+ },
28
+ {
29
+ 'type': 'Remediation',
30
+ 'description': 'Carbohydrate remediation',
31
+ 'decision': 'skip',
32
+ 'reason_code': 'carbohydrate_remediation',
33
+ },
34
+ ]
35
+
36
+
37
+ def _normalise_value(value):
38
+ """Normalize one Gemmi CIF value for display and comparison."""
39
+ if value is None:
40
+ return None
41
+ value = str(value).strip()
42
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
43
+ value = value[1:-1].strip()
44
+ value = re.sub(r'\s+', ' ', value)
45
+ if value in ('?', '.') or not value:
46
+ return None
47
+ return value
48
+
49
+
50
+ def _normalise_for_match(value):
51
+ """Return a normalized case-insensitive comparison value."""
52
+ value = _normalise_value(value)
53
+ return value.casefold() if value is not None else None
54
+
55
+
56
+ def _first_block(document):
57
+ """Return the first data block across supported Gemmi versions."""
58
+ try:
59
+ return document[0]
60
+ except (TypeError, IndexError):
61
+ return document.sole_block()
62
+
63
+
64
+ def _loop_from_block(block):
65
+ """Return the audit loop or ``None`` when the category is not looped."""
66
+ for field in _FIELDS:
67
+ try:
68
+ column = block.find_loop(f'{_CATEGORY}.{field}')
69
+ except Exception:
70
+ column = None
71
+ if column is not None:
72
+ return column.get_loop() if hasattr(column, 'get_loop') else column
73
+ return None
74
+
75
+
76
+ def _rows_from_loop(loop):
77
+ """Convert a Gemmi loop into normalized row dictionaries."""
78
+ tags = [str(tag) for tag in getattr(loop, 'tags', [])]
79
+ values = list(getattr(loop, 'values', []))
80
+ if not tags:
81
+ return []
82
+
83
+ width = len(tags)
84
+ rows = []
85
+ for offset in range(0, len(values), width):
86
+ row = {}
87
+ for field in _FIELDS:
88
+ tag = f'{_CATEGORY}.{field}'
89
+ try:
90
+ index = tags.index(tag)
91
+ except ValueError:
92
+ row[field] = None
93
+ continue
94
+ value_index = offset + index
95
+ row[field] = _normalise_value(values[value_index]) if value_index < len(values) else None
96
+ rows.append(row)
97
+ return rows
98
+
99
+
100
+ def _rows_from_scalars(block):
101
+ """Read a single audit row when the category is not represented as a loop."""
102
+ row = {}
103
+ found = False
104
+ for field in _FIELDS:
105
+ try:
106
+ value = block.find_value(f'{_CATEGORY}.{field}')
107
+ except Exception:
108
+ value = None
109
+ row[field] = _normalise_value(value)
110
+ found = found or row[field] is not None
111
+ return [row] if found else []
112
+
113
+
114
+ def _issue(code, message, path):
115
+ """Build a non-fatal audit issue."""
116
+ return {
117
+ 'code': code,
118
+ 'severity': 'warning',
119
+ 'path': path,
120
+ 'message': message,
121
+ }
122
+
123
+
124
+ def _rule_matches(row, rule):
125
+ """Return whether a rule exactly matches the relevant row fields."""
126
+ return (
127
+ _normalise_for_match(row.get('type')) == _normalise_for_match(rule.get('type'))
128
+ and _normalise_for_match(row.get('description')) == _normalise_for_match(rule.get('description'))
129
+ )
130
+
131
+
132
+ def _base_decision(rows, issues):
133
+ """Return the common report structure for parsed audit rows."""
134
+ return {
135
+ 'run_validation': True,
136
+ 'decision': 'run',
137
+ 'reason_code': 'unmatched_revision',
138
+ 'message': 'No known VA-skip audit rule matched; VA will run.',
139
+ 'matched_rules': [],
140
+ 'issues': issues,
141
+ 'audit_revision_details': {
142
+ 'row_count': len(rows),
143
+ 'rows': rows,
144
+ },
145
+ }
146
+
147
+
148
+ def should_run_va_for_cif(cif_path, rules=None):
149
+ """Return a conservative VA execution decision for one model CIF.
150
+
151
+ Args:
152
+ cif_path (str): Path to a model CIF/mmCIF file.
153
+ rules (list or None): Optional replacement list of skip-rule mappings.
154
+
155
+ Returns:
156
+ dict: A stable run/skip decision with parsed audit rows and warnings.
157
+ """
158
+ if not cif_path or not os.path.isfile(cif_path):
159
+ return {
160
+ 'run_validation': True,
161
+ 'decision': 'run',
162
+ 'reason_code': 'missing_cif',
163
+ 'message': 'Model CIF file is missing; VA will run without audit gating.',
164
+ 'matched_rules': [],
165
+ 'issues': [_issue('missing_cif', f'CIF file does not exist: {cif_path}', str(cif_path))],
166
+ 'audit_revision_details': {'row_count': 0, 'rows': []},
167
+ }
168
+
169
+ try:
170
+ document = gemmi.cif.read_file(cif_path)
171
+ block = _first_block(document)
172
+ loop = _loop_from_block(block)
173
+ rows = _rows_from_loop(loop) if loop is not None else _rows_from_scalars(block)
174
+ except Exception as exc:
175
+ return {
176
+ 'run_validation': True,
177
+ 'decision': 'run',
178
+ 'reason_code': 'parse_error',
179
+ 'message': 'CIF audit data could not be parsed; VA will run.',
180
+ 'matched_rules': [],
181
+ 'issues': [_issue('parse_error', str(exc), str(cif_path))],
182
+ 'audit_revision_details': {'row_count': 0, 'rows': []},
183
+ }
184
+
185
+ if not rows:
186
+ return {
187
+ 'run_validation': True,
188
+ 'decision': 'run',
189
+ 'reason_code': 'missing_audit_category',
190
+ 'message': 'No audit revision details were found; VA will run.',
191
+ 'matched_rules': [],
192
+ 'issues': [_issue(
193
+ 'missing_audit_category',
194
+ f'{_CATEGORY} is absent or has no rows.',
195
+ _CATEGORY,
196
+ )],
197
+ 'audit_revision_details': {'row_count': 0, 'rows': []},
198
+ }
199
+
200
+ report = _base_decision(rows, [])
201
+ active_rules = DEFAULT_SKIP_RULES if rules is None else rules
202
+ matched_rules = []
203
+ matched_row_indexes = set()
204
+ for index, row in enumerate(rows):
205
+ for rule in active_rules:
206
+ if _rule_matches(row, rule):
207
+ matched_rules.append(dict(rule))
208
+ matched_row_indexes.add(index)
209
+
210
+ initial_type = _normalise_for_match('Initial release')
211
+ unrelated_rows = [
212
+ row for index, row in enumerate(rows)
213
+ if index not in matched_row_indexes
214
+ and _normalise_for_match(row.get('type')) not in (None, initial_type)
215
+ ]
216
+
217
+ if matched_rules and not unrelated_rows:
218
+ report.update({
219
+ 'run_validation': False,
220
+ 'decision': 'skip',
221
+ 'reason_code': 'known_remediation_skip',
222
+ 'message': 'A known remediation-only audit rule matched; VA validation will be skipped.',
223
+ 'matched_rules': matched_rules,
224
+ })
225
+ return report
226
+
227
+ if len(rows) == 1 and _normalise_for_match(rows[0].get('type')) == initial_type:
228
+ report.update({
229
+ 'reason_code': 'initial_release_only',
230
+ 'message': 'Only an initial release audit revision was found; VA will run.',
231
+ })
232
+ return report
233
+
234
+
235
+ def _model_cif_paths(prepobj):
236
+ """Resolve model CIF paths using the locations used by ``read_model``."""
237
+ model_names = getattr(prepobj, 'model', None) or []
238
+ if isinstance(model_names, str):
239
+ model_names = [model_names]
240
+
241
+ paths = []
242
+ for model_name in model_names:
243
+ if not model_name:
244
+ continue
245
+ if os.path.isabs(model_name):
246
+ paths.append(model_name)
247
+ continue
248
+ working_path = os.path.join(prepobj.vadir, model_name)
249
+ paths.append(working_path if os.path.isfile(working_path) else model_name)
250
+ return paths
251
+
252
+
253
+ def _aggregate_model_reports(model_paths, model_reports):
254
+ """Aggregate per-model decisions using the conservative all-skip policy."""
255
+ all_skip = bool(model_reports) and all(
256
+ report.get('decision') == 'skip' for report in model_reports
257
+ )
258
+ issues = [issue for report in model_reports for issue in report.get('issues', [])]
259
+ matched_rules = [rule for report in model_reports for rule in report.get('matched_rules', [])]
260
+ rows = [
261
+ row for report in model_reports
262
+ for row in report.get('audit_revision_details', {}).get('rows', [])
263
+ ]
264
+ return {
265
+ 'run_validation': not all_skip,
266
+ 'decision': 'skip' if all_skip else 'run',
267
+ 'reason_code': 'known_remediation_skip' if all_skip else 'model_audit_reviewed',
268
+ 'message': (
269
+ 'All model CIFs matched known remediation rules; VA validation will be skipped.'
270
+ if all_skip else 'At least one model CIF did not clearly match a skip rule; VA will run.'
271
+ ),
272
+ 'matched_rules': matched_rules,
273
+ 'issues': issues,
274
+ 'audit_revision_details': {'row_count': len(rows), 'rows': rows},
275
+ 'model_count': len(model_paths),
276
+ 'model_reports': model_reports,
277
+ }
278
+
279
+
280
+ def audit_model_cifs_for_preparation(prepobj):
281
+ """Audit all configured model CIFs for a prepared VA entry.
282
+
283
+ Args:
284
+ prepobj: Prepared VA input object with ``model`` and ``vadir`` attributes.
285
+
286
+ Returns:
287
+ dict: Aggregate audit decision and per-model reports. Missing or
288
+ malformed CIF data produces a run decision with warnings.
289
+ """
290
+ model_paths = _model_cif_paths(prepobj)
291
+ if not model_paths:
292
+ return {
293
+ 'run_validation': True,
294
+ 'decision': 'run',
295
+ 'reason_code': 'no_model',
296
+ 'message': 'No model was supplied; CIF audit gating was not applied.',
297
+ 'matched_rules': [],
298
+ 'issues': [],
299
+ 'audit_revision_details': {'row_count': 0, 'rows': []},
300
+ 'model_count': 0,
301
+ 'model_reports': [],
302
+ }
303
+
304
+ model_reports = [should_run_va_for_cif(path) for path in model_paths]
305
+ return _aggregate_model_reports(model_paths, model_reports)
306
+
307
+
308
+ def write_cif_revision_audit_report(prepobj, report):
309
+ """Write the audit fragment consumed by the existing JSON merger.
310
+
311
+ Args:
312
+ prepobj: Prepared VA input object with ``vadir`` and ``mapname``.
313
+ report (dict): Aggregate CIF audit decision.
314
+
315
+ Returns:
316
+ str: Path to the written audit report fragment.
317
+ """
318
+ report_path = os.path.join(
319
+ prepobj.vadir,
320
+ '{}_cif_revision_audit.json'.format(os.path.basename(prepobj.mapname)),
321
+ )
322
+ with open(report_path, 'w', encoding='utf-8') as output:
323
+ json.dump({'audit_validation': {'cif_revision_audit': report}}, output, indent=2)
324
+ return report_path
@@ -40,6 +40,10 @@ from va.validationanalysis import ValidationAnalysis
40
40
  from va.preparation import PreParation as prep
41
41
  from va.metrics.strudel import run_strudel
42
42
  from va.utils.log_utils import LogRedirector
43
+ from va.audit.cif_revision import (
44
+ audit_model_cifs_for_preparation,
45
+ write_cif_revision_audit_report,
46
+ )
43
47
  import threading
44
48
  try:
45
49
  from PATHS import MAP_SERVER_PATH
@@ -335,6 +339,7 @@ def reconfigure_logging(temp_logger, buffer, log_file):
335
339
 
336
340
  temp_logger.info(f"Logging reconfigured to use file: {log_file}")
337
341
 
342
+
338
343
  # @profile
339
344
  def main():
340
345
  """Run the main VA command-line entry point.
@@ -348,6 +353,14 @@ def main():
348
353
  # Preparation
349
354
  # temp_logger, buffer = setup_temporary_logging()
350
355
  prepobj = prep()
356
+
357
+ if not prepobj.args.positions:
358
+ audit_report = audit_model_cifs_for_preparation(prepobj)
359
+ write_cif_revision_audit_report(prepobj, audit_report)
360
+ if not audit_report['run_validation']:
361
+ print(audit_report['message'])
362
+ return None
363
+
351
364
  # path_to_logs = f'{prepobj.vadir}/{prepobj.mapname}_va.log'
352
365
  # reconfigure_logging(temp_logger, buffer, path_to_logs)
353
366
  position = prepobj.args.positions
@@ -53,6 +53,7 @@ from va.version import __version__
53
53
  from va.utils.misc import out_json, create_symbolic_link, keep_three_significant_digits
54
54
  from va.utils.MapProcessor import MapProcessor
55
55
  from memory_profiler import profile
56
+ from va.schemas.loader import SchemaLoadError, load_json_schema, schema_candidate_paths, schema_file_path
56
57
 
57
58
  # JSON Schema (optional dependency; code will still run if missing)
58
59
  try:
@@ -314,22 +315,22 @@ class PreParation:
314
315
  copyfile(source, outpath)
315
316
  print(f'Wrote input example to {outpath}')
316
317
 
318
+ def _schema_file_paths(self):
319
+ """Return schema paths in package-first, source-tree-fallback order."""
320
+ return schema_candidate_paths()
321
+
317
322
  def _schema_file_path(self):
318
- """Return the on-disk path to docs/emdb_entry_full.schema.json."""
319
- # preparation.py lives under va/; the schema is stored in sibling docs/.
320
- return os.path.join(os.path.dirname(os.path.dirname(__file__)), 'docs', 'emdb_entry_full.schema.json')
323
+ """Return the first available output-schema path."""
324
+ return schema_file_path()
321
325
 
322
326
  def _load_json_schema(self):
323
- """Load a JSON Schema from docs/emdb_entry_full.schema.json. Returns dict or None."""
324
- path = self._schema_file_path()
325
- if not os.path.isfile(path):
326
- sys.stderr.write(f'[validation] Schema file not found: {path}\n')
327
- return None
327
+ """Load the packaged output schema, falling back to the source-tree copy."""
328
+ self._schema_load_error = None
328
329
  try:
329
- with open(path, 'r', encoding='utf-8') as f:
330
- return json.load(f)
331
- except Exception as e:
332
- sys.stderr.write(f'[validation] Could not load schema "{path}": {e}\n')
330
+ return load_json_schema()
331
+ except SchemaLoadError as exc:
332
+ self._schema_load_error = str(exc)
333
+ sys.stderr.write(self._schema_load_error + '\n')
333
334
  return None
334
335
 
335
336
  @staticmethod
@@ -373,7 +374,7 @@ class PreParation:
373
374
  """
374
375
  schema = self._load_json_schema()
375
376
  if schema is None:
376
- return False, ['Schema file not found or unreadable.']
377
+ return False, [getattr(self, '_schema_load_error', 'Schema file not found or unreadable.')]
377
378
 
378
379
  Validator = self._choose_validator(schema)
379
380
  if Validator is None:
@@ -0,0 +1 @@
1
+ """Packaged JSON schemas used by VA at runtime."""