consync 2.0.0__tar.gz → 2.2.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.0.0 → consync-2.2.0}/PKG-INFO +12 -8
- {consync-2.0.0 → consync-2.2.0}/README.md +11 -7
- {consync-2.0.0 → consync-2.2.0}/consync/__init__.py +1 -1
- {consync-2.0.0 → consync-2.2.0}/consync/config.py +32 -1
- {consync-2.0.0 → consync-2.2.0}/consync/parsers/json_parser.py +4 -0
- consync-2.2.0/consync/parsers/xlsx.py +228 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/c_struct_table.py +113 -27
- {consync-2.0.0 → consync-2.2.0}/pyproject.toml +1 -1
- consync-2.2.0/tests/test_bidirectional.py +364 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_cli.py +1 -1
- consync-2.2.0/tests/test_comprehensive_sync.py +1121 -0
- consync-2.0.0/consync/parsers/xlsx.py +0 -116
- {consync-2.0.0 → consync-2.2.0}/.github/CODEOWNERS +0 -0
- {consync-2.0.0 → consync-2.2.0}/.github/copilot-instructions.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/.github/dependabot.yml +0 -0
- {consync-2.0.0 → consync-2.2.0}/.github/workflows/ci.yml +0 -0
- {consync-2.0.0 → consync-2.2.0}/.github/workflows/codeql.yml +0 -0
- {consync-2.0.0 → consync-2.2.0}/.github/workflows/publish.yml +0 -0
- {consync-2.0.0 → consync-2.2.0}/.github/workflows/release.yml +0 -0
- {consync-2.0.0 → consync-2.2.0}/.gitignore +0 -0
- {consync-2.0.0 → consync-2.2.0}/CLAUDE.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/CONTRIBUTING.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/FAQ.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/LICENSE +0 -0
- {consync-2.0.0 → consync-2.2.0}/SECURITY.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/TODO.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/assets/demo.gif +0 -0
- {consync-2.0.0 → consync-2.2.0}/assets/demo.tape +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/backup.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/cli.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/hooks.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/lock.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/logging_config.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/models.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/parsers/__init__.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/parsers/c_header.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/parsers/c_struct_table.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/parsers/csv_parser.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/parsers/toml_parser.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/precision.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/__init__.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/c_header.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/csharp.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/csv_renderer.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/json_renderer.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/python_const.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/rust_const.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/verilog.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/renderers/vhdl.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/state.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/sync.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/validators.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/consync/watcher.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/examples/fpga/.consync.yaml +0 -0
- {consync-2.0.0 → consync-2.2.0}/examples/fpga/design_params.csv +0 -0
- {consync-2.0.0 → consync-2.2.0}/examples/hardware/.consync.yaml +0 -0
- {consync-2.0.0 → consync-2.2.0}/examples/hardware/constants.csv +0 -0
- {consync-2.0.0 → consync-2.2.0}/examples/multilang/.consync.yaml +0 -0
- {consync-2.0.0 → consync-2.2.0}/examples/multilang/constants.json +0 -0
- {consync-2.0.0 → consync-2.2.0}/npm/.npmrc +0 -0
- {consync-2.0.0 → consync-2.2.0}/npm/LICENSE +0 -0
- {consync-2.0.0 → consync-2.2.0}/npm/README.md +0 -0
- {consync-2.0.0 → consync-2.2.0}/npm/bin/consync.js +0 -0
- {consync-2.0.0 → consync-2.2.0}/npm/package.json +0 -0
- {consync-2.0.0 → consync-2.2.0}/npm/scripts/install.js +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/__init__.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_arrays.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_c_struct_table.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_embedded.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_parsers.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_precision.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_renderers.py +0 -0
- {consync-2.0.0 → consync-2.2.0}/tests/test_safety.py +0 -0
- {consync-2.0.0 → consync-2.2.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.2.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
|
|
@@ -70,10 +70,11 @@ Keep a single source of truth for hardware constants (resistor values, timing pa
|
|
|
70
70
|
│ Parsers │ │ State │ │ Renderers │
|
|
71
71
|
│ │ │ Tracker │ │ │
|
|
72
72
|
│ • xlsx │ │ │ │ • c_header │
|
|
73
|
-
│ • csv │ │ MD5 hashes │ │ •
|
|
74
|
-
│ • json │ │ per-mapping │ │ •
|
|
75
|
-
│ • toml │ │ in .json │ │ •
|
|
76
|
-
│ • c_header │ │ │ │ •
|
|
73
|
+
│ • csv │ │ MD5 hashes │ │ • c_struct_table│
|
|
74
|
+
│ • json │ │ per-mapping │ │ • csharp │
|
|
75
|
+
│ • toml │ │ in .json │ │ • python │
|
|
76
|
+
│ • c_header │ │ │ │ • rust │
|
|
77
|
+
│ • c_struct_table │ │ │ │ • verilog │
|
|
77
78
|
└────────┬────────┘ └──────┬──────┘ │ • vhdl │
|
|
78
79
|
│ │ │ • json │
|
|
79
80
|
│ │ │ • csv │
|
|
@@ -200,17 +201,19 @@ consync install-hook
|
|
|
200
201
|
|
|
201
202
|
| Format | Extension | Notes |
|
|
202
203
|
|--------|-----------|-------|
|
|
203
|
-
| Excel | `.xlsx` | Auto-detects
|
|
204
|
+
| Excel | `.xlsx` | Auto-detects flat or table layout (multi-variant) |
|
|
204
205
|
| CSV | `.csv`, `.tsv` | Auto-detects delimiter |
|
|
205
|
-
| JSON | `.json` | Flat, array, or
|
|
206
|
+
| JSON | `.json` | Flat, array, nested, or structured (`_meta` + `constants`) |
|
|
206
207
|
| TOML | `.toml` | Flat or table-with-metadata |
|
|
207
208
|
| C Header | `.h` | Parses `const`, `#define`, hex/int/float |
|
|
209
|
+
| C Struct Table | `.c` | Multi-variant `#if`/`#elif` struct arrays (Bosch-style) |
|
|
208
210
|
|
|
209
211
|
### Targets (output)
|
|
210
212
|
|
|
211
213
|
| Format | Extension | Features |
|
|
212
214
|
|--------|-----------|----------|
|
|
213
215
|
| C Header | `.h` | `const` or `#define`, `static`, `stdint.h` types, hex |
|
|
216
|
+
| C Struct Table | `.c` | In-place update of multi-variant struct arrays (minimal diffs) |
|
|
214
217
|
| C# | `.cs` | `namespace`, `public static class`, XML doc comments |
|
|
215
218
|
| Python | `.py` | Type annotations (`float`/`int`), inline comments |
|
|
216
219
|
| Rust | `.rs` | `pub const`, `f64`/`i64`, doc comments |
|
|
@@ -218,6 +221,7 @@ consync install-hook
|
|
|
218
221
|
| VHDL | `.vhd` | Package with `ieee.math_real`, typed constants |
|
|
219
222
|
| JSON | `.json` | Structured with `_meta` header |
|
|
220
223
|
| CSV | `.csv` | Round-trip back to spreadsheet |
|
|
224
|
+
| Excel | `.xlsx` | Multi-sheet table layout (one sheet per variant) |
|
|
221
225
|
|
|
222
226
|
---
|
|
223
227
|
|
|
@@ -581,7 +585,7 @@ Each entry records: timestamp, user, direction, files, all constant names+values
|
|
|
581
585
|
git clone https://github.com/naveenkumarbaskaran/consync.git
|
|
582
586
|
cd consync
|
|
583
587
|
pip install -e ".[dev]"
|
|
584
|
-
pytest #
|
|
588
|
+
pytest # 261 tests
|
|
585
589
|
```
|
|
586
590
|
|
|
587
591
|
---
|
|
@@ -35,10 +35,11 @@ Keep a single source of truth for hardware constants (resistor values, timing pa
|
|
|
35
35
|
│ Parsers │ │ State │ │ Renderers │
|
|
36
36
|
│ │ │ Tracker │ │ │
|
|
37
37
|
│ • xlsx │ │ │ │ • c_header │
|
|
38
|
-
│ • csv │ │ MD5 hashes │ │ •
|
|
39
|
-
│ • json │ │ per-mapping │ │ •
|
|
40
|
-
│ • toml │ │ in .json │ │ •
|
|
41
|
-
│ • c_header │ │ │ │ •
|
|
38
|
+
│ • csv │ │ MD5 hashes │ │ • c_struct_table│
|
|
39
|
+
│ • json │ │ per-mapping │ │ • csharp │
|
|
40
|
+
│ • toml │ │ in .json │ │ • python │
|
|
41
|
+
│ • c_header │ │ │ │ • rust │
|
|
42
|
+
│ • c_struct_table │ │ │ │ • verilog │
|
|
42
43
|
└────────┬────────┘ └──────┬──────┘ │ • vhdl │
|
|
43
44
|
│ │ │ • json │
|
|
44
45
|
│ │ │ • csv │
|
|
@@ -165,17 +166,19 @@ consync install-hook
|
|
|
165
166
|
|
|
166
167
|
| Format | Extension | Notes |
|
|
167
168
|
|--------|-----------|-------|
|
|
168
|
-
| Excel | `.xlsx` | Auto-detects
|
|
169
|
+
| Excel | `.xlsx` | Auto-detects flat or table layout (multi-variant) |
|
|
169
170
|
| CSV | `.csv`, `.tsv` | Auto-detects delimiter |
|
|
170
|
-
| JSON | `.json` | Flat, array, or
|
|
171
|
+
| JSON | `.json` | Flat, array, nested, or structured (`_meta` + `constants`) |
|
|
171
172
|
| TOML | `.toml` | Flat or table-with-metadata |
|
|
172
173
|
| C Header | `.h` | Parses `const`, `#define`, hex/int/float |
|
|
174
|
+
| C Struct Table | `.c` | Multi-variant `#if`/`#elif` struct arrays (Bosch-style) |
|
|
173
175
|
|
|
174
176
|
### Targets (output)
|
|
175
177
|
|
|
176
178
|
| Format | Extension | Features |
|
|
177
179
|
|--------|-----------|----------|
|
|
178
180
|
| C Header | `.h` | `const` or `#define`, `static`, `stdint.h` types, hex |
|
|
181
|
+
| C Struct Table | `.c` | In-place update of multi-variant struct arrays (minimal diffs) |
|
|
179
182
|
| C# | `.cs` | `namespace`, `public static class`, XML doc comments |
|
|
180
183
|
| Python | `.py` | Type annotations (`float`/`int`), inline comments |
|
|
181
184
|
| Rust | `.rs` | `pub const`, `f64`/`i64`, doc comments |
|
|
@@ -183,6 +186,7 @@ consync install-hook
|
|
|
183
186
|
| VHDL | `.vhd` | Package with `ieee.math_real`, typed constants |
|
|
184
187
|
| JSON | `.json` | Structured with `_meta` header |
|
|
185
188
|
| CSV | `.csv` | Round-trip back to spreadsheet |
|
|
189
|
+
| Excel | `.xlsx` | Multi-sheet table layout (one sheet per variant) |
|
|
186
190
|
|
|
187
191
|
---
|
|
188
192
|
|
|
@@ -546,7 +550,7 @@ Each entry records: timestamp, user, direction, files, all constant names+values
|
|
|
546
550
|
git clone https://github.com/naveenkumarbaskaran/consync.git
|
|
547
551
|
cd consync
|
|
548
552
|
pip install -e ".[dev]"
|
|
549
|
-
pytest #
|
|
553
|
+
pytest # 261 tests
|
|
550
554
|
```
|
|
551
555
|
|
|
552
556
|
---
|
|
@@ -80,6 +80,26 @@ def _parse_direction(raw: str) -> SyncDirection:
|
|
|
80
80
|
return mapping[normalized]
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _format_matches_extension(format_name: str, filepath: str) -> bool:
|
|
84
|
+
"""Check if a format name is compatible with a file's extension."""
|
|
85
|
+
ext = Path(filepath).suffix.lower()
|
|
86
|
+
# Map format names to compatible extensions
|
|
87
|
+
FORMAT_COMPATIBLE_EXTENSIONS: dict[str, set[str]] = {
|
|
88
|
+
"c_header": {".c", ".h", ".hpp", ".hh"},
|
|
89
|
+
"c_struct_table": {".c", ".h", ".hpp", ".hh"},
|
|
90
|
+
"xlsx": {".xlsx", ".xls"},
|
|
91
|
+
"csv": {".csv"},
|
|
92
|
+
"json": {".json"},
|
|
93
|
+
"verilog": {".v", ".sv"},
|
|
94
|
+
"vhdl": {".vhd", ".vhdl"},
|
|
95
|
+
"python": {".py"},
|
|
96
|
+
"rust": {".rs"},
|
|
97
|
+
"csharp": {".cs"},
|
|
98
|
+
}
|
|
99
|
+
compatible = FORMAT_COMPATIBLE_EXTENSIONS.get(format_name, set())
|
|
100
|
+
return ext in compatible
|
|
101
|
+
|
|
102
|
+
|
|
83
103
|
def _parse_mapping(raw: dict[str, Any], config_dir: Path) -> MappingConfig:
|
|
84
104
|
"""Parse a single mapping entry from YAML."""
|
|
85
105
|
source = raw.get("source", "")
|
|
@@ -90,8 +110,19 @@ def _parse_mapping(raw: dict[str, Any], config_dir: Path) -> MappingConfig:
|
|
|
90
110
|
if not target:
|
|
91
111
|
raise ValueError("Each mapping must have a 'target' field.")
|
|
92
112
|
|
|
113
|
+
generic_format = raw.get("format", "")
|
|
93
114
|
source_format = raw.get("source_format", "") or raw.get("format_source", "")
|
|
94
|
-
target_format = raw.get("target_format", "") or raw.get("format_target", "")
|
|
115
|
+
target_format = raw.get("target_format", "") or raw.get("format_target", "")
|
|
116
|
+
|
|
117
|
+
# If generic 'format' is specified, assign it to the side whose extension matches
|
|
118
|
+
if generic_format:
|
|
119
|
+
if not source_format and _format_matches_extension(generic_format, source):
|
|
120
|
+
source_format = generic_format
|
|
121
|
+
elif not target_format and _format_matches_extension(generic_format, target):
|
|
122
|
+
target_format = generic_format
|
|
123
|
+
elif not target_format:
|
|
124
|
+
# Legacy fallback: assign to target_format (backward compat)
|
|
125
|
+
target_format = generic_format
|
|
95
126
|
|
|
96
127
|
# Auto-detect formats if not specified
|
|
97
128
|
if not source_format:
|
|
@@ -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 = {
|
|
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
|
-
|
|
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.
|
|
7
|
+
version = "2.2.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"}
|