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.
Files changed (74) hide show
  1. {consync-2.3.0 → consync-2.4.0}/PKG-INFO +1 -1
  2. {consync-2.3.0 → consync-2.4.0}/consync/__init__.py +1 -1
  3. {consync-2.3.0 → consync-2.4.0}/consync/config.py +16 -0
  4. {consync-2.3.0 → consync-2.4.0}/consync/models.py +3 -0
  5. {consync-2.3.0 → consync-2.4.0}/consync/renderers/c_struct_table.py +74 -23
  6. {consync-2.3.0 → consync-2.4.0}/consync/sync.py +37 -0
  7. {consync-2.3.0 → consync-2.4.0}/pyproject.toml +1 -1
  8. {consync-2.3.0 → consync-2.4.0}/tests/test_arrays.py +1 -0
  9. {consync-2.3.0 → consync-2.4.0}/tests/test_audit_sync.py +4 -0
  10. {consync-2.3.0 → consync-2.4.0}/tests/test_c_struct_table.py +120 -1
  11. {consync-2.3.0 → consync-2.4.0}/tests/test_cli.py +2 -1
  12. {consync-2.3.0 → consync-2.4.0}/tests/test_comprehensive_sync.py +12 -1
  13. {consync-2.3.0 → consync-2.4.0}/tests/test_embedded.py +1 -0
  14. {consync-2.3.0 → consync-2.4.0}/tests/test_safety.py +144 -0
  15. {consync-2.3.0 → consync-2.4.0}/.github/CODEOWNERS +0 -0
  16. {consync-2.3.0 → consync-2.4.0}/.github/copilot-instructions.md +0 -0
  17. {consync-2.3.0 → consync-2.4.0}/.github/dependabot.yml +0 -0
  18. {consync-2.3.0 → consync-2.4.0}/.github/workflows/ci.yml +0 -0
  19. {consync-2.3.0 → consync-2.4.0}/.github/workflows/codeql.yml +0 -0
  20. {consync-2.3.0 → consync-2.4.0}/.github/workflows/publish.yml +0 -0
  21. {consync-2.3.0 → consync-2.4.0}/.github/workflows/release.yml +0 -0
  22. {consync-2.3.0 → consync-2.4.0}/.gitignore +0 -0
  23. {consync-2.3.0 → consync-2.4.0}/CLAUDE.md +0 -0
  24. {consync-2.3.0 → consync-2.4.0}/CONTRIBUTING.md +0 -0
  25. {consync-2.3.0 → consync-2.4.0}/FAQ.md +0 -0
  26. {consync-2.3.0 → consync-2.4.0}/LICENSE +0 -0
  27. {consync-2.3.0 → consync-2.4.0}/README.md +0 -0
  28. {consync-2.3.0 → consync-2.4.0}/SECURITY.md +0 -0
  29. {consync-2.3.0 → consync-2.4.0}/TODO.md +0 -0
  30. {consync-2.3.0 → consync-2.4.0}/assets/demo.gif +0 -0
  31. {consync-2.3.0 → consync-2.4.0}/assets/demo.tape +0 -0
  32. {consync-2.3.0 → consync-2.4.0}/consync/backup.py +0 -0
  33. {consync-2.3.0 → consync-2.4.0}/consync/cli.py +0 -0
  34. {consync-2.3.0 → consync-2.4.0}/consync/hooks.py +0 -0
  35. {consync-2.3.0 → consync-2.4.0}/consync/lock.py +0 -0
  36. {consync-2.3.0 → consync-2.4.0}/consync/logging_config.py +0 -0
  37. {consync-2.3.0 → consync-2.4.0}/consync/parsers/__init__.py +0 -0
  38. {consync-2.3.0 → consync-2.4.0}/consync/parsers/c_header.py +0 -0
  39. {consync-2.3.0 → consync-2.4.0}/consync/parsers/c_struct_table.py +0 -0
  40. {consync-2.3.0 → consync-2.4.0}/consync/parsers/csv_parser.py +0 -0
  41. {consync-2.3.0 → consync-2.4.0}/consync/parsers/json_parser.py +0 -0
  42. {consync-2.3.0 → consync-2.4.0}/consync/parsers/toml_parser.py +0 -0
  43. {consync-2.3.0 → consync-2.4.0}/consync/parsers/xlsx.py +0 -0
  44. {consync-2.3.0 → consync-2.4.0}/consync/precision.py +0 -0
  45. {consync-2.3.0 → consync-2.4.0}/consync/renderers/__init__.py +0 -0
  46. {consync-2.3.0 → consync-2.4.0}/consync/renderers/c_header.py +0 -0
  47. {consync-2.3.0 → consync-2.4.0}/consync/renderers/csharp.py +0 -0
  48. {consync-2.3.0 → consync-2.4.0}/consync/renderers/csv_renderer.py +0 -0
  49. {consync-2.3.0 → consync-2.4.0}/consync/renderers/json_renderer.py +0 -0
  50. {consync-2.3.0 → consync-2.4.0}/consync/renderers/python_const.py +0 -0
  51. {consync-2.3.0 → consync-2.4.0}/consync/renderers/rust_const.py +0 -0
  52. {consync-2.3.0 → consync-2.4.0}/consync/renderers/verilog.py +0 -0
  53. {consync-2.3.0 → consync-2.4.0}/consync/renderers/vhdl.py +0 -0
  54. {consync-2.3.0 → consync-2.4.0}/consync/state.py +0 -0
  55. {consync-2.3.0 → consync-2.4.0}/consync/validators.py +0 -0
  56. {consync-2.3.0 → consync-2.4.0}/consync/watcher.py +0 -0
  57. {consync-2.3.0 → consync-2.4.0}/examples/fpga/.consync.yaml +0 -0
  58. {consync-2.3.0 → consync-2.4.0}/examples/fpga/design_params.csv +0 -0
  59. {consync-2.3.0 → consync-2.4.0}/examples/hardware/.consync.yaml +0 -0
  60. {consync-2.3.0 → consync-2.4.0}/examples/hardware/constants.csv +0 -0
  61. {consync-2.3.0 → consync-2.4.0}/examples/multilang/.consync.yaml +0 -0
  62. {consync-2.3.0 → consync-2.4.0}/examples/multilang/constants.json +0 -0
  63. {consync-2.3.0 → consync-2.4.0}/npm/.npmrc +0 -0
  64. {consync-2.3.0 → consync-2.4.0}/npm/LICENSE +0 -0
  65. {consync-2.3.0 → consync-2.4.0}/npm/README.md +0 -0
  66. {consync-2.3.0 → consync-2.4.0}/npm/bin/consync.js +0 -0
  67. {consync-2.3.0 → consync-2.4.0}/npm/package.json +0 -0
  68. {consync-2.3.0 → consync-2.4.0}/npm/scripts/install.js +0 -0
  69. {consync-2.3.0 → consync-2.4.0}/tests/__init__.py +0 -0
  70. {consync-2.3.0 → consync-2.4.0}/tests/test_bidirectional.py +0 -0
  71. {consync-2.3.0 → consync-2.4.0}/tests/test_parsers.py +0 -0
  72. {consync-2.3.0 → consync-2.4.0}/tests/test_precision.py +0 -0
  73. {consync-2.3.0 → consync-2.4.0}/tests/test_renderers.py +0 -0
  74. {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.0
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
@@ -1,6 +1,6 @@
1
1
  """consync — Bidirectional sync between spreadsheets and source code constants."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.4.0"
4
4
 
5
5
  from consync.models import Constant, SyncDirection
6
6
  from consync.config import load_config
@@ -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
- Tries to maintain scientific notation, F suffix, etc. from the original.
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().rstrip("fFlL")
61
- if original_raw.strip().endswith("f") or original_raw.strip().endswith("F"):
62
- suffix = original_raw.strip()[-1]
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.lower() or "E" in original_raw):
68
- # Format in scientific notation
69
- # Detect the exponent style from original
70
- formatted = f"{value:E}"
71
- # Simplify: use same number of significant digits as original
72
- orig_stripped = original_raw.strip().rstrip("fFlL")
73
- if "." in orig_stripped:
74
- # Count digits after decimal before E
75
- parts = orig_stripped.upper().split("E")
76
- if "." in parts[0]:
77
- decimal_digits = len(parts[0].split(".")[1])
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
- decimal_digits = 2
80
- else:
81
- decimal_digits = 2
99
+ new_mantissa = abs_value / (10 ** orig_exponent)
82
100
 
83
- formatted = f"{value:.{decimal_digits}E}"
84
- return f"{formatted}{suffix}"
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 = str(value)
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.3.0"
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"}
@@ -446,6 +446,7 @@ class TestEndToEndArraySync:
446
446
  " - source: params.csv\n"
447
447
  " target: out.h\n"
448
448
  " direction: source_to_target\n"
449
+ " protect_target: false\n"
449
450
  " header_guard: PARAMS_H\n"
450
451
  " static_const: true\n"
451
452
  " typed_ints: true\n"
@@ -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 parsing."""
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.1.0" in result.output
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"
@@ -239,6 +239,7 @@ class TestECUEndToEnd:
239
239
  - source: ecu_params.csv
240
240
  target: ecu_params.h
241
241
  direction: source_to_target
242
+ protect_target: false
242
243
  precision: 17
243
244
  header_guard: ECU_PARAMS_H
244
245
  output_style: const
@@ -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