consync 2.2.3__tar.gz → 2.3.1__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.2.3 → consync-2.3.1}/PKG-INFO +1 -1
  2. {consync-2.2.3 → consync-2.3.1}/consync/__init__.py +1 -1
  3. {consync-2.2.3 → consync-2.3.1}/consync/renderers/c_struct_table.py +74 -23
  4. {consync-2.2.3 → consync-2.3.1}/consync/state.py +32 -4
  5. {consync-2.2.3 → consync-2.3.1}/consync/watcher.py +59 -24
  6. {consync-2.2.3 → consync-2.3.1}/pyproject.toml +1 -1
  7. consync-2.3.1/tests/test_audit_sync.py +669 -0
  8. {consync-2.2.3 → consync-2.3.1}/tests/test_c_struct_table.py +120 -1
  9. {consync-2.2.3 → consync-2.3.1}/.github/CODEOWNERS +0 -0
  10. {consync-2.2.3 → consync-2.3.1}/.github/copilot-instructions.md +0 -0
  11. {consync-2.2.3 → consync-2.3.1}/.github/dependabot.yml +0 -0
  12. {consync-2.2.3 → consync-2.3.1}/.github/workflows/ci.yml +0 -0
  13. {consync-2.2.3 → consync-2.3.1}/.github/workflows/codeql.yml +0 -0
  14. {consync-2.2.3 → consync-2.3.1}/.github/workflows/publish.yml +0 -0
  15. {consync-2.2.3 → consync-2.3.1}/.github/workflows/release.yml +0 -0
  16. {consync-2.2.3 → consync-2.3.1}/.gitignore +0 -0
  17. {consync-2.2.3 → consync-2.3.1}/CLAUDE.md +0 -0
  18. {consync-2.2.3 → consync-2.3.1}/CONTRIBUTING.md +0 -0
  19. {consync-2.2.3 → consync-2.3.1}/FAQ.md +0 -0
  20. {consync-2.2.3 → consync-2.3.1}/LICENSE +0 -0
  21. {consync-2.2.3 → consync-2.3.1}/README.md +0 -0
  22. {consync-2.2.3 → consync-2.3.1}/SECURITY.md +0 -0
  23. {consync-2.2.3 → consync-2.3.1}/TODO.md +0 -0
  24. {consync-2.2.3 → consync-2.3.1}/assets/demo.gif +0 -0
  25. {consync-2.2.3 → consync-2.3.1}/assets/demo.tape +0 -0
  26. {consync-2.2.3 → consync-2.3.1}/consync/backup.py +0 -0
  27. {consync-2.2.3 → consync-2.3.1}/consync/cli.py +0 -0
  28. {consync-2.2.3 → consync-2.3.1}/consync/config.py +0 -0
  29. {consync-2.2.3 → consync-2.3.1}/consync/hooks.py +0 -0
  30. {consync-2.2.3 → consync-2.3.1}/consync/lock.py +0 -0
  31. {consync-2.2.3 → consync-2.3.1}/consync/logging_config.py +0 -0
  32. {consync-2.2.3 → consync-2.3.1}/consync/models.py +0 -0
  33. {consync-2.2.3 → consync-2.3.1}/consync/parsers/__init__.py +0 -0
  34. {consync-2.2.3 → consync-2.3.1}/consync/parsers/c_header.py +0 -0
  35. {consync-2.2.3 → consync-2.3.1}/consync/parsers/c_struct_table.py +0 -0
  36. {consync-2.2.3 → consync-2.3.1}/consync/parsers/csv_parser.py +0 -0
  37. {consync-2.2.3 → consync-2.3.1}/consync/parsers/json_parser.py +0 -0
  38. {consync-2.2.3 → consync-2.3.1}/consync/parsers/toml_parser.py +0 -0
  39. {consync-2.2.3 → consync-2.3.1}/consync/parsers/xlsx.py +0 -0
  40. {consync-2.2.3 → consync-2.3.1}/consync/precision.py +0 -0
  41. {consync-2.2.3 → consync-2.3.1}/consync/renderers/__init__.py +0 -0
  42. {consync-2.2.3 → consync-2.3.1}/consync/renderers/c_header.py +0 -0
  43. {consync-2.2.3 → consync-2.3.1}/consync/renderers/csharp.py +0 -0
  44. {consync-2.2.3 → consync-2.3.1}/consync/renderers/csv_renderer.py +0 -0
  45. {consync-2.2.3 → consync-2.3.1}/consync/renderers/json_renderer.py +0 -0
  46. {consync-2.2.3 → consync-2.3.1}/consync/renderers/python_const.py +0 -0
  47. {consync-2.2.3 → consync-2.3.1}/consync/renderers/rust_const.py +0 -0
  48. {consync-2.2.3 → consync-2.3.1}/consync/renderers/verilog.py +0 -0
  49. {consync-2.2.3 → consync-2.3.1}/consync/renderers/vhdl.py +0 -0
  50. {consync-2.2.3 → consync-2.3.1}/consync/sync.py +0 -0
  51. {consync-2.2.3 → consync-2.3.1}/consync/validators.py +0 -0
  52. {consync-2.2.3 → consync-2.3.1}/examples/fpga/.consync.yaml +0 -0
  53. {consync-2.2.3 → consync-2.3.1}/examples/fpga/design_params.csv +0 -0
  54. {consync-2.2.3 → consync-2.3.1}/examples/hardware/.consync.yaml +0 -0
  55. {consync-2.2.3 → consync-2.3.1}/examples/hardware/constants.csv +0 -0
  56. {consync-2.2.3 → consync-2.3.1}/examples/multilang/.consync.yaml +0 -0
  57. {consync-2.2.3 → consync-2.3.1}/examples/multilang/constants.json +0 -0
  58. {consync-2.2.3 → consync-2.3.1}/npm/.npmrc +0 -0
  59. {consync-2.2.3 → consync-2.3.1}/npm/LICENSE +0 -0
  60. {consync-2.2.3 → consync-2.3.1}/npm/README.md +0 -0
  61. {consync-2.2.3 → consync-2.3.1}/npm/bin/consync.js +0 -0
  62. {consync-2.2.3 → consync-2.3.1}/npm/package.json +0 -0
  63. {consync-2.2.3 → consync-2.3.1}/npm/scripts/install.js +0 -0
  64. {consync-2.2.3 → consync-2.3.1}/tests/__init__.py +0 -0
  65. {consync-2.2.3 → consync-2.3.1}/tests/test_arrays.py +0 -0
  66. {consync-2.2.3 → consync-2.3.1}/tests/test_bidirectional.py +0 -0
  67. {consync-2.2.3 → consync-2.3.1}/tests/test_cli.py +0 -0
  68. {consync-2.2.3 → consync-2.3.1}/tests/test_comprehensive_sync.py +0 -0
  69. {consync-2.2.3 → consync-2.3.1}/tests/test_embedded.py +0 -0
  70. {consync-2.2.3 → consync-2.3.1}/tests/test_parsers.py +0 -0
  71. {consync-2.2.3 → consync-2.3.1}/tests/test_precision.py +0 -0
  72. {consync-2.2.3 → consync-2.3.1}/tests/test_renderers.py +0 -0
  73. {consync-2.2.3 → consync-2.3.1}/tests/test_safety.py +0 -0
  74. {consync-2.2.3 → consync-2.3.1}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: consync
