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/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
+ ))