consync 0.1.0__py3-none-any.whl
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.
- consync/__init__.py +9 -0
- consync/backup.py +188 -0
- consync/cli.py +372 -0
- consync/config.py +200 -0
- consync/hooks.py +81 -0
- consync/lock.py +118 -0
- consync/logging_config.py +273 -0
- consync/models.py +104 -0
- consync/parsers/__init__.py +40 -0
- consync/parsers/c_header.py +96 -0
- consync/parsers/csv_parser.py +133 -0
- consync/parsers/json_parser.py +138 -0
- consync/parsers/toml_parser.py +74 -0
- consync/parsers/xlsx.py +116 -0
- consync/precision.py +148 -0
- consync/renderers/__init__.py +49 -0
- consync/renderers/c_header.py +222 -0
- consync/renderers/csharp.py +174 -0
- consync/renderers/csv_renderer.py +46 -0
- consync/renderers/json_renderer.py +71 -0
- consync/renderers/python_const.py +84 -0
- consync/renderers/rust_const.py +90 -0
- consync/renderers/verilog.py +89 -0
- consync/renderers/vhdl.py +94 -0
- consync/state.py +76 -0
- consync/sync.py +458 -0
- consync/validators.py +233 -0
- consync/watcher.py +176 -0
- consync-0.1.0.dist-info/METADATA +590 -0
- consync-0.1.0.dist-info/RECORD +33 -0
- consync-0.1.0.dist-info/WHEEL +4 -0
- consync-0.1.0.dist-info/entry_points.txt +2 -0
- consync-0.1.0.dist-info/licenses/LICENSE +21 -0
consync/sync.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Sync engine — the core logic that ties parsers, renderers, and state together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from consync.backup import backup_file
|
|
11
|
+
from consync.config import load_config
|
|
12
|
+
from consync.lock import SyncLock, LockError
|
|
13
|
+
from consync.logging_config import write_audit_entry
|
|
14
|
+
from consync.models import ConsyncConfig, MappingConfig, SyncDirection
|
|
15
|
+
from consync.parsers import get_parser
|
|
16
|
+
from consync.renderers import get_renderer
|
|
17
|
+
from consync.state import SyncState, compute_hash
|
|
18
|
+
from consync.validators import parse_validators, validate_constants
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SyncResult(Enum):
|
|
24
|
+
"""Outcome of a sync operation."""
|
|
25
|
+
SYNCED_SOURCE_TO_TARGET = "source → target"
|
|
26
|
+
SYNCED_TARGET_TO_SOURCE = "target → source"
|
|
27
|
+
ALREADY_IN_SYNC = "already in sync"
|
|
28
|
+
CONFLICT = "conflict"
|
|
29
|
+
SKIPPED = "skipped"
|
|
30
|
+
ERROR = "error"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SyncReport:
|
|
35
|
+
"""Result of syncing one mapping."""
|
|
36
|
+
source: str
|
|
37
|
+
target: str
|
|
38
|
+
result: SyncResult
|
|
39
|
+
count: int = 0 # number of constants synced
|
|
40
|
+
message: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sync(
|
|
44
|
+
config_path: str | Path | None = None,
|
|
45
|
+
force_direction: str | None = None,
|
|
46
|
+
dry_run: bool = False,
|
|
47
|
+
) -> list[SyncReport]:
|
|
48
|
+
"""Run sync for all mappings in config.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
config_path: Path to .consync.yaml (auto-find if None).
|
|
52
|
+
force_direction: Override direction ("source" or "target").
|
|
53
|
+
dry_run: If True, report what would happen without writing files.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of SyncReport for each mapping.
|
|
57
|
+
"""
|
|
58
|
+
cfg = load_config(config_path)
|
|
59
|
+
config_dir = _config_dir(config_path)
|
|
60
|
+
state = SyncState(config_dir / cfg.state_file)
|
|
61
|
+
|
|
62
|
+
reports: list[SyncReport] = []
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with SyncLock(config_dir):
|
|
66
|
+
for mapping in cfg.mappings:
|
|
67
|
+
report = _sync_one(mapping, config_dir, state, cfg, force_direction, dry_run)
|
|
68
|
+
reports.append(report)
|
|
69
|
+
except LockError as e:
|
|
70
|
+
logger.error("Lock conflict: %s", e)
|
|
71
|
+
reports.append(SyncReport(
|
|
72
|
+
source="*", target="*",
|
|
73
|
+
result=SyncResult.ERROR,
|
|
74
|
+
message=str(e),
|
|
75
|
+
))
|
|
76
|
+
|
|
77
|
+
return reports
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check(config_path: str | Path | None = None) -> list[SyncReport]:
|
|
81
|
+
"""Verify all mappings are in sync (CI mode).
|
|
82
|
+
|
|
83
|
+
Returns reports — any with result != ALREADY_IN_SYNC means out-of-sync.
|
|
84
|
+
Does NOT write any files.
|
|
85
|
+
"""
|
|
86
|
+
cfg = load_config(config_path)
|
|
87
|
+
config_dir = _config_dir(config_path)
|
|
88
|
+
|
|
89
|
+
reports: list[SyncReport] = []
|
|
90
|
+
for mapping in cfg.mappings:
|
|
91
|
+
report = _check_one(mapping, config_dir)
|
|
92
|
+
reports.append(report)
|
|
93
|
+
|
|
94
|
+
return reports
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _config_dir(config_path: str | Path | None) -> Path:
|
|
98
|
+
"""Resolve the directory containing the config file."""
|
|
99
|
+
if config_path is None:
|
|
100
|
+
from consync.config import find_config
|
|
101
|
+
found = find_config()
|
|
102
|
+
if found:
|
|
103
|
+
return found.parent
|
|
104
|
+
return Path.cwd()
|
|
105
|
+
return Path(config_path).parent
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_path(filepath: str, config_dir: Path) -> Path:
|
|
109
|
+
"""Resolve a path relative to config directory."""
|
|
110
|
+
p = Path(filepath)
|
|
111
|
+
if p.is_absolute():
|
|
112
|
+
return p
|
|
113
|
+
return (config_dir / p).resolve()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _sync_one(
|
|
117
|
+
mapping: MappingConfig,
|
|
118
|
+
config_dir: Path,
|
|
119
|
+
state: SyncState,
|
|
120
|
+
cfg: ConsyncConfig,
|
|
121
|
+
force_direction: str | None,
|
|
122
|
+
dry_run: bool,
|
|
123
|
+
) -> SyncReport:
|
|
124
|
+
"""Sync a single mapping."""
|
|
125
|
+
source_path = _resolve_path(mapping.source, config_dir)
|
|
126
|
+
target_path = _resolve_path(mapping.target, config_dir)
|
|
127
|
+
key = state.mapping_key(mapping.source, mapping.target)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Determine direction
|
|
131
|
+
direction = _determine_direction(
|
|
132
|
+
mapping, source_path, target_path, state, key, cfg.on_conflict, force_direction
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if direction is None:
|
|
136
|
+
return SyncReport(
|
|
137
|
+
source=mapping.source, target=mapping.target,
|
|
138
|
+
result=SyncResult.ALREADY_IN_SYNC,
|
|
139
|
+
message="Files are already in sync.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if direction == "conflict":
|
|
143
|
+
return SyncReport(
|
|
144
|
+
source=mapping.source, target=mapping.target,
|
|
145
|
+
result=SyncResult.CONFLICT,
|
|
146
|
+
message="Both files changed. Use --from source or --from target to resolve.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if dry_run:
|
|
150
|
+
result = (SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
151
|
+
if direction == "source" else SyncResult.SYNCED_TARGET_TO_SOURCE)
|
|
152
|
+
return SyncReport(
|
|
153
|
+
source=mapping.source, target=mapping.target,
|
|
154
|
+
result=result,
|
|
155
|
+
message=f"[DRY RUN] Would sync {result.value}",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Execute sync (with backup before overwrite)
|
|
159
|
+
if direction == "source":
|
|
160
|
+
constants = _parse_file(source_path, mapping.source_format)
|
|
161
|
+
# Validate before writing
|
|
162
|
+
validation = _validate_if_needed(constants, mapping)
|
|
163
|
+
if validation and not validation.ok:
|
|
164
|
+
return SyncReport(
|
|
165
|
+
source=mapping.source, target=mapping.target,
|
|
166
|
+
result=SyncResult.ERROR,
|
|
167
|
+
message=f"Validation failed: {validation.errors[0].message}"
|
|
168
|
+
+ (f" (+{len(validation.errors)-1} more)" if len(validation.errors) > 1 else ""),
|
|
169
|
+
)
|
|
170
|
+
backup_file(target_path, backup_dir=config_dir / ".consync" / "backups")
|
|
171
|
+
_render_file(constants, target_path, mapping.target_format, mapping)
|
|
172
|
+
result = SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
173
|
+
logger.info(
|
|
174
|
+
"Synced %s → %s (%d constants)",
|
|
175
|
+
mapping.source, mapping.target, len(constants),
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
constants = _parse_file(target_path, mapping.target_format)
|
|
179
|
+
# Validate before writing
|
|
180
|
+
validation = _validate_if_needed(constants, mapping)
|
|
181
|
+
if validation and not validation.ok:
|
|
182
|
+
return SyncReport(
|
|
183
|
+
source=mapping.source, target=mapping.target,
|
|
184
|
+
result=SyncResult.ERROR,
|
|
185
|
+
message=f"Validation failed: {validation.errors[0].message}"
|
|
186
|
+
+ (f" (+{len(validation.errors)-1} more)" if len(validation.errors) > 1 else ""),
|
|
187
|
+
)
|
|
188
|
+
backup_file(source_path, backup_dir=config_dir / ".consync" / "backups")
|
|
189
|
+
_render_file(constants, source_path, mapping.source_format, mapping)
|
|
190
|
+
result = SyncResult.SYNCED_TARGET_TO_SOURCE
|
|
191
|
+
logger.info(
|
|
192
|
+
"Synced %s → %s (%d constants)",
|
|
193
|
+
mapping.target, mapping.source, len(constants),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Log individual constant values at DEBUG level
|
|
197
|
+
for c in constants:
|
|
198
|
+
logger.debug(" %s = %r%s", c.name, c.value, f" ({c.unit})" if c.unit else "")
|
|
199
|
+
|
|
200
|
+
# Write structured audit entry
|
|
201
|
+
write_audit_entry(
|
|
202
|
+
direction=result.value,
|
|
203
|
+
source=mapping.source,
|
|
204
|
+
target=mapping.target,
|
|
205
|
+
constants=constants,
|
|
206
|
+
result="synced",
|
|
207
|
+
dry_run=dry_run,
|
|
208
|
+
audit_file=config_dir / ".consync.audit.jsonl",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Update state with hashes of BOTH files after sync
|
|
212
|
+
src_constants = _parse_file(source_path, mapping.source_format)
|
|
213
|
+
src_hash = compute_hash(src_constants)
|
|
214
|
+
# For target formats without a parser, use source hash (they're equivalent after sync)
|
|
215
|
+
try:
|
|
216
|
+
tgt_constants = _parse_file(target_path, mapping.target_format)
|
|
217
|
+
tgt_hash = compute_hash(tgt_constants)
|
|
218
|
+
except (ValueError, FileNotFoundError):
|
|
219
|
+
tgt_hash = src_hash
|
|
220
|
+
state.set_hash(key, src_hash, tgt_hash)
|
|
221
|
+
|
|
222
|
+
return SyncReport(
|
|
223
|
+
source=mapping.source, target=mapping.target,
|
|
224
|
+
result=result,
|
|
225
|
+
count=len(constants),
|
|
226
|
+
message=f"{len(constants)} constants synced ({result.value})",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.error("Sync failed for %s ↔ %s: %s", mapping.source, mapping.target, e)
|
|
231
|
+
return SyncReport(
|
|
232
|
+
source=mapping.source, target=mapping.target,
|
|
233
|
+
result=SyncResult.ERROR,
|
|
234
|
+
message=str(e),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_one(mapping: MappingConfig, config_dir: Path) -> SyncReport:
|
|
239
|
+
"""Check if a single mapping is in sync."""
|
|
240
|
+
source_path = _resolve_path(mapping.source, config_dir)
|
|
241
|
+
target_path = _resolve_path(mapping.target, config_dir)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
if not source_path.exists():
|
|
245
|
+
return SyncReport(
|
|
246
|
+
source=mapping.source, target=mapping.target,
|
|
247
|
+
result=SyncResult.ERROR,
|
|
248
|
+
message=f"Source file not found: {source_path}",
|
|
249
|
+
)
|
|
250
|
+
if not target_path.exists():
|
|
251
|
+
return SyncReport(
|
|
252
|
+
source=mapping.source, target=mapping.target,
|
|
253
|
+
result=SyncResult.ERROR,
|
|
254
|
+
message=f"Target file not found: {target_path}",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
src_constants = _parse_file(source_path, mapping.source_format)
|
|
258
|
+
tgt_constants = _parse_file(target_path, mapping.target_format)
|
|
259
|
+
|
|
260
|
+
src_hash = compute_hash(src_constants)
|
|
261
|
+
tgt_hash = compute_hash(tgt_constants)
|
|
262
|
+
|
|
263
|
+
if src_hash == tgt_hash:
|
|
264
|
+
return SyncReport(
|
|
265
|
+
source=mapping.source, target=mapping.target,
|
|
266
|
+
result=SyncResult.ALREADY_IN_SYNC,
|
|
267
|
+
count=len(src_constants),
|
|
268
|
+
message=f"In sync ({len(src_constants)} constants).",
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
return SyncReport(
|
|
272
|
+
source=mapping.source, target=mapping.target,
|
|
273
|
+
result=SyncResult.CONFLICT,
|
|
274
|
+
count=len(src_constants),
|
|
275
|
+
message=f"OUT OF SYNC. Source has {len(src_constants)} constants, "
|
|
276
|
+
f"target has {len(tgt_constants)}. Run 'consync sync' to fix.",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
return SyncReport(
|
|
281
|
+
source=mapping.source, target=mapping.target,
|
|
282
|
+
result=SyncResult.ERROR,
|
|
283
|
+
message=str(e),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _determine_direction(
|
|
288
|
+
mapping: MappingConfig,
|
|
289
|
+
source_path: Path,
|
|
290
|
+
target_path: Path,
|
|
291
|
+
state: SyncState,
|
|
292
|
+
key: str,
|
|
293
|
+
on_conflict: str,
|
|
294
|
+
force_direction: str | None,
|
|
295
|
+
) -> str | None:
|
|
296
|
+
"""Determine sync direction based on state hashes and config.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
"source" — sync source → target
|
|
300
|
+
"target" — sync target → source
|
|
301
|
+
"conflict" — both changed, can't auto-resolve
|
|
302
|
+
None — already in sync
|
|
303
|
+
"""
|
|
304
|
+
# Forced direction overrides everything
|
|
305
|
+
if force_direction:
|
|
306
|
+
return "source" if force_direction.lower() in ("source", "xlsx", "s") else "target"
|
|
307
|
+
|
|
308
|
+
# One-way modes: always sync in configured direction
|
|
309
|
+
if mapping.direction == SyncDirection.SOURCE_TO_TARGET:
|
|
310
|
+
if not target_path.exists():
|
|
311
|
+
return "source"
|
|
312
|
+
# Check if source changed since last sync (use state hash)
|
|
313
|
+
src_constants = _parse_file(source_path, mapping.source_format)
|
|
314
|
+
src_hash = compute_hash(src_constants)
|
|
315
|
+
last_src_hash = state.get_hash(key, "source") if state else None
|
|
316
|
+
if last_src_hash and src_hash == last_src_hash:
|
|
317
|
+
return None # Source hasn't changed
|
|
318
|
+
return "source"
|
|
319
|
+
|
|
320
|
+
if mapping.direction == SyncDirection.TARGET_TO_SOURCE:
|
|
321
|
+
if not source_path.exists():
|
|
322
|
+
return "target"
|
|
323
|
+
# Check if target changed since last sync
|
|
324
|
+
try:
|
|
325
|
+
tgt_constants = _parse_file(target_path, mapping.target_format)
|
|
326
|
+
tgt_hash = compute_hash(tgt_constants)
|
|
327
|
+
last_tgt_hash = state.get_hash(key, "target") if state else None
|
|
328
|
+
if last_tgt_hash and tgt_hash == last_tgt_hash:
|
|
329
|
+
return None
|
|
330
|
+
except (ValueError, FileNotFoundError):
|
|
331
|
+
pass
|
|
332
|
+
return "target"
|
|
333
|
+
|
|
334
|
+
# Bidirectional: use state hashes to detect which side changed
|
|
335
|
+
if not source_path.exists():
|
|
336
|
+
return "target"
|
|
337
|
+
if not target_path.exists():
|
|
338
|
+
return "source"
|
|
339
|
+
|
|
340
|
+
src_constants = _parse_file(source_path, mapping.source_format)
|
|
341
|
+
tgt_constants = _parse_file(target_path, mapping.target_format)
|
|
342
|
+
cur_src = compute_hash(src_constants)
|
|
343
|
+
cur_tgt = compute_hash(tgt_constants)
|
|
344
|
+
|
|
345
|
+
prev_src = state.get_hash(key, "source")
|
|
346
|
+
prev_tgt = state.get_hash(key, "target")
|
|
347
|
+
|
|
348
|
+
# No prior state — treat source as truth
|
|
349
|
+
if prev_src is None or prev_tgt is None:
|
|
350
|
+
if cur_src == cur_tgt:
|
|
351
|
+
return None
|
|
352
|
+
return "source"
|
|
353
|
+
|
|
354
|
+
src_changed = cur_src != prev_src
|
|
355
|
+
tgt_changed = cur_tgt != prev_tgt
|
|
356
|
+
|
|
357
|
+
if not src_changed and not tgt_changed:
|
|
358
|
+
return None
|
|
359
|
+
if src_changed and not tgt_changed:
|
|
360
|
+
return "source"
|
|
361
|
+
if tgt_changed and not src_changed:
|
|
362
|
+
return "target"
|
|
363
|
+
|
|
364
|
+
# Both changed — conflict
|
|
365
|
+
if on_conflict == "source_wins":
|
|
366
|
+
return "source"
|
|
367
|
+
elif on_conflict == "target_wins":
|
|
368
|
+
return "target"
|
|
369
|
+
else:
|
|
370
|
+
return "conflict"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_file(filepath: Path, format_name: str) -> list:
|
|
374
|
+
"""Parse a file using the appropriate parser."""
|
|
375
|
+
parser = get_parser(format_name)
|
|
376
|
+
return parser(filepath)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _render_file(constants: list, filepath: Path, format_name: str, mapping: MappingConfig):
|
|
380
|
+
"""Render constants to a file using the appropriate renderer."""
|
|
381
|
+
# Special case: xlsx output needs openpyxl writer (not a simple renderer)
|
|
382
|
+
if format_name == "xlsx":
|
|
383
|
+
_write_xlsx(constants, filepath, mapping)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
renderer = get_renderer(format_name)
|
|
387
|
+
renderer(constants, filepath, config=mapping)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _validate_if_needed(constants: list, mapping: MappingConfig):
|
|
391
|
+
"""Run validation hooks if configured. Returns ValidationResult or None."""
|
|
392
|
+
if not mapping.validators:
|
|
393
|
+
return None
|
|
394
|
+
rules = parse_validators(mapping.validators)
|
|
395
|
+
result = validate_constants(constants, rules)
|
|
396
|
+
if not result.ok:
|
|
397
|
+
for err in result.errors:
|
|
398
|
+
logger.warning("Validation error: %s", err.message)
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _write_xlsx(constants: list, filepath: Path, mapping: MappingConfig):
|
|
403
|
+
"""Write constants back to an Excel file."""
|
|
404
|
+
import openpyxl
|
|
405
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
406
|
+
|
|
407
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
|
|
409
|
+
if filepath.exists():
|
|
410
|
+
wb = openpyxl.load_workbook(filepath)
|
|
411
|
+
ws = wb.active
|
|
412
|
+
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
|
413
|
+
for cell in row:
|
|
414
|
+
cell.value = None
|
|
415
|
+
else:
|
|
416
|
+
wb = openpyxl.Workbook()
|
|
417
|
+
ws = wb.active
|
|
418
|
+
ws.title = "Constants"
|
|
419
|
+
headers = ["Name", "Value", "Unit", "Description"]
|
|
420
|
+
ws.append(headers)
|
|
421
|
+
header_fill = PatternFill("solid", fgColor="1F4E79")
|
|
422
|
+
header_font = Font(bold=True, color="FFFFFF", size=11)
|
|
423
|
+
thin = Side(style="thin", color="CCCCCC")
|
|
424
|
+
for col in range(1, 5):
|
|
425
|
+
cell = ws.cell(row=1, column=col)
|
|
426
|
+
cell.fill = header_fill
|
|
427
|
+
cell.font = header_font
|
|
428
|
+
cell.alignment = Alignment(horizontal="center")
|
|
429
|
+
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
430
|
+
|
|
431
|
+
thin = Side(style="thin", color="CCCCCC")
|
|
432
|
+
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
433
|
+
even_fill = PatternFill("solid", fgColor="D6E4F0")
|
|
434
|
+
value_font = Font(name="Courier New", size=10)
|
|
435
|
+
|
|
436
|
+
for i, c in enumerate(constants, 2):
|
|
437
|
+
ws.cell(row=i, column=1).value = c.name
|
|
438
|
+
ws.cell(row=i, column=2).value = c.value
|
|
439
|
+
ws.cell(row=i, column=3).value = c.unit
|
|
440
|
+
ws.cell(row=i, column=4).value = c.description
|
|
441
|
+
fill = even_fill if i % 2 == 0 else None
|
|
442
|
+
for col in range(1, 5):
|
|
443
|
+
cell = ws.cell(row=i, column=col)
|
|
444
|
+
if fill:
|
|
445
|
+
cell.fill = fill
|
|
446
|
+
cell.border = border
|
|
447
|
+
if col == 2:
|
|
448
|
+
cell.font = value_font
|
|
449
|
+
cell.number_format = "0.00000000000000"
|
|
450
|
+
cell.alignment = Alignment(horizontal="right")
|
|
451
|
+
|
|
452
|
+
ws.column_dimensions["A"].width = 22
|
|
453
|
+
ws.column_dimensions["B"].width = 22
|
|
454
|
+
ws.column_dimensions["C"].width = 10
|
|
455
|
+
ws.column_dimensions["D"].width = 36
|
|
456
|
+
ws.freeze_panes = "A2"
|
|
457
|
+
|
|
458
|
+
wb.save(filepath)
|
consync/validators.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Validation hooks — user-defined checks on constant values.
|
|
2
|
+
|
|
3
|
+
Validators are defined in .consync.yaml:
|
|
4
|
+
|
|
5
|
+
mappings:
|
|
6
|
+
- source: calibration.xlsx
|
|
7
|
+
target: config.h
|
|
8
|
+
validators:
|
|
9
|
+
BRAKE_MAX_PRESSURE:
|
|
10
|
+
min: 0
|
|
11
|
+
max: 300
|
|
12
|
+
TIMEOUT_MS:
|
|
13
|
+
type: int
|
|
14
|
+
min: 100
|
|
15
|
+
max: 60000
|
|
16
|
+
DEVICE_NAME:
|
|
17
|
+
pattern: "^[A-Z]{3}-\\d{4}$"
|
|
18
|
+
LOOKUP_TABLE:
|
|
19
|
+
min_length: 1
|
|
20
|
+
max_length: 256
|
|
21
|
+
|
|
22
|
+
Supported checks:
|
|
23
|
+
- min / max — numeric range (inclusive)
|
|
24
|
+
- type — "int", "float", "string"
|
|
25
|
+
- pattern — regex pattern for string values
|
|
26
|
+
- min_length / max_length — for arrays or strings
|
|
27
|
+
- not_empty — value must not be "" or []
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from consync.models import Constant
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ValidationRule:
|
|
44
|
+
"""A single validation rule for a constant."""
|
|
45
|
+
|
|
46
|
+
name: str # constant name this rule applies to
|
|
47
|
+
min: float | None = None
|
|
48
|
+
max: float | None = None
|
|
49
|
+
type: str | None = None # "int", "float", "string"
|
|
50
|
+
pattern: str | None = None # regex for string values
|
|
51
|
+
min_length: int | None = None
|
|
52
|
+
max_length: int | None = None
|
|
53
|
+
not_empty: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ValidationError:
|
|
58
|
+
"""A validation failure."""
|
|
59
|
+
|
|
60
|
+
constant_name: str
|
|
61
|
+
rule: str
|
|
62
|
+
message: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ValidationResult:
|
|
67
|
+
"""Aggregate result of running all validators."""
|
|
68
|
+
|
|
69
|
+
errors: list[ValidationError] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ok(self) -> bool:
|
|
73
|
+
return len(self.errors) == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_validators(raw: dict[str, dict[str, Any]]) -> list[ValidationRule]:
|
|
77
|
+
"""Parse validator config from YAML mapping.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
raw: Dict of {constant_name: {rule_key: value, ...}}
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of ValidationRule objects.
|
|
84
|
+
"""
|
|
85
|
+
rules = []
|
|
86
|
+
for name, checks in raw.items():
|
|
87
|
+
rule = ValidationRule(
|
|
88
|
+
name=name,
|
|
89
|
+
min=checks.get("min"),
|
|
90
|
+
max=checks.get("max"),
|
|
91
|
+
type=checks.get("type"),
|
|
92
|
+
pattern=checks.get("pattern"),
|
|
93
|
+
min_length=checks.get("min_length"),
|
|
94
|
+
max_length=checks.get("max_length"),
|
|
95
|
+
not_empty=checks.get("not_empty", False),
|
|
96
|
+
)
|
|
97
|
+
rules.append(rule)
|
|
98
|
+
return rules
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_constants(
|
|
102
|
+
constants: list[Constant],
|
|
103
|
+
rules: list[ValidationRule],
|
|
104
|
+
) -> ValidationResult:
|
|
105
|
+
"""Validate a list of constants against rules.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
constants: Parsed constants to validate.
|
|
109
|
+
rules: Validation rules to apply.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
ValidationResult with any errors found.
|
|
113
|
+
"""
|
|
114
|
+
result = ValidationResult()
|
|
115
|
+
|
|
116
|
+
# Build name → constant lookup
|
|
117
|
+
by_name = {c.name: c for c in constants}
|
|
118
|
+
|
|
119
|
+
for rule in rules:
|
|
120
|
+
if rule.name not in by_name:
|
|
121
|
+
# Constant not found — skip (it might be optional)
|
|
122
|
+
logger.debug("Validator: constant '%s' not found in current set, skipping", rule.name)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
constant = by_name[rule.name]
|
|
126
|
+
value = constant.value
|
|
127
|
+
|
|
128
|
+
# Type check
|
|
129
|
+
if rule.type:
|
|
130
|
+
_check_type(constant, rule, result)
|
|
131
|
+
|
|
132
|
+
# Range checks
|
|
133
|
+
if rule.min is not None:
|
|
134
|
+
if isinstance(value, (int, float)):
|
|
135
|
+
if value < rule.min:
|
|
136
|
+
result.errors.append(ValidationError(
|
|
137
|
+
constant_name=rule.name,
|
|
138
|
+
rule="min",
|
|
139
|
+
message=f"{rule.name} = {value} is below minimum {rule.min}",
|
|
140
|
+
))
|
|
141
|
+
elif isinstance(value, list):
|
|
142
|
+
for i, v in enumerate(value):
|
|
143
|
+
if isinstance(v, (int, float)) and v < rule.min:
|
|
144
|
+
result.errors.append(ValidationError(
|
|
145
|
+
constant_name=rule.name,
|
|
146
|
+
rule="min",
|
|
147
|
+
message=f"{rule.name}[{i}] = {v} is below minimum {rule.min}",
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
if rule.max is not None:
|
|
151
|
+
if isinstance(value, (int, float)):
|
|
152
|
+
if value > rule.max:
|
|
153
|
+
result.errors.append(ValidationError(
|
|
154
|
+
constant_name=rule.name,
|
|
155
|
+
rule="max",
|
|
156
|
+
message=f"{rule.name} = {value} exceeds maximum {rule.max}",
|
|
157
|
+
))
|
|
158
|
+
elif isinstance(value, list):
|
|
159
|
+
for i, v in enumerate(value):
|
|
160
|
+
if isinstance(v, (int, float)) and v > rule.max:
|
|
161
|
+
result.errors.append(ValidationError(
|
|
162
|
+
constant_name=rule.name,
|
|
163
|
+
rule="max",
|
|
164
|
+
message=f"{rule.name}[{i}] = {v} exceeds maximum {rule.max}",
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
# Pattern check (string values)
|
|
168
|
+
if rule.pattern:
|
|
169
|
+
if isinstance(value, str):
|
|
170
|
+
if not re.match(rule.pattern, value):
|
|
171
|
+
result.errors.append(ValidationError(
|
|
172
|
+
constant_name=rule.name,
|
|
173
|
+
rule="pattern",
|
|
174
|
+
message=f"{rule.name} = '{value}' does not match pattern '{rule.pattern}'",
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
# Length checks (arrays and strings)
|
|
178
|
+
if rule.min_length is not None:
|
|
179
|
+
length = len(value) if isinstance(value, (list, str)) else None
|
|
180
|
+
if length is not None and length < rule.min_length:
|
|
181
|
+
result.errors.append(ValidationError(
|
|
182
|
+
constant_name=rule.name,
|
|
183
|
+
rule="min_length",
|
|
184
|
+
message=f"{rule.name} has length {length}, minimum is {rule.min_length}",
|
|
185
|
+
))
|
|
186
|
+
|
|
187
|
+
if rule.max_length is not None:
|
|
188
|
+
length = len(value) if isinstance(value, (list, str)) else None
|
|
189
|
+
if length is not None and length > rule.max_length:
|
|
190
|
+
result.errors.append(ValidationError(
|
|
191
|
+
constant_name=rule.name,
|
|
192
|
+
rule="max_length",
|
|
193
|
+
message=f"{rule.name} has length {length}, maximum is {rule.max_length}",
|
|
194
|
+
))
|
|
195
|
+
|
|
196
|
+
# Not-empty check
|
|
197
|
+
if rule.not_empty:
|
|
198
|
+
if value == "" or value == [] or value is None:
|
|
199
|
+
result.errors.append(ValidationError(
|
|
200
|
+
constant_name=rule.name,
|
|
201
|
+
rule="not_empty",
|
|
202
|
+
message=f"{rule.name} must not be empty",
|
|
203
|
+
))
|
|
204
|
+
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_type(constant: Constant, rule: ValidationRule, result: ValidationResult):
|
|
209
|
+
"""Check if the constant's value matches the expected type."""
|
|
210
|
+
value = constant.value
|
|
211
|
+
expected = rule.type.lower() if rule.type else None
|
|
212
|
+
|
|
213
|
+
if expected == "int":
|
|
214
|
+
if not isinstance(value, int):
|
|
215
|
+
result.errors.append(ValidationError(
|
|
216
|
+
constant_name=rule.name,
|
|
217
|
+
rule="type",
|
|
218
|
+
message=f"{rule.name} = {value!r} is not an integer",
|
|
219
|
+
))
|
|
220
|
+
elif expected == "float":
|
|
221
|
+
if not isinstance(value, (int, float)):
|
|
222
|
+
result.errors.append(ValidationError(
|
|
223
|
+
constant_name=rule.name,
|
|
224
|
+
rule="type",
|
|
225
|
+
message=f"{rule.name} = {value!r} is not a float",
|
|
226
|
+
))
|
|
227
|
+
elif expected == "string":
|
|
228
|
+
if not isinstance(value, str):
|
|
229
|
+
result.errors.append(ValidationError(
|
|
230
|
+
constant_name=rule.name,
|
|
231
|
+
rule="type",
|
|
232
|
+
message=f"{rule.name} = {value!r} is not a string",
|
|
233
|
+
))
|