consync 2.0.0__tar.gz → 2.1.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.0.0 → consync-2.1.0}/PKG-INFO +1 -1
  2. {consync-2.0.0 → consync-2.1.0}/consync/__init__.py +1 -1
  3. {consync-2.0.0 → consync-2.1.0}/consync/parsers/json_parser.py +4 -0
  4. consync-2.1.0/consync/parsers/xlsx.py +228 -0
  5. {consync-2.0.0 → consync-2.1.0}/consync/renderers/c_struct_table.py +113 -27
  6. {consync-2.0.0 → consync-2.1.0}/pyproject.toml +1 -1
  7. consync-2.1.0/tests/test_bidirectional.py +364 -0
  8. {consync-2.0.0 → consync-2.1.0}/tests/test_cli.py +1 -1
  9. consync-2.1.0/tests/test_comprehensive_sync.py +1121 -0
  10. consync-2.0.0/consync/parsers/xlsx.py +0 -116
  11. {consync-2.0.0 → consync-2.1.0}/.github/CODEOWNERS +0 -0
  12. {consync-2.0.0 → consync-2.1.0}/.github/copilot-instructions.md +0 -0
  13. {consync-2.0.0 → consync-2.1.0}/.github/dependabot.yml +0 -0
  14. {consync-2.0.0 → consync-2.1.0}/.github/workflows/ci.yml +0 -0
  15. {consync-2.0.0 → consync-2.1.0}/.github/workflows/codeql.yml +0 -0
  16. {consync-2.0.0 → consync-2.1.0}/.github/workflows/publish.yml +0 -0
  17. {consync-2.0.0 → consync-2.1.0}/.github/workflows/release.yml +0 -0
  18. {consync-2.0.0 → consync-2.1.0}/.gitignore +0 -0
  19. {consync-2.0.0 → consync-2.1.0}/CLAUDE.md +0 -0
  20. {consync-2.0.0 → consync-2.1.0}/CONTRIBUTING.md +0 -0
  21. {consync-2.0.0 → consync-2.1.0}/FAQ.md +0 -0
  22. {consync-2.0.0 → consync-2.1.0}/LICENSE +0 -0
  23. {consync-2.0.0 → consync-2.1.0}/README.md +0 -0
  24. {consync-2.0.0 → consync-2.1.0}/SECURITY.md +0 -0
  25. {consync-2.0.0 → consync-2.1.0}/TODO.md +0 -0
  26. {consync-2.0.0 → consync-2.1.0}/assets/demo.gif +0 -0
  27. {consync-2.0.0 → consync-2.1.0}/assets/demo.tape +0 -0
  28. {consync-2.0.0 → consync-2.1.0}/consync/backup.py +0 -0
  29. {consync-2.0.0 → consync-2.1.0}/consync/cli.py +0 -0
  30. {consync-2.0.0 → consync-2.1.0}/consync/config.py +0 -0
  31. {consync-2.0.0 → consync-2.1.0}/consync/hooks.py +0 -0
  32. {consync-2.0.0 → consync-2.1.0}/consync/lock.py +0 -0
  33. {consync-2.0.0 → consync-2.1.0}/consync/logging_config.py +0 -0
  34. {consync-2.0.0 → consync-2.1.0}/consync/models.py +0 -0
  35. {consync-2.0.0 → consync-2.1.0}/consync/parsers/__init__.py +0 -0
  36. {consync-2.0.0 → consync-2.1.0}/consync/parsers/c_header.py +0 -0
  37. {consync-2.0.0 → consync-2.1.0}/consync/parsers/c_struct_table.py +0 -0
  38. {consync-2.0.0 → consync-2.1.0}/consync/parsers/csv_parser.py +0 -0
  39. {consync-2.0.0 → consync-2.1.0}/consync/parsers/toml_parser.py +0 -0
  40. {consync-2.0.0 → consync-2.1.0}/consync/precision.py +0 -0
  41. {consync-2.0.0 → consync-2.1.0}/consync/renderers/__init__.py +0 -0
  42. {consync-2.0.0 → consync-2.1.0}/consync/renderers/c_header.py +0 -0
  43. {consync-2.0.0 → consync-2.1.0}/consync/renderers/csharp.py +0 -0
  44. {consync-2.0.0 → consync-2.1.0}/consync/renderers/csv_renderer.py +0 -0
  45. {consync-2.0.0 → consync-2.1.0}/consync/renderers/json_renderer.py +0 -0
  46. {consync-2.0.0 → consync-2.1.0}/consync/renderers/python_const.py +0 -0
  47. {consync-2.0.0 → consync-2.1.0}/consync/renderers/rust_const.py +0 -0
  48. {consync-2.0.0 → consync-2.1.0}/consync/renderers/verilog.py +0 -0
  49. {consync-2.0.0 → consync-2.1.0}/consync/renderers/vhdl.py +0 -0
  50. {consync-2.0.0 → consync-2.1.0}/consync/state.py +0 -0
  51. {consync-2.0.0 → consync-2.1.0}/consync/sync.py +0 -0
  52. {consync-2.0.0 → consync-2.1.0}/consync/validators.py +0 -0
  53. {consync-2.0.0 → consync-2.1.0}/consync/watcher.py +0 -0
  54. {consync-2.0.0 → consync-2.1.0}/examples/fpga/.consync.yaml +0 -0
  55. {consync-2.0.0 → consync-2.1.0}/examples/fpga/design_params.csv +0 -0
  56. {consync-2.0.0 → consync-2.1.0}/examples/hardware/.consync.yaml +0 -0
  57. {consync-2.0.0 → consync-2.1.0}/examples/hardware/constants.csv +0 -0
  58. {consync-2.0.0 → consync-2.1.0}/examples/multilang/.consync.yaml +0 -0
  59. {consync-2.0.0 → consync-2.1.0}/examples/multilang/constants.json +0 -0
  60. {consync-2.0.0 → consync-2.1.0}/npm/.npmrc +0 -0
  61. {consync-2.0.0 → consync-2.1.0}/npm/LICENSE +0 -0
  62. {consync-2.0.0 → consync-2.1.0}/npm/README.md +0 -0
  63. {consync-2.0.0 → consync-2.1.0}/npm/bin/consync.js +0 -0
  64. {consync-2.0.0 → consync-2.1.0}/npm/package.json +0 -0
  65. {consync-2.0.0 → consync-2.1.0}/npm/scripts/install.js +0 -0
  66. {consync-2.0.0 → consync-2.1.0}/tests/__init__.py +0 -0
  67. {consync-2.0.0 → consync-2.1.0}/tests/test_arrays.py +0 -0
  68. {consync-2.0.0 → consync-2.1.0}/tests/test_c_struct_table.py +0 -0
  69. {consync-2.0.0 → consync-2.1.0}/tests/test_embedded.py +0 -0
  70. {consync-2.0.0 → consync-2.1.0}/tests/test_parsers.py +0 -0
  71. {consync-2.0.0 → consync-2.1.0}/tests/test_precision.py +0 -0
  72. {consync-2.0.0 → consync-2.1.0}/tests/test_renderers.py +0 -0
  73. {consync-2.0.0 → consync-2.1.0}/tests/test_safety.py +0 -0
  74. {consync-2.0.0 → consync-2.1.0}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: consync