3
- Version: 2.2.3
3
+ Version: 2.3.1
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.1.0"
3
+ __version__ = "2.3.1"
4
4
 
5
5
  from consync.models import Constant, SyncDirection
6
6
  from consync.config import load_config
@@ -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}"
@@ -18,17 +18,45 @@ from pathlib import Path
18
18
  from consync.models import Constant
19
19
 
20
20
 
21
+ def _normalize_value(v):
22
+ """Normalize a constant value for stable hashing.
23
+
24
+ Ensures int/float equivalence: C produces 1.0 (float) for ``1.0F``,
25
+ but Excel stores it as integer 1. Without normalization these hash
26
+ differently, causing perpetual "out of sync" after every round-trip.
27
+
28
+ Rules:
29
+ - int/float that are whole numbers → canonical float (``1.0``)
30
+ - list values → each element normalized recursively
31
+ - strings → unchanged
32
+ """
33
+ if isinstance(v, list):
34
+ return [_normalize_value(x) for x in v]
35
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
36
+ return float(v)
37
+ return v
38
+
39
+
21
40
  def compute_hash(constants: list[Constant]) -> str:
22
41
  """Compute a stable hash of constant name+value pairs.
23
42
 
24
43
  Only hashes names and values (not unit/description) since those
25
44
  are the semantically meaningful content for sync detection.
45
+
46
+ Uses a sorted list of (name, value) tuples instead of a dict to
47
+ correctly handle duplicate constant names (e.g., multi-variant
48
+ struct tables where Motor_X__R_Phase appears once per variant).
49
+ A dict would silently discard all but the last duplicate, making
50
+ edits to earlier variants invisible to change detection.
51
+
52
+ Numeric values are normalized to float so that ``int 1`` and
53
+ ``float 1.0`` hash identically — this is critical for xlsx
54
+ round-trips where Excel drops the ``.0`` from whole numbers.
26
55
  """
