consync 2.3.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {consync-2.3.0 → consync-2.4.0}/PKG-INFO +1 -1
- {consync-2.3.0 → consync-2.4.0}/consync/__init__.py +1 -1
- {consync-2.3.0 → consync-2.4.0}/consync/config.py +16 -0
- {consync-2.3.0 → consync-2.4.0}/consync/models.py +3 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/c_struct_table.py +74 -23
- {consync-2.3.0 → consync-2.4.0}/consync/sync.py +37 -0
- {consync-2.3.0 → consync-2.4.0}/pyproject.toml +1 -1
- {consync-2.3.0 → consync-2.4.0}/tests/test_arrays.py +1 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_audit_sync.py +4 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_c_struct_table.py +120 -1
- {consync-2.3.0 → consync-2.4.0}/tests/test_cli.py +2 -1
- {consync-2.3.0 → consync-2.4.0}/tests/test_comprehensive_sync.py +12 -1
- {consync-2.3.0 → consync-2.4.0}/tests/test_embedded.py +1 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_safety.py +144 -0
- {consync-2.3.0 → consync-2.4.0}/.github/CODEOWNERS +0 -0
- {consync-2.3.0 → consync-2.4.0}/.github/copilot-instructions.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/.github/dependabot.yml +0 -0
- {consync-2.3.0 → consync-2.4.0}/.github/workflows/ci.yml +0 -0
- {consync-2.3.0 → consync-2.4.0}/.github/workflows/codeql.yml +0 -0
- {consync-2.3.0 → consync-2.4.0}/.github/workflows/publish.yml +0 -0
- {consync-2.3.0 → consync-2.4.0}/.github/workflows/release.yml +0 -0
- {consync-2.3.0 → consync-2.4.0}/.gitignore +0 -0
- {consync-2.3.0 → consync-2.4.0}/CLAUDE.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/CONTRIBUTING.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/FAQ.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/LICENSE +0 -0
- {consync-2.3.0 → consync-2.4.0}/README.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/SECURITY.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/TODO.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/assets/demo.gif +0 -0
- {consync-2.3.0 → consync-2.4.0}/assets/demo.tape +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/backup.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/cli.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/hooks.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/lock.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/logging_config.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/__init__.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/c_header.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/c_struct_table.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/csv_parser.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/json_parser.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/toml_parser.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/parsers/xlsx.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/precision.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/__init__.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/c_header.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/csharp.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/csv_renderer.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/json_renderer.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/python_const.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/rust_const.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/verilog.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/renderers/vhdl.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/state.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/validators.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/consync/watcher.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/examples/fpga/.consync.yaml +0 -0
- {consync-2.3.0 → consync-2.4.0}/examples/fpga/design_params.csv +0 -0
- {consync-2.3.0 → consync-2.4.0}/examples/hardware/.consync.yaml +0 -0
- {consync-2.3.0 → consync-2.4.0}/examples/hardware/constants.csv +0 -0
- {consync-2.3.0 → consync-2.4.0}/examples/multilang/.consync.yaml +0 -0
- {consync-2.3.0 → consync-2.4.0}/examples/multilang/constants.json +0 -0
- {consync-2.3.0 → consync-2.4.0}/npm/.npmrc +0 -0
- {consync-2.3.0 → consync-2.4.0}/npm/LICENSE +0 -0
- {consync-2.3.0 → consync-2.4.0}/npm/README.md +0 -0
- {consync-2.3.0 → consync-2.4.0}/npm/bin/consync.js +0 -0
- {consync-2.3.0 → consync-2.4.0}/npm/package.json +0 -0
- {consync-2.3.0 → consync-2.4.0}/npm/scripts/install.js +0 -0
- {consync-2.3.0 → consync-2.4.0}/tests/__init__.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_bidirectional.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_parsers.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_precision.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_renderers.py +0 -0
- {consync-2.3.0 → consync-2.4.0}/tests/test_sync.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: consync
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Bidirectional sync between spreadsheets and source code constants — with full decimal precision.
|
|
5
5
|
Project-URL: Homepage, https://github.com/naveenkumarbaskaran/consync
|
|
6
6
|
Project-URL: Repository, https://github.com/naveenkumarbaskaran/consync
|
|
@@ -140,6 +140,18 @@ def _parse_mapping(raw: dict[str, Any], config_dir: Path) -> MappingConfig:
|
|
|
140
140
|
if key not in parser_options and key in raw:
|
|
141
141
|
parser_options[key] = raw[key]
|
|
142
142
|
|
|
143
|
+
# protect_target is mandatory when direction is not 'both'
|
|
144
|
+
protect_target_raw = raw.get("protect_target")
|
|
145
|
+
if direction != SyncDirection.BOTH:
|
|
146
|
+
if protect_target_raw is None:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Mapping '{source}' → '{target}': 'protect_target' is required "
|
|
149
|
+
f"when direction is '{raw.get('direction', 'source_to_target')}' (not 'both'). "
|
|
150
|
+
f"Set protect_target: true to make the destination file read-only after sync, "
|
|
151
|
+
f"or protect_target: false to allow manual edits."
|
|
152
|
+
)
|
|
153
|
+
protect_target = bool(protect_target_raw) if protect_target_raw is not None else False
|
|
154
|
+
|
|
143
155
|
return MappingConfig(
|
|
144
156
|
source=source,
|
|
145
157
|
target=target,
|
|
@@ -155,6 +167,7 @@ def _parse_mapping(raw: dict[str, Any], config_dir: Path) -> MappingConfig:
|
|
|
155
167
|
output_style=raw.get("output_style", "const"),
|
|
156
168
|
static_const=raw.get("static_const", False),
|
|
157
169
|
typed_ints=raw.get("typed_ints", True),
|
|
170
|
+
protect_target=protect_target,
|
|
158
171
|
parser_options=parser_options,
|
|
159
172
|
renderer_options=raw.get("renderer_options") or {},
|
|
160
173
|
validators=raw.get("validators") or {},
|
|
@@ -241,10 +254,13 @@ mappings:
|
|
|
241
254
|
# - source: params.csv # Will be created from your existing code
|
|
242
255
|
# target: legacy/params.h # Already exists
|
|
243
256
|
# direction: target_to_source # First sync creates the CSV from the .h
|
|
257
|
+
# protect_target: true # REQUIRED when direction is not 'both'
|
|
244
258
|
|
|
245
259
|
# Optional:
|
|
246
260
|
# prefix: "" # Prefix all constant names (e.g., "HW_")
|
|
247
261
|
# uppercase_names: true # Force UPPER_CASE names in output
|
|
262
|
+
# protect_target: true # Make destination file read-only after sync
|
|
263
|
+
# # (mandatory when direction is not 'both')
|
|
248
264
|
|
|
249
265
|
# Global settings:
|
|
250
266
|
# state_file: .consync.state.json # Track sync state (gitignore this)
|
|
@@ -90,6 +90,9 @@ class MappingConfig:
|
|
|
90
90
|
output_style: str = "const" # "const" | "define" — #define vs const declaration
|
|
91
91
|
static_const: bool = False # emit "static const" to avoid linker duplicates
|
|
92
92
|
typed_ints: bool = True # use uint32_t/int32_t instead of plain int/double for integers
|
|
93
|
+
# File protection — prevents manual edits to the destination file
|
|
94
|
+
# Mandatory when direction is not 'both'.
|
|
95
|
+
protect_target: bool = False # make target read-only after sync (source_to_target)
|
|
93
96
|
# Parser/renderer options (format-specific kwargs)
|
|
94
97
|
parser_options: dict = field(default_factory=dict) # passed to parser as **kwargs
|
|
95
98
|
renderer_options: dict = field(default_factory=dict) # passed to renderer as **kwargs
|
|
@@ -41,7 +41,12 @@ def _sanitize_label(label: str) -> str:
|
|
|
41
41
|
def _format_numeric(value: float | int, original_raw: str = "") -> str:
|
|
42
42
|
"""Format a numeric value back to C literal, preserving style of original.
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Preserves:
|
|
45
|
+
1. Original exponent for scientific notation (E-3 stays E-3)
|
|
46
|
+
2. Lowercase/uppercase 'e'/'E'
|
|
47
|
+
3. Mantissa format (with/without decimal point)
|
|
48
|
+
4. Precision (number of decimal digits)
|
|
49
|
+
5. Exponent digit count (E-03 vs E-3)
|
|
45
50
|
"""
|
|
46
51
|
if isinstance(value, int):
|
|
47
52
|
if original_raw and original_raw.strip().startswith(("0x", "0X")):
|
|
@@ -55,42 +60,88 @@ def _format_numeric(value: float | int, original_raw: str = "") -> str:
|
|
|
55
60
|
return f"{value}{suffix}"
|
|
56
61
|
|
|
57
62
|
# Float value
|
|
58
|
-
suffix = "F"
|
|
63
|
+
suffix = "F" # default
|
|
59
64
|
if original_raw:
|
|
60
|
-
stripped = original_raw.strip()
|
|
61
|
-
if
|
|
62
|
-
suffix =
|
|
65
|
+
stripped = original_raw.strip()
|
|
66
|
+
if stripped.endswith("f"):
|
|
67
|
+
suffix = "f"
|
|
68
|
+
elif stripped.endswith("F"):
|
|
69
|
+
suffix = "F"
|
|
70
|
+
elif stripped.endswith("L") or stripped.endswith("l"):
|
|
71
|
+
suffix = stripped[-1]
|
|
63
72
|
else:
|
|
64
73
|
suffix = ""
|
|
65
74
|
|
|
66
75
|
# Check if original used scientific notation
|
|
67
|
-
if original_raw and ("e" in original_raw
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
if original_raw and ("e" in original_raw or "E" in original_raw):
|
|
77
|
+
# Determine case of exponent character
|
|
78
|
+
use_lowercase = "e" in original_raw and "E" not in original_raw
|
|
79
|
+
exp_char = "e" if use_lowercase else "E"
|
|
80
|
+
|
|
81
|
+
# Extract original exponent value
|
|
82
|
+
orig_no_suffix = original_raw.strip().rstrip("fFlLuU")
|
|
83
|
+
exp_match = re.search(r"[eE]([+-]?\d+)", orig_no_suffix)
|
|
84
|
+
if exp_match:
|
|
85
|
+
orig_exponent = int(exp_match.group(1))
|
|
86
|
+
|
|
87
|
+
# Handle negative values
|
|
88
|
+
sign = ""
|
|
89
|
+
abs_value = value
|
|
90
|
+
if value < 0:
|
|
91
|
+
sign = "-"
|
|
92
|
+
abs_value = abs(value)
|
|
93
|
+
|
|
94
|
+
# Calculate new mantissa preserving the original exponent
|
|
95
|
+
# value = mantissa * 10^exponent => mantissa = value / 10^exponent
|
|
96
|
+
if abs_value == 0:
|
|
97
|
+
new_mantissa = 0.0
|
|
78
98
|
else:
|
|
79
|
-
|
|
80
|
-
else:
|
|
81
|
-
decimal_digits = 2
|
|
99
|
+
new_mantissa = abs_value / (10 ** orig_exponent)
|
|
82
100
|
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
# Determine mantissa format from original
|
|
102
|
+
mantissa_part = orig_no_suffix.split("e")[0].split("E")[0]
|
|
103
|
+
mantissa_part = mantissa_part.lstrip("+-")
|
|
104
|
+
|
|
105
|
+
if "." in mantissa_part:
|
|
106
|
+
decimal_digits = len(mantissa_part.split(".")[1])
|
|
107
|
+
mantissa_str = f"{new_mantissa:.{decimal_digits}f}"
|
|
108
|
+
else:
|
|
109
|
+
# No decimal in original (like "1E-2")
|
|
110
|
+
mantissa_str = f"{int(round(new_mantissa))}"
|
|
111
|
+
|
|
112
|
+
# Preserve exponent format (E-03 vs E-3, leading zeros)
|
|
113
|
+
exp_match_full = re.search(r"[eE]([+-]?)(\d+)", orig_no_suffix)
|
|
114
|
+
if exp_match_full:
|
|
115
|
+
exp_sign_char = exp_match_full.group(1)
|
|
116
|
+
exp_digit_count = len(exp_match_full.group(2))
|
|
117
|
+
if orig_exponent < 0:
|
|
118
|
+
exp_sign_str = "-"
|
|
119
|
+
elif exp_sign_char == "+":
|
|
120
|
+
exp_sign_str = "+"
|
|
121
|
+
else:
|
|
122
|
+
exp_sign_str = ""
|
|
123
|
+
exp_abs = abs(orig_exponent)
|
|
124
|
+
exp_str = f"{exp_sign_str}{exp_abs:0{exp_digit_count}d}"
|
|
125
|
+
else:
|
|
126
|
+
exp_str = f"{orig_exponent:+d}"
|
|
127
|
+
|
|
128
|
+
formatted = f"{sign}{mantissa_str}{exp_char}{exp_str}"
|
|
129
|
+
return f"{formatted}{suffix}"
|
|
130
|
+
else:
|
|
131
|
+
# Fallback (shouldn't happen if we got here)
|
|
132
|
+
formatted = f"{value:E}"
|
|
133
|
+
return f"{formatted}{suffix}"
|
|
85
134
|
else:
|
|
86
|
-
# Regular float notation
|
|
135
|
+
# Regular float notation (no scientific notation in original)
|
|
87
136
|
if original_raw:
|
|
88
137
|
orig_stripped = original_raw.strip().rstrip("fFlL")
|
|
89
138
|
if "." in orig_stripped:
|
|
90
139
|
decimal_digits = len(orig_stripped.split(".")[1])
|
|
91
140
|
formatted = f"{value:.{decimal_digits}f}"
|
|
92
141
|
else:
|
|
93
|
-
formatted =
|
|
142
|
+
formatted = (
|
|
143
|
+
str(int(round(value))) if value == int(value) else str(value)
|
|
144
|
+
)
|
|
94
145
|
else:
|
|
95
146
|
formatted = str(value)
|
|
96
147
|
return f"{formatted}{suffix}"
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from enum import Enum
|
|
8
10
|
from pathlib import Path
|
|
@@ -169,7 +171,9 @@ def _sync_one(
|
|
|
169
171
|
+ (f" (+{len(validation.errors)-1} more)" if len(validation.errors) > 1 else ""),
|
|
170
172
|
)
|
|
171
173
|
backup_file(target_path, backup_dir=config_dir / ".consync" / "backups")
|
|
174
|
+
_unprotect_before_write(target_path, mapping)
|
|
172
175
|
_render_file(constants, target_path, mapping.target_format, mapping)
|
|
176
|
+
_protect_after_write(target_path, mapping)
|
|
173
177
|
result = SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
174
178
|
logger.info(
|
|
175
179
|
"Synced %s → %s (%d constants)",
|
|
@@ -187,7 +191,9 @@ def _sync_one(
|
|
|
187
191
|
+ (f" (+{len(validation.errors)-1} more)" if len(validation.errors) > 1 else ""),
|
|
188
192
|
)
|
|
189
193
|
backup_file(source_path, backup_dir=config_dir / ".consync" / "backups")
|
|
194
|
+
_unprotect_before_write(source_path, mapping)
|
|
190
195
|
_render_file(constants, source_path, mapping.source_format, mapping)
|
|
196
|
+
_protect_after_write(source_path, mapping)
|
|
191
197
|
result = SyncResult.SYNCED_TARGET_TO_SOURCE
|
|
192
198
|
logger.info(
|
|
193
199
|
"Synced %s → %s (%d constants)",
|
|
@@ -389,6 +395,37 @@ def _render_file(constants: list, filepath: Path, format_name: str, mapping: Map
|
|
|
389
395
|
renderer(constants, filepath, config=mapping)
|
|
390
396
|
|
|
391
397
|
|
|
398
|
+
def _unprotect_before_write(filepath: Path, mapping: MappingConfig) -> None:
|
|
399
|
+
"""Temporarily make a protected file writable before consync writes to it.
|
|
400
|
+
|
|
401
|
+
Only acts if protect_target is enabled and the file exists and is read-only.
|
|
402
|
+
"""
|
|
403
|
+
if not mapping.protect_target:
|
|
404
|
+
return
|
|
405
|
+
if not filepath.exists():
|
|
406
|
+
return
|
|
407
|
+
current = filepath.stat().st_mode
|
|
408
|
+
if not (current & stat.S_IWUSR):
|
|
409
|
+
filepath.chmod(current | stat.S_IWUSR)
|
|
410
|
+
logger.debug("Temporarily made %s writable for sync.", filepath.name)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _protect_after_write(filepath: Path, mapping: MappingConfig) -> None:
|
|
414
|
+
"""Make the destination file read-only after sync writes it.
|
|
415
|
+
|
|
416
|
+
Removes the owner write bit so the user cannot accidentally edit the
|
|
417
|
+
file that is managed by consync. Only acts when protect_target is True.
|
|
418
|
+
"""
|
|
419
|
+
if not mapping.protect_target:
|
|
420
|
+
return
|
|
421
|
+
if not filepath.exists():
|
|
422
|
+
return
|
|
423
|
+
current = filepath.stat().st_mode
|
|
424
|
+
readonly = current & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
|
|
425
|
+
filepath.chmod(readonly)
|
|
426
|
+
logger.info("Protected %s (read-only). Use 'consync sync' to update.", filepath.name)
|
|
427
|
+
|
|
428
|
+
|
|
392
429
|
def _validate_if_needed(constants: list, mapping: MappingConfig):
|
|
393
430
|
"""Run validation hooks if configured. Returns ValidationResult or None."""
|
|
394
431
|
if not mapping.validators:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "consync"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "Bidirectional sync between spreadsheets and source code constants — with full decimal precision."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -495,6 +495,7 @@ static const MotorParam_t params[3] = {
|
|
|
495
495
|
target: motor_params.xlsx
|
|
496
496
|
format: c_struct_table
|
|
497
497
|
direction: target_to_source
|
|
498
|
+
protect_target: false
|
|
498
499
|
parser_options:
|
|
499
500
|
variant: all
|
|
500
501
|
"""))
|
|
@@ -508,6 +509,7 @@ static const MotorParam_t params[3] = {
|
|
|
508
509
|
target: motor_params.xlsx
|
|
509
510
|
format: c_struct_table
|
|
510
511
|
direction: source_to_target
|
|
512
|
+
protect_target: false
|
|
511
513
|
parser_options:
|
|
512
514
|
variant: all
|
|
513
515
|
"""))
|
|
@@ -526,6 +528,7 @@ static const MotorParam_t params[3] = {
|
|
|
526
528
|
target: motor_params.xlsx
|
|
527
529
|
format: c_struct_table
|
|
528
530
|
direction: target_to_source
|
|
531
|
+
protect_target: false
|
|
529
532
|
parser_options:
|
|
530
533
|
variant: all
|
|
531
534
|
"""))
|
|
@@ -567,6 +570,7 @@ static const Param_t tbl[2] = {
|
|
|
567
570
|
target: p.xlsx
|
|
568
571
|
format: c_struct_table
|
|
569
572
|
direction: source_to_target
|
|
573
|
+
protect_target: false
|
|
570
574
|
"""))
|
|
571
575
|
# First sync to create xlsx
|
|
572
576
|
sync(config_path=config)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""Tests for c_struct_table parser — generic C struct array initializer
|
|
1
|
+
"""Tests for c_struct_table parser and renderer — generic C struct array initializer."""
|
|
2
2
|
|
|
3
3
|
import textwrap
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
|
+
from consync.renderers.c_struct_table import _format_numeric
|
|
8
9
|
from consync.parsers.c_struct_table import (
|
|
9
10
|
parse_c_struct_table,
|
|
10
11
|
_auto_detect_table_var,
|
|
@@ -472,3 +473,121 @@ class TestParseCStructTable:
|
|
|
472
473
|
# Special chars in label → underscore, fields from header comment
|
|
473
474
|
assert result[0].name == "Motor_A_B_v2__alpha"
|
|
474
475
|
assert result[1].name == "Motor_A_B_v2__beta"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ============================================================
|
|
479
|
+
# _format_numeric — renderer numeric formatting
|
|
480
|
+
# ============================================================
|
|
481
|
+
|
|
482
|
+
class TestFormatNumeric:
|
|
483
|
+
"""Tests for _format_numeric() in the c_struct_table renderer.
|
|
484
|
+
|
|
485
|
+
Covers all C literal format variations found in automotive embedded code:
|
|
486
|
+
scientific notation exponent preservation, case preservation, mantissa
|
|
487
|
+
format, precision, hex, unsigned, and regular floats.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
# --- Issue 1: Exponent preservation (CRITICAL) ---
|
|
491
|
+
|
|
492
|
+
def test_scientific_notation_preserves_exponent(self):
|
|
493
|
+
"""Original E-3 exponent must be preserved, not renormalized."""
|
|
494
|
+
assert _format_numeric(0.205, "195.0E-3F") == "205.0E-3F"
|
|
495
|
+
|
|
496
|
+
def test_scientific_notation_same_value(self):
|
|
497
|
+
assert _format_numeric(0.195, "195.0E-3F") == "195.0E-3F"
|
|
498
|
+
|
|
499
|
+
def test_scientific_notation_higher_precision(self):
|
|
500
|
+
assert _format_numeric(0.0315, "30.13E-3F") == "31.50E-3F"
|
|
501
|
+
|
|
502
|
+
def test_scientific_notation_micro_scale(self):
|
|
503
|
+
assert _format_numeric(0.00002521, "24.21E-6F") == "25.21E-6F"
|
|
504
|
+
|
|
505
|
+
def test_scientific_notation_micro_scale_same(self):
|
|
506
|
+
assert _format_numeric(0.00002421, "24.21E-6F") == "24.21E-6F"
|
|
507
|
+
|
|
508
|
+
def test_scientific_notation_negative_value(self):
|
|
509
|
+
assert _format_numeric(-0.0001242, "-1.242E-4F") == "-1.242E-4F"
|
|
510
|
+
|
|
511
|
+
def test_scientific_notation_negative_different_exp(self):
|
|
512
|
+
assert _format_numeric(-0.0000101, "-1.01E-5F") == "-1.01E-5F"
|
|
513
|
+
|
|
514
|
+
def test_scientific_notation_positive_exponent(self):
|
|
515
|
+
assert _format_numeric(1000.0, "1.0E3F") == "1.0E3F"
|
|
516
|
+
|
|
517
|
+
def test_scientific_notation_positive_exponent_changed(self):
|
|
518
|
+
assert _format_numeric(2000.0, "1.0E3F") == "2.0E3F"
|
|
519
|
+
|
|
520
|
+
def test_scientific_notation_milli_scale(self):
|
|
521
|
+
assert _format_numeric(0.052, "42.0E-3F") == "52.0E-3F"
|
|
522
|
+
|
|
523
|
+
# --- Issue 2: Lowercase 'e' preservation ---
|
|
524
|
+
|
|
525
|
+
def test_lowercase_e_preserved_same_value(self):
|
|
526
|
+
assert _format_numeric(0.0726, "72.6e-3F") == "72.6e-3F"
|
|
527
|
+
|
|
528
|
+
def test_lowercase_e_preserved_new_value(self):
|
|
529
|
+
assert _format_numeric(0.0831, "72.6e-3F") == "83.1e-3F"
|
|
530
|
+
|
|
531
|
+
# --- Issue 3: No decimal point in mantissa ---
|
|
532
|
+
|
|
533
|
+
def test_no_decimal_mantissa_same(self):
|
|
534
|
+
assert _format_numeric(0.01, "1E-2F") == "1E-2F"
|
|
535
|
+
|
|
536
|
+
def test_no_decimal_mantissa_4e3(self):
|
|
537
|
+
assert _format_numeric(0.004, "4E-3F") == "4E-3F"
|
|
538
|
+
|
|
539
|
+
def test_no_decimal_mantissa_6e3(self):
|
|
540
|
+
assert _format_numeric(0.006, "6E-3F") == "6E-3F"
|
|
541
|
+
|
|
542
|
+
def test_no_decimal_mantissa_changed(self):
|
|
543
|
+
assert _format_numeric(0.02, "1E-2F") == "2E-2F"
|
|
544
|
+
|
|
545
|
+
# --- Issue 4: High precision + exponent digit preservation ---
|
|
546
|
+
|
|
547
|
+
def test_high_precision_same(self):
|
|
548
|
+
assert _format_numeric(0.002457019940, "2.457019940E-03F") == "2.457019940E-03F"
|
|
549
|
+
|
|
550
|
+
def test_high_precision_changed(self):
|
|
551
|
+
assert _format_numeric(0.002557019940, "2.457019940E-03F") == "2.557019940E-03F"
|
|
552
|
+
|
|
553
|
+
# --- Working cases: integers ---
|
|
554
|
+
|
|
555
|
+
def test_unsigned_int_same(self):
|
|
556
|
+
assert _format_numeric(5, "5u") == "5u"
|
|
557
|
+
|
|
558
|
+
def test_unsigned_int_changed(self):
|
|
559
|
+
assert _format_numeric(7, "5u") == "7u"
|
|
560
|
+
|
|
561
|
+
def test_hex_literal_same(self):
|
|
562
|
+
assert _format_numeric(255, "0xFF") == "0xFF"
|
|
563
|
+
|
|
564
|
+
def test_hex_literal_changed(self):
|
|
565
|
+
assert _format_numeric(16, "0xFF") == "0x10"
|
|
566
|
+
|
|
567
|
+
# --- Working cases: regular floats ---
|
|
568
|
+
|
|
569
|
+
def test_regular_float_same(self):
|
|
570
|
+
assert _format_numeric(121.0, "121.0F") == "121.0F"
|
|
571
|
+
|
|
572
|
+
def test_regular_float_changed(self):
|
|
573
|
+
assert _format_numeric(125.5, "121.0F") == "125.5F"
|
|
574
|
+
|
|
575
|
+
def test_small_float_no_sci(self):
|
|
576
|
+
assert _format_numeric(0.0055, "0.0055F") == "0.0055F"
|
|
577
|
+
|
|
578
|
+
def test_float_three_decimals(self):
|
|
579
|
+
assert _format_numeric(0.087, "0.087F") == "0.087F"
|
|
580
|
+
|
|
581
|
+
def test_zero_regular(self):
|
|
582
|
+
assert _format_numeric(0.0, "0.0F") == "0.0F"
|
|
583
|
+
|
|
584
|
+
def test_zero_scientific(self):
|
|
585
|
+
assert _format_numeric(0.0, "0.0E-3F") == "0.0E-3F"
|
|
586
|
+
|
|
587
|
+
def test_no_original_raw(self):
|
|
588
|
+
"""When no original is provided, just format the value."""
|
|
589
|
+
result = _format_numeric(3.14)
|
|
590
|
+
assert result == "3.14F"
|
|
591
|
+
|
|
592
|
+
def test_int_no_original(self):
|
|
593
|
+
assert _format_numeric(42) == "42"
|
|
@@ -22,6 +22,7 @@ def project_dir(tmp_path):
|
|
|
22
22
|
- source: data.csv
|
|
23
23
|
target: out.h
|
|
24
24
|
direction: source_to_target
|
|
25
|
+
protect_target: false
|
|
25
26
|
precision: 17
|
|
26
27
|
header_guard: TEST_H
|
|
27
28
|
"""))
|
|
@@ -90,4 +91,4 @@ class TestVersionCommand:
|
|
|
90
91
|
def test_version(self, runner):
|
|
91
92
|
result = runner.invoke(main, ["--version"])
|
|
92
93
|
assert result.exit_code == 0
|
|
93
|
-
assert "2.
|
|
94
|
+
assert "2.4.0" in result.output
|
|
@@ -129,7 +129,8 @@ C_STRUCT_TABLE_VARIANTS = textwrap.dedent("""\
|
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
def create_config(tmp_path, source_name, target_name, direction="source_to_target",
|
|
132
|
-
source_format="", target_format="", parser_options=None
|
|
132
|
+
source_format="", target_format="", parser_options=None,
|
|
133
|
+
protect_target=False):
|
|
133
134
|
"""Create a .consync.yaml config file."""
|
|
134
135
|
cfg = f"""\
|
|
135
136
|
mappings:
|
|
@@ -137,6 +138,9 @@ mappings:
|
|
|
137
138
|
target: {target_name}
|
|
138
139
|
direction: {direction}
|
|
139
140
|
"""
|
|
141
|
+
# protect_target is mandatory when direction is not 'both'
|
|
142
|
+
if direction != "both":
|
|
143
|
+
cfg += f" protect_target: {'true' if protect_target else 'false'}\n"
|
|
140
144
|
if source_format:
|
|
141
145
|
cfg += f" source_format: {source_format}\n"
|
|
142
146
|
if target_format:
|
|
@@ -403,12 +407,15 @@ class TestOneWaySourceToTarget:
|
|
|
403
407
|
- source: params.csv
|
|
404
408
|
target: params.h
|
|
405
409
|
direction: source_to_target
|
|
410
|
+
protect_target: false
|
|
406
411
|
- source: params.csv
|
|
407
412
|
target: params.json
|
|
408
413
|
direction: source_to_target
|
|
414
|
+
protect_target: false
|
|
409
415
|
- source: params.csv
|
|
410
416
|
target: params.py
|
|
411
417
|
direction: source_to_target
|
|
418
|
+
protect_target: false
|
|
412
419
|
""")
|
|
413
420
|
config = tmp_path / ".consync.yaml"
|
|
414
421
|
config.write_text(cfg_text)
|
|
@@ -1059,6 +1066,7 @@ class TestFormatRendering:
|
|
|
1059
1066
|
- source: params.csv
|
|
1060
1067
|
target: params.h
|
|
1061
1068
|
direction: source_to_target
|
|
1069
|
+
protect_target: false
|
|
1062
1070
|
output_style: define
|
|
1063
1071
|
""")
|
|
1064
1072
|
config = tmp_path / ".consync.yaml"
|
|
@@ -1076,6 +1084,7 @@ class TestFormatRendering:
|
|
|
1076
1084
|
- source: params.csv
|
|
1077
1085
|
target: params.h
|
|
1078
1086
|
direction: source_to_target
|
|
1087
|
+
protect_target: false
|
|
1079
1088
|
output_style: const
|
|
1080
1089
|
""")
|
|
1081
1090
|
config = tmp_path / ".consync.yaml"
|
|
@@ -1093,6 +1102,7 @@ class TestFormatRendering:
|
|
|
1093
1102
|
- source: params.csv
|
|
1094
1103
|
target: params.v
|
|
1095
1104
|
direction: source_to_target
|
|
1105
|
+
protect_target: false
|
|
1096
1106
|
module_name: motor_params
|
|
1097
1107
|
""")
|
|
1098
1108
|
config = tmp_path / ".consync.yaml"
|
|
@@ -1111,6 +1121,7 @@ class TestFormatRendering:
|
|
|
1111
1121
|
- source: params.csv
|
|
1112
1122
|
target: params.cs
|
|
1113
1123
|
direction: source_to_target
|
|
1124
|
+
protect_target: false
|
|
1114
1125
|
namespace: Motor.Config
|
|
1115
1126
|
""")
|
|
1116
1127
|
config = tmp_path / ".consync.yaml"
|
|
@@ -414,6 +414,7 @@ mappings:
|
|
|
414
414
|
- source: data.csv
|
|
415
415
|
target: out.h
|
|
416
416
|
direction: source_to_target
|
|
417
|
+
protect_target: false
|
|
417
418
|
""")
|
|
418
419
|
source = tmp_path / "data.csv"
|
|
419
420
|
source.write_text(source_content)
|
|
@@ -470,6 +471,7 @@ mappings:
|
|
|
470
471
|
- source: data.csv
|
|
471
472
|
target: out.h
|
|
472
473
|
direction: source_to_target
|
|
474
|
+
protect_target: false
|
|
473
475
|
validators:
|
|
474
476
|
X:
|
|
475
477
|
min: 0
|
|
@@ -494,6 +496,7 @@ mappings:
|
|
|
494
496
|
- source: data.csv
|
|
495
497
|
target: out.h
|
|
496
498
|
direction: source_to_target
|
|
499
|
+
protect_target: false
|
|
497
500
|
validators:
|
|
498
501
|
X:
|
|
499
502
|
min: 0
|
|
@@ -506,3 +509,144 @@ mappings:
|
|
|
506
509
|
reports = sync(config_path=str(config))
|
|
507
510
|
|
|
508
511
|
assert reports[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ============================================================
|
|
515
|
+
# protect_target Tests
|
|
516
|
+
# ============================================================
|
|
517
|
+
|
|
518
|
+
class TestProtectTarget:
|
|
519
|
+
"""Tests for the protect_target option that makes destination files read-only."""
|
|
520
|
+
|
|
521
|
+
def test_config_requires_protect_target_for_s2t(self, tmp_path):
|
|
522
|
+
"""Config loader rejects source_to_target without protect_target."""
|
|
523
|
+
from consync.config import load_config
|
|
524
|
+
|
|
525
|
+
config = tmp_path / ".consync.yaml"
|
|
526
|
+
config.write_text("""\
|
|
527
|
+
mappings:
|
|
528
|
+
- source: data.csv
|
|
529
|
+
target: out.h
|
|
530
|
+
direction: source_to_target
|
|
531
|
+
""")
|
|
532
|
+
(tmp_path / "data.csv").write_text("Name,Value\nX,1\n")
|
|
533
|
+
|
|
534
|
+
with pytest.raises(ValueError, match="protect_target.*required"):
|
|
535
|
+
load_config(config)
|
|
536
|
+
|
|
537
|
+
def test_config_requires_protect_target_for_t2s(self, tmp_path):
|
|
538
|
+
"""Config loader rejects target_to_source without protect_target."""
|
|
539
|
+
from consync.config import load_config
|
|
540
|
+
|
|
541
|
+
config = tmp_path / ".consync.yaml"
|
|
542
|
+
config.write_text("""\
|
|
543
|
+
mappings:
|
|
544
|
+
- source: data.csv
|
|
545
|
+
target: out.h
|
|
546
|
+
direction: target_to_source
|
|
547
|
+
protect_target: false
|
|
548
|
+
""")
|
|
549
|
+
# This should load fine (protect_target is set)
|
|
550
|
+
cfg = load_config(config)
|
|
551
|
+
assert cfg.mappings[0].protect_target is False
|
|
552
|
+
|
|
553
|
+
def test_config_optional_for_both_direction(self, tmp_path):
|
|
554
|
+
"""Config loader does NOT require protect_target when direction is 'both'."""
|
|
555
|
+
from consync.config import load_config
|
|
556
|
+
|
|
557
|
+
config = tmp_path / ".consync.yaml"
|
|
558
|
+
config.write_text("""\
|
|
559
|
+
mappings:
|
|
560
|
+
- source: data.csv
|
|
561
|
+
target: out.h
|
|
562
|
+
direction: both
|
|
563
|
+
""")
|
|
564
|
+
# Should load without error
|
|
565
|
+
cfg = load_config(config)
|
|
566
|
+
assert cfg.mappings[0].protect_target is False
|
|
567
|
+
|
|
568
|
+
def test_protect_target_true_makes_file_readonly(self, tmp_path):
|
|
569
|
+
"""When protect_target is True, target file is set read-only after sync."""
|
|
570
|
+
import stat
|
|
571
|
+
from consync.sync import sync, SyncResult
|
|
572
|
+
|
|
573
|
+
config = tmp_path / ".consync.yaml"
|
|
574
|
+
config.write_text("""\
|
|
575
|
+
mappings:
|
|
576
|
+
- source: data.csv
|
|
577
|
+
target: out.h
|
|
578
|
+
direction: source_to_target
|
|
579
|
+
protect_target: true
|
|
580
|
+
""")
|
|
581
|
+
source = tmp_path / "data.csv"
|
|
582
|
+
source.write_text("Name,Value\nX,42\n")
|
|
583
|
+
|
|
584
|
+
os.chdir(tmp_path)
|
|
585
|
+
reports = sync(config_path=str(config))
|
|
586
|
+
assert reports[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
587
|
+
|
|
588
|
+
target = tmp_path / "out.h"
|
|
589
|
+
assert target.exists()
|
|
590
|
+
mode = target.stat().st_mode
|
|
591
|
+
assert not (mode & stat.S_IWUSR), "Target should be read-only (no owner write bit)"
|
|
592
|
+
assert not (mode & stat.S_IWGRP), "Target should be read-only (no group write bit)"
|
|
593
|
+
assert not (mode & stat.S_IWOTH), "Target should be read-only (no other write bit)"
|
|
594
|
+
|
|
595
|
+
def test_protect_target_false_leaves_file_writable(self, tmp_path):
|
|
596
|
+
"""When protect_target is False, target file stays writable."""
|
|
597
|
+
import stat
|
|
598
|
+
from consync.sync import sync, SyncResult
|
|
599
|
+
|
|
600
|
+
config = tmp_path / ".consync.yaml"
|
|
601
|
+
config.write_text("""\
|
|
602
|
+
mappings:
|
|
603
|
+
- source: data.csv
|
|
604
|
+
target: out.h
|
|
605
|
+
direction: source_to_target
|
|
606
|
+
protect_target: false
|
|
607
|
+
""")
|
|
608
|
+
source = tmp_path / "data.csv"
|
|
609
|
+
source.write_text("Name,Value\nX,42\n")
|
|
610
|
+
|
|
611
|
+
os.chdir(tmp_path)
|
|
612
|
+
reports = sync(config_path=str(config))
|
|
613
|
+
assert reports[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
614
|
+
|
|
615
|
+
target = tmp_path / "out.h"
|
|
616
|
+
mode = target.stat().st_mode
|
|
617
|
+
assert mode & stat.S_IWUSR, "Target should remain writable"
|
|
618
|
+
|
|
619
|
+
def test_protect_target_resync_works(self, tmp_path):
|
|
620
|
+
"""Protected (read-only) file can still be updated by subsequent sync."""
|
|
621
|
+
import stat
|
|
622
|
+
from consync.sync import sync, SyncResult
|
|
623
|
+
|
|
624
|
+
config = tmp_path / ".consync.yaml"
|
|
625
|
+
config.write_text("""\
|
|
626
|
+
mappings:
|
|
627
|
+
- source: data.csv
|
|
628
|
+
target: out.h
|
|
629
|
+
direction: source_to_target
|
|
630
|
+
protect_target: true
|
|
631
|
+
""")
|
|
632
|
+
source = tmp_path / "data.csv"
|
|
633
|
+
source.write_text("Name,Value\nX,42\n")
|
|
634
|
+
|
|
635
|
+
os.chdir(tmp_path)
|
|
636
|
+
sync(config_path=str(config))
|
|
637
|
+
target = tmp_path / "out.h"
|
|
638
|
+
assert not (target.stat().st_mode & stat.S_IWUSR)
|
|
639
|
+
|
|
640
|
+
content_v1 = target.read_text()
|
|
641
|
+
|
|
642
|
+
# Update source and sync again — should work despite file being read-only
|
|
643
|
+
source.write_text("Name,Value\nX,99\n")
|
|
644
|
+
reports = sync(config_path=str(config))
|
|
645
|
+
assert reports[0].result == SyncResult.SYNCED_SOURCE_TO_TARGET
|
|
646
|
+
|
|
647
|
+
content_v2 = target.read_text()
|
|
648
|
+
assert content_v2 != content_v1
|
|
649
|
+
assert "99" in content_v2
|
|
650
|
+
|
|
651
|
+
# File should be read-only again
|
|
652
|
+
assert not (target.stat().st_mode & stat.S_IWUSR)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|