3
- Version: 2.0.0
3
+ Version: 2.1.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.0.0"
3
+ __version__ = "2.1.0"
4
4
 
5
5
  from consync.models import Constant, SyncDirection
6
6
  from consync.config import load_config
@@ -49,6 +49,10 @@ def parse_json(filepath: str | Path, **kwargs) -> list[Constant]:
49
49
 
50
50
  # Format A or C: object
51
51
  if isinstance(data, dict):
52
+ # Format D: consync renderer output {"_meta": {...}, "constants": [...]}
53
+ if "constants" in data and isinstance(data["constants"], list):
54
+ return _parse_array(data["constants"])
55
+
52
56
  # Check first value to distinguish A vs C
53
57
  first_val = next(iter(data.values()), None) if data else None
54
58
  if isinstance(first_val, dict):
@@ -0,0 +1,228 @@
1
+ """Excel (.xlsx) parser — reads constants from a spreadsheet.
2
+
3
+ Supports two layouts:
4
+
5
+ 1. **Flat layout** (default for c_header, csv, etc.):
6
+ Row 1: Header row (Name, Value, Unit, Description)
7
+ Row 2+: Data rows
8
+
9
+ 2. **Table layout** (for c_struct_table):
10
+ Row 1: Motor Variant | field1 | field2 | field3 | ...
11
+ Row 2+: variant_name | val1 | val2 | val3 | ...
12
+ - Multiple sheets = multiple variants
13
+ - Auto-detected when first column header matches "Motor Variant"
14
+ - Reconstructs full metadata for bidirectional sync with C files
15
+
16
+ Column mapping is flexible — auto-detects by header names.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from pathlib import Path
23
+
24
+ from consync.models import Constant
25
+ from consync.parsers import register
26
+
27
+
28
+ # Column header aliases (case-insensitive matching)
29
+ NAME_ALIASES = {"name", "constant", "parameter", "variable", "symbol", "id"}
30
+ VALUE_ALIASES = {"value", "val", "data", "number", "amount"}
31
+ UNIT_ALIASES = {"unit", "units", "uom", "dimension"}
32
+ DESC_ALIASES = {"description", "desc", "comment", "note", "notes", "info"}
33
+
34
+ # Table layout detection
35
+ TABLE_FIRST_COL_ALIASES = {"motor variant", "variant", "row", "label", "name"}
36
+
37
+
38
+ def _find_column(headers: list[str], aliases: set[str]) -> int | None:
39
+ """Find column index matching any alias (case-insensitive)."""
40
+ for i, h in enumerate(headers):
41
+ if h and h.strip().lower() in aliases:
42
+ return i
43
+ return None
44
+
45
+
46
+ def _is_table_layout(ws) -> bool:
47
+ """Detect if a worksheet uses table layout (first col = Motor Variant)."""
48
+ first_header = ws.cell(1, 1).value
49
+ if first_header and str(first_header).strip().lower() in TABLE_FIRST_COL_ALIASES:
50
+ # Additional check: second column should NOT be "Value"
51
+ second_header = ws.cell(1, 2).value
52
+ if second_header and str(second_header).strip().lower() in VALUE_ALIASES:
53
+ return False
54
+ return True
55
+ return False
56
+
57
+
58
+ def _sanitize_label(label: str) -> str:
59
+ """Convert a row label to a valid C-style identifier prefix."""
60
+ sanitized = re.sub(r"[^a-zA-Z0-9]+", "_", label.strip())
61
+ return sanitized.strip("_")
62
+
63
+
64
+ @register("xlsx")
65
+ def parse_xlsx(filepath: str | Path, **kwargs) -> list[Constant]:
66
+ """Parse constants from an Excel file.
67
+
68
+ Auto-detects layout:
69
+ - Table layout → reads all sheets, reconstructs row_label/field metadata
70
+ - Flat layout → reads active sheet as Name/Value/Unit/Description
71
+
72
+ Args:
73
+ filepath: Path to .xlsx file.
74
+ sheet: Sheet name or index (default: auto).
75
+
76
+ Returns:
77
+ List of Constant objects.
78
+ """
79
+ import openpyxl
80
+
81
+ filepath = Path(filepath)
82
+ if not filepath.exists():
83
+ raise FileNotFoundError(f"Excel file not found: {filepath}")
84
+
85
+ wb = openpyxl.load_workbook(filepath, data_only=True)
86
+
87
+ # Check if this is table layout by examining the first data sheet
88
+ data_sheets = [s for s in wb.sheetnames if s.lower() != "info"]
89
+ if data_sheets:
90
+ first_ws = wb[data_sheets[0]]
91
+ if _is_table_layout(first_ws):
92
+ return _parse_table_layout(wb, data_sheets, **kwargs)
93
+
94
+ # Fall back to flat layout
95
+ return _parse_flat_layout(wb, **kwargs)
96
+
97
+
98
+ def _parse_table_layout(wb, data_sheets: list[str], **kwargs) -> list[Constant]:
99
+ """Parse table-layout Excel (one sheet per variant, rows=motors, cols=fields)."""
100
+ constants: list[Constant] = []
101
+
102
+ for sheet_name in data_sheets:
103
+ ws = wb[sheet_name]
104
+ variant = sheet_name # Sheet name = variant name
105
+
106
+ # Read field names from header row (skip first column = "Motor Variant")
107
+ headers = []
108
+ for col in range(2, ws.max_column + 1):
109
+ h = ws.cell(1, col).value
110
+ headers.append(str(h).strip() if h else f"field_{col - 2}")
111
+
112
+ # Read data rows
113
+ for row in range(2, ws.max_row + 1):
114
+ row_label = ws.cell(row, 1).value
115
+ if not row_label:
116
+ continue
117
+ row_label = str(row_label).strip()
118
+ name_prefix = _sanitize_label(row_label)
119
+
120
+ for col_idx, field_name in enumerate(headers):
121
+ cell_value = ws.cell(row, col_idx + 2).value
122
+ if cell_value is None:
123
+ continue
124
+
125
+ const_name = f"{name_prefix}__{field_name}"
126
+
127
+ # Determine value type
128
+ if isinstance(cell_value, (int, float)):
129
+ value = cell_value
130
+ is_expression = False
131
+ else:
132
+ str_val = str(cell_value).strip()
133
+ # Try numeric parse
134
+ try:
135
+ value = int(str_val)
136
+ is_expression = False
137
+ except ValueError:
138
+ try:
139
+ value = float(str_val)
140
+ is_expression = False
141
+ except ValueError:
142
+ value = str_val
143
+ is_expression = str_val.upper() not in ("TRUE", "FALSE")
144
+
145
+ constants.append(Constant(
146
+ name=const_name,
147
+ value=value,
148
+ unit="",
149
+ description=f"Row: {row_label}, Field: {field_name}",
150
+ metadata={
151
+ "row_label": row_label,
152
+ "field": field_name,
153
+ "field_index": col_idx,
154
+ "variant": variant,
155
+ "is_expression": is_expression,
156
+ },
157
+ ))
158
+
159
+ return constants
160
+
161
+
162
+ def _parse_flat_layout(wb, **kwargs) -> list[Constant]:
163
+ """Parse flat-layout Excel (Name/Value/Unit/Description columns)."""
164
+ sheet = kwargs.get("sheet")
165
+ if sheet is not None:
166
+ if isinstance(sheet, int):
167
+ ws = wb.worksheets[sheet]
168
+ else:
169
+ ws = wb[sheet]
170
+ else:
171
+ ws = wb.active
172
+
173
+ # Read header row
174
+ header_row = [str(cell.value or "").strip() for cell in ws[1]]
175
+
176
+ # Auto-detect columns
177
+ name_col = _find_column(header_row, NAME_ALIASES)
178
+ value_col = _find_column(header_row, VALUE_ALIASES)
179
+ unit_col = _find_column(header_row, UNIT_ALIASES)
180
+ desc_col = _find_column(header_row, DESC_ALIASES)
181
+
182
+ # Fallback to positional: A=Name, B=Value, C=Unit, D=Description
183
+ if name_col is None:
184
+ name_col = 0
185
+ if value_col is None:
186
+ value_col = 1
187
+ if unit_col is None:
188
+ unit_col = 2 if len(header_row) > 2 else None
189
+ if desc_col is None:
190
+ desc_col = 3 if len(header_row) > 3 else None
191
+
192
+ constants: list[Constant] = []
193
+ for row in ws.iter_rows(min_row=2, values_only=True):
194
+ if not row or row[name_col] is None:
195
+ continue
196
+
197
+ name = str(row[name_col]).strip()
198
+ if not name:
199
+ continue
200
+
201
+ raw_value = row[value_col] if value_col < len(row) else None
202
+ if raw_value is None:
203
+ continue
204
+
205
+ # Preserve numeric types
206
+ if isinstance(raw_value, (int, float)):
207
+ value = raw_value
208
+ else:
209
+ # Try to parse as number
210
+ try:
211
+ value = int(str(raw_value))
212
+ except ValueError:
213
+ try:
214
+ value = float(str(raw_value))
215
+ except ValueError:
216
+ value = str(raw_value)
217
+
218
+ unit = ""
219
+ if unit_col is not None and unit_col < len(row) and row[unit_col]:
220
+ unit = str(row[unit_col]).strip()
221
+
222
+ description = ""
223
+ if desc_col is not None and desc_col < len(row) and row[desc_col]:
224
+ description = str(row[desc_col]).strip()
225
+
226
+ constants.append(Constant(name=name, value=value, unit=unit, description=description))
227
+
228
+ return constants
@@ -108,6 +108,10 @@ def render_c_struct_table(
108
108
  Only updates literal numeric values that match by row label and field index.
109
109
  Expression values and non-matching fields are left unchanged.
110
110
 
111
+ When constants lack "raw" metadata (e.g., coming from Excel), the renderer
112
+ first parses the existing C file to get current raw values, then uses those
113
+ for pattern-based replacement — only updating values that actually changed.
114
+
111
115
  Args:
112
116
  constants: List of Constant objects (as produced by the parser).
113
117
  filepath: Path to the existing .c/.h file to update in place.
@@ -120,8 +124,8 @@ def render_c_struct_table(
120
124
  f"This renderer only updates existing files in-place."
121
125
  )
122
126
 
123
- # Build a lookup: (sanitized_label, field_index) → Constant
124
- updates: dict[tuple[str, int], Constant] = {}
127
+ # Build a lookup: (variant, sanitized_label, field_index) → Constant
128
+ updates: dict[tuple[str, str, int], Constant] = {}
125
129
  for c in constants:
126
130
  meta = c.metadata
127
131
  if not meta:
@@ -130,26 +134,48 @@ def render_c_struct_table(
130
134
  continue # Don't try to update expressions
131
135
  label = meta.get("row_label", "")
132
136
  field_idx = meta.get("field_index")
137
+ variant = meta.get("variant", "")
133
138
  if label is not None and field_idx is not None:
134
- key = (_sanitize_label(label), field_idx)
139
+ key = (variant, _sanitize_label(label), field_idx)
135
140
  updates[key] = c
136
141
 
137
142
  if not updates:
138
143
  return # Nothing to update
139
144
 
145
+ # If constants lack "raw" metadata, enrich them from the existing file
146
+ has_raw = any(c.metadata.get("raw") for c in updates.values())
147
+ if not has_raw:
148
+ updates = _enrich_with_raw(updates, filepath, config)
149
+
150
+ if not updates:
151
+ return
152
+
140
153
  text = filepath.read_text(encoding="utf-8")
141
154
  lines = text.splitlines(keepends=True)
142
155
  result_lines: list[str] = []
143
156
 
157
+ # Track current variant section as we scan lines
158
+ current_variant = ""
159
+ # Detect variant from #if/#elif lines using the same logic as the parser
160
+ variant_re = re.compile(r"#(?:if|elif)\s*\(.*?==\s*\w+?_(\w+)\s*\)")
161
+
144
162
  for line in lines:
163
+ # Track variant sections
164
+ variant_match = variant_re.search(line)
165
+ if variant_match:
166
+ current_variant = variant_match.group(1)
167
+
145
168
  # Check if this line has a row label and struct data
146
169
  label_match = _ROW_LABEL_RE.search(line)
147
170
  if label_match and ("{{" in line or ("{" in line and "}" in line)):
148
171
  label = label_match.group(1).strip()
149
172
  sanitized = _sanitize_label(label)
150
173
 
151
- # Check if we have any updates for this row
152
- row_updates = {idx: c for (lbl, idx), c in updates.items() if lbl == sanitized}
174
+ # Check if we have any updates for this row in the current variant
175
+ row_updates = {
176
+ idx: c for (v, lbl, idx), c in updates.items()
177
+ if lbl == sanitized and (v == current_variant or v == "")
178
+ }
153
179
  if row_updates:
154
180
  line = _update_row_values(line, row_updates)
155
181
 
@@ -158,10 +184,91 @@ def render_c_struct_table(
158
184
  filepath.write_text("".join(result_lines), encoding="utf-8")
159
185
 
160
186
 
187
+ def _enrich_with_raw(
188
+ updates: dict[tuple[str, str, int], Constant],
189
+ filepath: Path,
190
+ config=None,
191
+ ) -> dict[tuple[str, str, int], Constant]:
192
+ """Enrich update constants with 'raw' metadata from the existing C file.
193
+
194
+ Parses the current C file to get raw values, then only keeps updates
195
+ where the new value actually differs from the current value.
196
+ Returns a filtered dict with raw metadata populated.
197
+ """
198
+ from consync.parsers.c_struct_table import parse_c_struct_table
199
+
200
+ # Parse the file with parser options from config if available
201
+ parser_opts = {}
202
+ if config and hasattr(config, "parser_options"):
203
+ parser_opts = config.parser_options or {}
204
+
205
+ try:
206
+ current_constants = parse_c_struct_table(filepath, **parser_opts)
207
+ except Exception:
208
+ return {} # Can't parse — skip updates
209
+
210
+ # Build lookup of current state: (variant, sanitized_label, field_index) → Constant
211
+ current_map: dict[tuple[str, str, int], Constant] = {}
212
+ for c in current_constants:
213
+ meta = c.metadata
214
+ if not meta:
215
+ continue
216
+ label = meta.get("row_label", "")
217
+ field_idx = meta.get("field_index")
218
+ variant = meta.get("variant", "")
219
+ if label is not None and field_idx is not None:
220
+ key = (variant, _sanitize_label(label), field_idx)
221
+ current_map[key] = c
222
+
223
+ # Filter: only keep updates where value actually changed
224
+ # Enrich with raw metadata from current file
225
+ enriched: dict[tuple[str, str, int], Constant] = {}
226
+ for key, new_const in updates.items():
227
+ current = current_map.get(key)
228
+ if current is None:
229
+ continue # Not found in current file, skip
230
+
231
+ raw = current.metadata.get("raw", "")
232
+ if not raw:
233
+ continue # No raw value to match against
234
+
235
+ # Check if value actually changed
236
+ if _values_equal(new_const.value, current.value):
237
+ continue # Same value, no update needed
238
+
239
+ # Create enriched constant with raw metadata
240
+ enriched_meta = dict(new_const.metadata)
241
+ enriched_meta["raw"] = raw
242
+ enriched_const = Constant(
243
+ name=new_const.name,
244
+ value=new_const.value,
245
+ unit=new_const.unit,
246
+ description=new_const.description,
247
+ metadata=enriched_meta,
248
+ )
249
+ enriched[key] = enriched_const
250
+
251
+ return enriched
252
+
253
+
254
+ def _values_equal(a, b) -> bool:
255
+ """Compare two values with tolerance for floating point."""
256
+ if isinstance(a, float) and isinstance(b, float):
257
+ if a == 0.0 and b == 0.0:
258
+ return True
259
+ if a == 0.0 or b == 0.0:
260
+ return abs(a - b) < 1e-15
261
+ return abs(a - b) / max(abs(a), abs(b)) < 1e-9
262
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
263
+ return float(a) == float(b)
264
+ return a == b
265
+
266
+
161
267
  def _update_row_values(line: str, row_updates: dict[int, Constant]) -> str:
162
268
  """Update specific field values in a struct initializer row.