27
- key = json.dumps(
28
- {c.name: c.value for c in constants},
29
- sort_keys=True,
30
- default=str,
56
+ pairs = sorted(
57
+ (c.name, _normalize_value(c.value)) for c in constants
31
58
  )
59
+ key = json.dumps(pairs, default=str)
32
60
  return hashlib.md5(key.encode()).hexdigest()
33
61
 
34
62
 
@@ -6,6 +6,11 @@ No `brew install` required — pure Python.
6
6
  Event handling:
7
7
  - Changes during debounce are QUEUED, not dropped
8
8
  - After debounce expires, all queued events are coalesced into one sync
9
+ - Write suppression prevents ping-pong loops (sync-written files are
10
+ ignored for a short window so they don't re-trigger sync)
11
+ - Direction is NEVER forced — the state engine auto-detects which side
12
+ changed using stored hashes, which correctly returns "already in sync"
13
+ for files that were just written by the sync itself
9
14
  - Lock conflicts trigger automatic retry after a short delay
10
15
  - Errors are logged but the watcher continues (resilient)
11
16
  - On startup, a full sync is run to recover from any drift
@@ -29,6 +34,10 @@ logger = logging.getLogger(__name__)
29
34
  LOCK_RETRY_ATTEMPTS = 3
30
35
  LOCK_RETRY_DELAY = 2.0 # seconds between retries
31
36
 
37
+ # Write suppression: ignore events on files we just wrote for this duration (seconds).
38
+ # Must be longer than filesystem event propagation but shorter than user interaction.
39
+ WRITE_SUPPRESSION_WINDOW = 2.0
40
+
32
41
 
33
42
  def start_watcher(
34
43
  config_path: str | Path | None = None,
@@ -84,10 +93,14 @@ def start_watcher(
84
93
  click.echo(f"[startup] ⚠️ Startup sync failed: {e} — continuing in watch mode.\n")
85
94
 
86
95
  # ── Event queue (thread-safe) — changes are QUEUED, never dropped ──
87
- pending_changes: dict[Path, str] = {} # path → forced direction
96
+ pending_changes: dict[Path, str] = {} # path → info about what changed
88
97
  pending_lock = threading.Lock()
89
98
  last_sync_time: float = 0
90
99
 
100
+ # ── Write suppression — tracks files recently written by sync ──
101
+ recently_written: dict[Path, float] = {} # path → timestamp of sync write
102
+ written_lock = threading.Lock()
103
+
91
104
  class SyncHandler(FileSystemEventHandler):
92
105
  def on_modified(self, event):
93
106
  if event.is_directory:
@@ -97,22 +110,22 @@ def start_watcher(
97
110
  if changed_path not in watch_files:
98
111
  return
99
112
 
100
- # Determine direction based on which file changed
101
- force = None
102
- for m in cfg.mappings:
103
- source_resolved = (config_dir / m.source).resolve()
104
- target_resolved = (config_dir / m.target).resolve()
105
- if changed_path == source_resolved:
106
- force = "source"
107
- break
108
- elif changed_path == target_resolved:
109
- force = "target"
110
- break
111
-
112
- # Queue the event (never drop)
113
+ # Write suppression: skip events caused by sync's own writes
114
+ with written_lock:
115
+ written_time = recently_written.get(changed_path)
116
+ if written_time and (time.time() - written_time) < WRITE_SUPPRESSION_WINDOW:
117
+ logger.debug(
118
+ "Suppressed event for %s (written %.1fs ago by sync)",
119
+ changed_path.name,
120
+ time.time() - written_time,
121
+ )
122
+ return
123
+
124
+ # Queue the event (never drop). Don't force direction — let
125
+ # the state engine auto-detect using stored hashes.
113
126
  with pending_lock:
114
- pending_changes[changed_path] = force
115
- logger.debug("Queued: %s (direction=%s)", changed_path.name, force)
127
+ pending_changes[changed_path] = changed_path.name
128
+ logger.debug("Queued: %s", changed_path.name)
116
129
 
117
130
  def _process_queue():
118
131
  """Process all pending changes in one coalesced sync."""
@@ -125,24 +138,25 @@ def start_watcher(
125
138
  queued = dict(pending_changes)
126
139
  pending_changes.clear()
127
140
 
128
- # Determine overall force direction (if all queued point same way)
129
- directions = set(queued.values())
130
- if len(directions) == 1:
131
- force = directions.pop()
132
- else:
133
- force = None # mixed — let engine auto-detect
134
-
135
141
  rel_names = [p.name for p in queued.keys()]
136
142
  timestamp = time.strftime("%H:%M:%S")
137
143
  click.echo(f"[{timestamp}] 📝 {', '.join(rel_names)} changed — syncing...")
138
144
 
145
+ # Never force direction — let the state engine auto-detect which
146
+ # side changed using stored hashes. This prevents ping-pong:
147
+ # after sync writes file B, B's hash matches the stored hash,
148
+ # so the next cycle correctly detects "already in sync".
149
+
139
150
  # Retry on lock conflict
140
151
  for attempt in range(LOCK_RETRY_ATTEMPTS):
141
152
  try:
142
- reports = sync(config_path=config_path, force_direction=force)
153
+ reports = sync(config_path=config_path)
143
154
  for r in reports:
144
155
  if r.result in (SyncResult.SYNCED_SOURCE_TO_TARGET, SyncResult.SYNCED_TARGET_TO_SOURCE):
145
156
  click.echo(f" ✅ {r.message}")
157
+ # Register written files for suppression so their
158
+ # filesystem events don't re-trigger another sync.
159
+ _suppress_written_files(r, config_dir)
146
160
  elif r.result == SyncResult.ALREADY_IN_SYNC:
147
161
  pass # silent
148
162
  elif r.result == SyncResult.ERROR:
@@ -158,6 +172,27 @@ def start_watcher(
158
172
  logger.error("Watch sync failed: %s", e)
159
173
  return
160
174
 
175
+ def _suppress_written_files(report, conf_dir):
176
+ """Mark files written by sync for suppression so their events are ignored."""
177
+ now = time.time()
178
+ with written_lock:
179
+ # Determine which file was written based on sync direction
180
+ if report.result == SyncResult.SYNCED_SOURCE_TO_TARGET:
181
+ # source → target: target was written
182
+ written_path = (conf_dir / report.target).resolve()
183
+ recently_written[written_path] = now
184
+ elif report.result == SyncResult.SYNCED_TARGET_TO_SOURCE:
185
+ # target → source: source was written
186
+ written_path = (conf_dir / report.source).resolve()
187
+ recently_written[written_path] = now
188
+
189
+ # Prune old entries to prevent memory leak
190
+ with written_lock:
191
+ cutoff = now - WRITE_SUPPRESSION_WINDOW * 3
192
+ stale = [p for p, t in recently_written.items() if t < cutoff]
193
+ for p in stale:
194
+ del recently_written[p]
195
+
161
196
  observer = Observer()
162
197
  handler = SyncHandler()
163
198
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "consync"
7
- version = "2.2.3"
7
+ version = "2.3.1"
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"}