163
269
 
164
- Finds the Nth numeric literal in the brace content and replaces it.
270
+ Uses raw-pattern matching: finds the original literal text in the line
271
+ and replaces with the new formatted value.
165
272
  """
166
273
  # Find where the data starts (first {{ after the label comment)
167
274
  brace_start = line.find("{{")
@@ -173,35 +280,14 @@ def _update_row_values(line: str, row_updates: dict[int, Constant]) -> str:
173
280
  prefix = line[:brace_start]
174
281
  data_part = line[brace_start:]
175
282
 
176
- # Walk through data_part, finding all tokens (numeric literals + expressions)
177
- # and tracking field indices
178
- field_idx = 0
179
- result = ""
180
- i = 0
181
- brace_depth = 0
182
- in_value = False
183
- current_value_start = -1
184
-
185
- # We need a smarter approach: tokenize by commas at the correct brace depth
186
- # But for replacement, we iterate through and find numeric literals at each position
187
-
188
- # Strategy: find all numeric literal positions in the data portion,
189
- # tracking which field index they correspond to
190
- # This is approximate — we count comma-separated values at depth 1 or 2
191
-
192
- # Alternative simpler approach: find and replace by matching the original raw value
193
283
  for field_idx, const in row_updates.items():
194
284
  raw_original = const.metadata.get("raw", "")
195
285
  if not raw_original or not isinstance(const.value, (int, float)):
196
286
  continue
197
287
 
198
- # Build a regex pattern that matches the original value (with possible whitespace)
199
288
  escaped = re.escape(raw_original)
200
- # Allow flexible whitespace around it
201
289
  pattern = re.compile(r"(?<![a-zA-Z0-9_.])" + escaped + r"(?![a-zA-Z0-9_.])")
202
-
203
290
  new_value = _format_numeric(const.value, raw_original)
204
- # Replace only the first occurrence in the data part
205
291
  data_part, count = pattern.subn(new_value, data_part, count=1)
206
292
 
207
293
  return prefix + data_part
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "consync"
7
- version = "2.0.0"
7
+ version = "2.1.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"}