consync 0.1.0__py3-none-any.whl
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/__init__.py +9 -0
- consync/backup.py +188 -0
- consync/cli.py +372 -0
- consync/config.py +200 -0
- consync/hooks.py +81 -0
- consync/lock.py +118 -0
- consync/logging_config.py +273 -0
- consync/models.py +104 -0
- consync/parsers/__init__.py +40 -0
- consync/parsers/c_header.py +96 -0
- consync/parsers/csv_parser.py +133 -0
- consync/parsers/json_parser.py +138 -0
- consync/parsers/toml_parser.py +74 -0
- consync/parsers/xlsx.py +116 -0
- consync/precision.py +148 -0
- consync/renderers/__init__.py +49 -0
- consync/renderers/c_header.py +222 -0
- consync/renderers/csharp.py +174 -0
- consync/renderers/csv_renderer.py +46 -0
- consync/renderers/json_renderer.py +71 -0
- consync/renderers/python_const.py +84 -0
- consync/renderers/rust_const.py +90 -0
- consync/renderers/verilog.py +89 -0
- consync/renderers/vhdl.py +94 -0
- consync/state.py +76 -0
- consync/sync.py +458 -0
- consync/validators.py +233 -0
- consync/watcher.py +176 -0
- consync-0.1.0.dist-info/METADATA +590 -0
- consync-0.1.0.dist-info/RECORD +33 -0
- consync-0.1.0.dist-info/WHEEL +4 -0
- consync-0.1.0.dist-info/entry_points.txt +2 -0
- consync-0.1.0.dist-info/licenses/LICENSE +21 -0
consync/models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Core data models for consync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SyncDirection(Enum):
|
|
11
|
+
"""Which direction to sync constants."""
|
|
12
|
+
|
|
13
|
+
SOURCE_TO_TARGET = "source_to_target"
|
|
14
|
+
TARGET_TO_SOURCE = "target_to_source"
|
|
15
|
+
BOTH = "both"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConstantType(Enum):
|
|
19
|
+
"""Supported constant value types."""
|
|
20
|
+
|
|
21
|
+
FLOAT = "float"
|
|
22
|
+
INT = "int"
|
|
23
|
+
STRING = "string"
|
|
24
|
+
ARRAY_INT = "array_int"
|
|
25
|
+
ARRAY_FLOAT = "array_float"
|
|
26
|
+
ARRAY_STRING = "array_string"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Constant:
|
|
31
|
+
"""A single named constant with value and metadata.
|
|
32
|
+
|
|
33
|
+
This is the universal data model — every parser produces these,
|
|
34
|
+
every renderer consumes them.
|
|
35
|
+
|
|
36
|
+
Values can be scalars (int, float, str) or typed arrays (list[int],
|
|
37
|
+
list[float], list[str]) for array constants like lookup tables.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
value: float | int | str | list[int] | list[float] | list[str]
|
|
42
|
+
unit: str = ""
|
|
43
|
+
description: str = ""
|
|
44
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def type(self) -> ConstantType:
|
|
48
|
+
if isinstance(self.value, list):
|
|
49
|
+
if not self.value:
|
|
50
|
+
return ConstantType.ARRAY_INT # empty defaults to int array
|
|
51
|
+
first = self.value[0]
|
|
52
|
+
if isinstance(first, int):
|
|
53
|
+
return ConstantType.ARRAY_INT
|
|
54
|
+
elif isinstance(first, float):
|
|
55
|
+
return ConstantType.ARRAY_FLOAT
|
|
56
|
+
return ConstantType.ARRAY_STRING
|
|
57
|
+
if isinstance(self.value, int):
|
|
58
|
+
return ConstantType.INT
|
|
59
|
+
if isinstance(self.value, float):
|
|
60
|
+
return ConstantType.FLOAT
|
|
61
|
+
return ConstantType.STRING
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_numeric(self) -> bool:
|
|
65
|
+
if isinstance(self.value, list):
|
|
66
|
+
return self.type in (ConstantType.ARRAY_INT, ConstantType.ARRAY_FLOAT)
|
|
67
|
+
return isinstance(self.value, (int, float))
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_array(self) -> bool:
|
|
71
|
+
return isinstance(self.value, list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class MappingConfig:
|
|
76
|
+
"""Configuration for a single source ↔ target mapping."""
|
|
77
|
+
|
|
78
|
+
source: str
|
|
79
|
+
target: str
|
|
80
|
+
source_format: str = "" # auto-detect from extension if empty
|
|
81
|
+
target_format: str = "" # auto-detect from extension if empty
|
|
82
|
+
direction: SyncDirection = SyncDirection.SOURCE_TO_TARGET
|
|
83
|
+
precision: int = 17 # significant digits for floats
|
|
84
|
+
header_guard: str = "" # C header guard name
|
|
85
|
+
namespace: str = "" # for Rust/C++ namespacing
|
|
86
|
+
module_name: str = "" # for Verilog/VHDL module scoping
|
|
87
|
+
prefix: str = "" # prefix added to all constant names
|
|
88
|
+
uppercase_names: bool = True # force UPPER_CASE names in output
|
|
89
|
+
# C/C++ specific options
|
|
90
|
+
output_style: str = "const" # "const" | "define" — #define vs const declaration
|
|
91
|
+
static_const: bool = False # emit "static const" to avoid linker duplicates
|
|
92
|
+
typed_ints: bool = True # use uint32_t/int32_t instead of plain int/double for integers
|
|
93
|
+
# Validation hooks
|
|
94
|
+
validators: dict = field(default_factory=dict) # {name: {min:, max:, type:, pattern:, ...}}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ConsyncConfig:
|
|
99
|
+
"""Root configuration loaded from .consync.yaml."""
|
|
100
|
+
|
|
101
|
+
mappings: list[MappingConfig] = field(default_factory=list)
|
|
102
|
+
state_file: str = ".consync.state.json"
|
|
103
|
+
watch_debounce: float = 2.0 # seconds
|
|
104
|
+
on_conflict: str = "source_wins" # source_wins | target_wins | fail
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Parser registry — maps format names to parser functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from consync.models import Constant
|
|
9
|
+
|
|
10
|
+
# Type for parser functions: (filepath, **kwargs) -> list[Constant]
|
|
11
|
+
ParserFunc = Callable[..., list[Constant]]
|
|
12
|
+
|
|
13
|
+
_REGISTRY: dict[str, ParserFunc] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(name: str):
|
|
17
|
+
"""Decorator to register a parser function."""
|
|
18
|
+
def decorator(func: ParserFunc) -> ParserFunc:
|
|
19
|
+
_REGISTRY[name] = func
|
|
20
|
+
return func
|
|
21
|
+
return decorator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_parser(format_name: str) -> ParserFunc:
|
|
25
|
+
"""Get a parser by format name."""
|
|
26
|
+
if format_name not in _REGISTRY:
|
|
27
|
+
available = ", ".join(sorted(_REGISTRY.keys()))
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unknown parser format '{format_name}'. Available: {available}"
|
|
30
|
+
)
|
|
31
|
+
return _REGISTRY[format_name]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_parsers() -> list[str]:
|
|
35
|
+
"""List all registered parser format names."""
|
|
36
|
+
return sorted(_REGISTRY.keys())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Import all parser modules to trigger registration
|
|
40
|
+
from consync.parsers import xlsx, csv_parser, json_parser, toml_parser, c_header # noqa: E402, F401
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""C/C++ header parser — reads const declarations from .h files.
|
|
2
|
+
|
|
3
|
+
Parses lines matching:
|
|
4
|
+
const double NAME = VALUE; /* unit | description */
|
|
5
|
+
const float NAME = VALUE; // description
|
|
6
|
+
#define NAME VALUE /* unit | description */
|
|
7
|
+
static const int NAME = VALUE;
|
|
8
|
+
|
|
9
|
+
Also handles:
|
|
10
|
+
const double NAME = 1.234e-07; (scientific notation)
|
|
11
|
+
const uint32_t NAME = 0xFF; (hex literals)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from consync.models import Constant
|
|
20
|
+
from consync.parsers import register
|
|
21
|
+
from consync.precision import parse_number
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Patterns for C constant declarations
|
|
25
|
+
_CONST_PATTERN = re.compile(
|
|
26
|
+
r"^\s*(?:static\s+)?(?:const(?:expr)?)\s+"
|
|
27
|
+
r"(?:unsigned\s+|signed\s+)?"
|
|
28
|
+
r"(?:double|float|int|long|uint\d+_t|int\d+_t|size_t)\s+"
|
|
29
|
+
r"(\w+)\s*=\s*([^;]+);\s*"
|
|
30
|
+
r"(?:/[/*]\s*(.*?)(?:\*/)?)?$"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# #define pattern
|
|
34
|
+
_DEFINE_PATTERN = re.compile(
|
|
35
|
+
r"^\s*#define\s+(\w+)\s+([^\s/]+)\s*(?:/[/*]\s*(.*?)(?:\*/)?)?$"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@register("c_header")
|
|
40
|
+
def parse_c_header(filepath: str | Path, **kwargs) -> list[Constant]:
|
|
41
|
+
"""Parse constants from a C/C++ header file.
|
|
42
|
+
|
|
43
|
+
Reads `const type NAME = VALUE;` and `#define NAME VALUE` declarations.
|
|
44
|
+
Extracts unit and description from trailing comments (pipe-separated).
|
|
45
|
+
"""
|
|
46
|
+
filepath = Path(filepath)
|
|
47
|
+
if not filepath.exists():
|
|
48
|
+
raise FileNotFoundError(f"Header file not found: {filepath}")
|
|
49
|
+
|
|
50
|
+
text = filepath.read_text(encoding="utf-8")
|
|
51
|
+
constants: list[Constant] = []
|
|
52
|
+
|
|
53
|
+
for line in text.splitlines():
|
|
54
|
+
const = _try_parse_line(line)
|
|
55
|
+
if const is not None:
|
|
56
|
+
constants.append(const)
|
|
57
|
+
|
|
58
|
+
return constants
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _try_parse_line(line: str) -> Constant | None:
|
|
62
|
+
"""Try to parse a single line as a constant declaration."""
|
|
63
|
+
# Try const declaration first
|
|
64
|
+
m = _CONST_PATTERN.match(line)
|
|
65
|
+
if m:
|
|
66
|
+
return _build_constant(m.group(1), m.group(2), m.group(3))
|
|
67
|
+
|
|
68
|
+
# Try #define
|
|
69
|
+
m = _DEFINE_PATTERN.match(line)
|
|
70
|
+
if m:
|
|
71
|
+
return _build_constant(m.group(1), m.group(2), m.group(3))
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_constant(name: str, raw_value: str, comment: str | None) -> Constant | None:
|
|
77
|
+
"""Build a Constant from parsed components."""
|
|
78
|
+
raw_value = raw_value.strip()
|
|
79
|
+
# Strip C type suffixes (e.g., 1.0f, 100UL) but NOT from hex literals
|
|
80
|
+
if not raw_value.startswith(("0x", "0X")):
|
|
81
|
+
raw_value = raw_value.rstrip("fFlLuU")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
value = parse_number(raw_value)
|
|
85
|
+
except (ValueError, IndexError):
|
|
86
|
+
return None # Skip non-numeric defines
|
|
87
|
+
|
|
88
|
+
unit = ""
|
|
89
|
+
description = ""
|
|
90
|
+
if comment:
|
|
91
|
+
comment = comment.strip().rstrip("*/").strip()
|
|
92
|
+
parts = [p.strip() for p in comment.split("|", 1)]
|
|
93
|
+
unit = parts[0] if len(parts) >= 1 else ""
|
|
94
|
+
description = parts[1] if len(parts) >= 2 else ""
|
|
95
|
+
|
|
96
|
+
return Constant(name=name, value=value, unit=unit, description=description)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""CSV parser — reads constants from comma/tab-separated files.
|
|
2
|
+
|
|
3
|
+
Expected format:
|
|
4
|
+
Row 1: Header (Name, Value, Unit, Description)
|
|
5
|
+
Row 2+: Data
|
|
6
|
+
|
|
7
|
+
Auto-detects delimiter (comma, tab, semicolon).
|
|
8
|
+
|
|
9
|
+
Array values are supported using pipe (|) or semicolon separation within the
|
|
10
|
+
value cell:
|
|
11
|
+
THRESHOLDS,"50|100|150|200|250",bar,Brake pressure thresholds
|
|
12
|
+
GAINS,"1.0|2.5|3.7",,PID gain table
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from consync.models import Constant
|
|
21
|
+
from consync.parsers import register
|
|
22
|
+
from consync.precision import parse_number
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_value(raw: str) -> float | int | str | list[int] | list[float] | list[str]:
|
|
26
|
+
"""Parse a value cell, detecting arrays (pipe or semicolon-separated).
|
|
27
|
+
|
|
28
|
+
Returns a list if the value contains | or ; delimiters with multiple items.
|
|
29
|
+
Otherwise returns a scalar.
|
|
30
|
+
"""
|
|
31
|
+
# Detect array delimiter — pipes first, then semicolons (if not the CSV delimiter itself)
|
|
32
|
+
for delim in ("|", ";"):
|
|
33
|
+
if delim in raw:
|
|
34
|
+
parts = [p.strip() for p in raw.split(delim) if p.strip()]
|
|
35
|
+
if len(parts) >= 2:
|
|
36
|
+
return _parse_array_parts(parts)
|
|
37
|
+
|
|
38
|
+
# Scalar
|
|
39
|
+
try:
|
|
40
|
+
return parse_number(raw)
|
|
41
|
+
except (ValueError, IndexError):
|
|
42
|
+
return raw
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_array_parts(parts: list[str]) -> list[int] | list[float] | list[str]:
|
|
46
|
+
"""Parse a list of string parts into a typed array."""
|
|
47
|
+
# Try all-int first
|
|
48
|
+
try:
|
|
49
|
+
return [int(p, 16) if p.lower().startswith("0x") else int(p) for p in parts]
|
|
50
|
+
except (ValueError, TypeError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# Try all-float
|
|
54
|
+
try:
|
|
55
|
+
return [float(p) for p in parts]
|
|
56
|
+
except (ValueError, TypeError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Fallback: string array
|
|
60
|
+
return parts
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@register("csv")
|
|
64
|
+
def parse_csv(filepath: str | Path, **kwargs) -> list[Constant]:
|
|
65
|
+
"""Parse constants from a CSV file.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
filepath: Path to .csv file.
|
|
69
|
+
delimiter: Override delimiter (default: auto-detect).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of Constant objects.
|
|
73
|
+
"""
|
|
74
|
+
filepath = Path(filepath)
|
|
75
|
+
if not filepath.exists():
|
|
76
|
+
raise FileNotFoundError(f"CSV file not found: {filepath}")
|
|
77
|
+
|
|
78
|
+
text = filepath.read_text(encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
# Auto-detect delimiter
|
|
81
|
+
delimiter = kwargs.get("delimiter")
|
|
82
|
+
if delimiter is None:
|
|
83
|
+
sniffer = csv.Sniffer()
|
|
84
|
+
try:
|
|
85
|
+
dialect = sniffer.sniff(text[:2048])
|
|
86
|
+
delimiter = dialect.delimiter
|
|
87
|
+
except csv.Error:
|
|
88
|
+
delimiter = ","
|
|
89
|
+
|
|
90
|
+
reader = csv.reader(text.splitlines(), delimiter=delimiter)
|
|
91
|
+
rows = list(reader)
|
|
92
|
+
|
|
93
|
+
if len(rows) < 2:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
# Header row — find columns
|
|
97
|
+
headers = [h.strip().lower() for h in rows[0]]
|
|
98
|
+
|
|
99
|
+
name_col = _find_col(headers, {"name", "constant", "parameter", "symbol"})
|
|
100
|
+
value_col = _find_col(headers, {"value", "val", "data", "number"})
|
|
101
|
+
unit_col = _find_col(headers, {"unit", "units", "uom"})
|
|
102
|
+
desc_col = _find_col(headers, {"description", "desc", "comment", "note"})
|
|
103
|
+
|
|
104
|
+
if name_col is None:
|
|
105
|
+
name_col = 0
|
|
106
|
+
if value_col is None:
|
|
107
|
+
value_col = 1
|
|
108
|
+
|
|
109
|
+
constants: list[Constant] = []
|
|
110
|
+
for row in rows[1:]:
|
|
111
|
+
if not row or not row[name_col].strip():
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
name = row[name_col].strip()
|
|
115
|
+
raw_value = row[value_col].strip() if value_col < len(row) else ""
|
|
116
|
+
if not raw_value:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
value = _parse_value(raw_value)
|
|
120
|
+
|
|
121
|
+
unit = row[unit_col].strip() if unit_col is not None and unit_col < len(row) else ""
|
|
122
|
+
desc = row[desc_col].strip() if desc_col is not None and desc_col < len(row) else ""
|
|
123
|
+
|
|
124
|
+
constants.append(Constant(name=name, value=value, unit=unit, description=desc))
|
|
125
|
+
|
|
126
|
+
return constants
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_col(headers: list[str], aliases: set[str]) -> int | None:
|
|
130
|
+
for i, h in enumerate(headers):
|
|
131
|
+
if h in aliases:
|
|
132
|
+
return i
|
|
133
|
+
return None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""JSON parser — reads constants from a JSON file.
|
|
2
|
+
|
|
3
|
+
Supports two formats:
|
|
4
|
+
|
|
5
|
+
Format A (flat object):
|
|
6
|
+
{
|
|
7
|
+
"R_SENSE": 1.9999999999910001,
|
|
8
|
+
"R_PULLUP": 4706
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Format B (array with metadata):
|
|
12
|
+
[
|
|
13
|
+
{"name": "R_SENSE", "value": 1.999, "unit": "Ohm", "description": "..."},
|
|
14
|
+
...
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
Format C (nested object):
|
|
18
|
+
{
|
|
19
|
+
"R_SENSE": {"value": 1.999, "unit": "Ohm", "description": "..."},
|
|
20
|
+
...
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from consync.models import Constant
|
|
30
|
+
from consync.parsers import register
|
|
31
|
+
from consync.precision import parse_number
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@register("json")
|
|
35
|
+
def parse_json(filepath: str | Path, **kwargs) -> list[Constant]:
|
|
36
|
+
"""Parse constants from a JSON file.
|
|
37
|
+
|
|
38
|
+
Auto-detects format (flat, array, or nested object).
|
|
39
|
+
"""
|
|
40
|
+
filepath = Path(filepath)
|
|
41
|
+
if not filepath.exists():
|
|
42
|
+
raise FileNotFoundError(f"JSON file not found: {filepath}")
|
|
43
|
+
|
|
44
|
+
data = json.loads(filepath.read_text(encoding="utf-8"))
|
|
45
|
+
|
|
46
|
+
# Format B: array of objects
|
|
47
|
+
if isinstance(data, list):
|
|
48
|
+
return _parse_array(data)
|
|
49
|
+
|
|
50
|
+
# Format A or C: object
|
|
51
|
+
if isinstance(data, dict):
|
|
52
|
+
# Check first value to distinguish A vs C
|
|
53
|
+
first_val = next(iter(data.values()), None) if data else None
|
|
54
|
+
if isinstance(first_val, dict):
|
|
55
|
+
return _parse_nested(data)
|
|
56
|
+
return _parse_flat(data)
|
|
57
|
+
|
|
58
|
+
raise ValueError(f"Unexpected JSON structure: expected object or array, got {type(data).__name__}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_flat(data: dict) -> list[Constant]:
|
|
62
|
+
"""Format A: {"NAME": value, ...} — value can be scalar or array."""
|
|
63
|
+
constants = []
|
|
64
|
+
for name, value in data.items():
|
|
65
|
+
if isinstance(value, list):
|
|
66
|
+
# Array constant — typed list of ints, floats, or strings
|
|
67
|
+
constants.append(Constant(name=name, value=_coerce_array(value)))
|
|
68
|
+
elif isinstance(value, (int, float)):
|
|
69
|
+
constants.append(Constant(name=name, value=value))
|
|
70
|
+
elif isinstance(value, str):
|
|
71
|
+
try:
|
|
72
|
+
constants.append(Constant(name=name, value=parse_number(value)))
|
|
73
|
+
except ValueError:
|
|
74
|
+
constants.append(Constant(name=name, value=value))
|
|
75
|
+
return constants
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_array(data: list) -> list[Constant]:
|
|
79
|
+
"""Format B: [{"name": ..., "value": ..., ...}, ...]"""
|
|
80
|
+
constants = []
|
|
81
|
+
for item in data:
|
|
82
|
+
if not isinstance(item, dict):
|
|
83
|
+
continue
|
|
84
|
+
name = item.get("name", "")
|
|
85
|
+
value = item.get("value")
|
|
86
|
+
if not name or value is None:
|
|
87
|
+
continue
|
|
88
|
+
# Support array values in Format B
|
|
89
|
+
if isinstance(value, list):
|
|
90
|
+
value = _coerce_array(value)
|
|
91
|
+
constants.append(Constant(
|
|
92
|
+
name=name,
|
|
93
|
+
value=value,
|
|
94
|
+
unit=str(item.get("unit", "")),
|
|
95
|
+
description=str(item.get("description", item.get("desc", ""))),
|
|
96
|
+
))
|
|
97
|
+
return constants
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_nested(data: dict) -> list[Constant]:
|
|
101
|
+
"""Format C: {"NAME": {"value": ..., "unit": ..., ...}, ...}"""
|
|
102
|
+
constants = []
|
|
103
|
+
for name, props in data.items():
|
|
104
|
+
if not isinstance(props, dict):
|
|
105
|
+
continue
|
|
106
|
+
value = props.get("value")
|
|
107
|
+
if value is None:
|
|
108
|
+
continue
|
|
109
|
+
# Support array values in Format C
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
value = _coerce_array(value)
|
|
112
|
+
constants.append(Constant(
|
|
113
|
+
name=name,
|
|
114
|
+
value=value,
|
|
115
|
+
unit=str(props.get("unit", "")),
|
|
116
|
+
description=str(props.get("description", props.get("desc", ""))),
|
|
117
|
+
))
|
|
118
|
+
return constants
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _coerce_array(arr: list) -> list[int] | list[float] | list[str]:
|
|
122
|
+
"""Coerce a JSON array into a typed Python list.
|
|
123
|
+
|
|
124
|
+
Priority: int → float → str.
|
|
125
|
+
"""
|
|
126
|
+
if not arr:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
# Check if all ints
|
|
130
|
+
if all(isinstance(x, int) for x in arr):
|
|
131
|
+
return arr
|
|
132
|
+
|
|
133
|
+
# Check if all numeric (mix of int/float → float)
|
|
134
|
+
if all(isinstance(x, (int, float)) for x in arr):
|
|
135
|
+
return [float(x) for x in arr]
|
|
136
|
+
|
|
137
|
+
# Fallback to strings
|
|
138
|
+
return [str(x) for x in arr]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""TOML parser — reads constants from a TOML file.
|
|
2
|
+
|
|
3
|
+
Supports two formats:
|
|
4
|
+
|
|
5
|
+
Format A (flat):
|
|
6
|
+
[constants]
|
|
7
|
+
R_SENSE = 1.9999999999910001
|
|
8
|
+
R_PULLUP = 4706
|
|
9
|
+
|
|
10
|
+
Format B (tables with metadata):
|
|
11
|
+
[constants.R_SENSE]
|
|
12
|
+
value = 1.999
|
|
13
|
+
unit = "Ohm"
|
|
14
|
+
description = "Current sense resistor"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from consync.models import Constant
|
|
23
|
+
from consync.parsers import register
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_toml(filepath: Path) -> dict:
|
|
27
|
+
"""Load TOML file using tomllib (3.11+) or tomli."""
|
|
28
|
+
text = filepath.read_text(encoding="utf-8")
|
|
29
|
+
if sys.version_info >= (3, 11):
|
|
30
|
+
import tomllib
|
|
31
|
+
return tomllib.loads(text)
|
|
32
|
+
else:
|
|
33
|
+
try:
|
|
34
|
+
import tomli
|
|
35
|
+
return tomli.loads(text)
|
|
36
|
+
except ImportError:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"TOML support requires Python 3.11+ or 'pip install tomli'. "
|
|
39
|
+
"Install with: pip install consync[toml]"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register("toml")
|
|
44
|
+
def parse_toml(filepath: str | Path, **kwargs) -> list[Constant]:
|
|
45
|
+
"""Parse constants from a TOML file."""
|
|
46
|
+
filepath = Path(filepath)
|
|
47
|
+
if not filepath.exists():
|
|
48
|
+
raise FileNotFoundError(f"TOML file not found: {filepath}")
|
|
49
|
+
|
|
50
|
+
data = _load_toml(filepath)
|
|
51
|
+
|
|
52
|
+
# Look for a [constants] section, fallback to root
|
|
53
|
+
section = data.get("constants", data)
|
|
54
|
+
|
|
55
|
+
constants: list[Constant] = []
|
|
56
|
+
for name, value in section.items():
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
# Format B: table with metadata
|
|
59
|
+
val = value.get("value")
|
|
60
|
+
if val is None:
|
|
61
|
+
continue
|
|
62
|
+
constants.append(Constant(
|
|
63
|
+
name=name,
|
|
64
|
+
value=val,
|
|
65
|
+
unit=str(value.get("unit", "")),
|
|
66
|
+
description=str(value.get("description", value.get("desc", ""))),
|
|
67
|
+
))
|
|
68
|
+
elif isinstance(value, (int, float)):
|
|
69
|
+
# Format A: flat key=value
|
|
70
|
+
constants.append(Constant(name=name, value=value))
|
|
71
|
+
elif isinstance(value, str):
|
|
72
|
+
constants.append(Constant(name=name, value=value))
|
|
73
|
+
|
|
74
|
+
return constants
|
consync/parsers/xlsx.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Excel (.xlsx) parser — reads constants from a spreadsheet.
|
|
2
|
+
|
|
3
|
+
Expected format:
|
|
4
|
+
Row 1: Header row (Name, Value, Unit, Description)
|
|
5
|
+
Row 2+: Data rows
|
|
6
|
+
|
|
7
|
+
Column mapping is flexible — auto-detects by header names.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from consync.models import Constant
|
|
15
|
+
from consync.parsers import register
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Column header aliases (case-insensitive matching)
|
|
19
|
+
NAME_ALIASES = {"name", "constant", "parameter", "variable", "symbol", "id"}
|
|
20
|
+
VALUE_ALIASES = {"value", "val", "data", "number", "amount"}
|
|
21
|
+
UNIT_ALIASES = {"unit", "units", "uom", "dimension"}
|
|
22
|
+
DESC_ALIASES = {"description", "desc", "comment", "note", "notes", "info"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_column(headers: list[str], aliases: set[str]) -> int | None:
|
|
26
|
+
"""Find column index matching any alias (case-insensitive)."""
|
|
27
|
+
for i, h in enumerate(headers):
|
|
28
|
+
if h and h.strip().lower() in aliases:
|
|
29
|
+
return i
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register("xlsx")
|
|
34
|
+
def parse_xlsx(filepath: str | Path, **kwargs) -> list[Constant]:
|
|
35
|
+
"""Parse constants from an Excel file.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
filepath: Path to .xlsx file.
|
|
39
|
+
sheet: Sheet name or index (default: active sheet).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of Constant objects.
|
|
43
|
+
"""
|
|
44
|
+
import openpyxl
|
|
45
|
+
|
|
46
|
+
filepath = Path(filepath)
|
|
47
|
+
if not filepath.exists():
|
|
48
|
+
raise FileNotFoundError(f"Excel file not found: {filepath}")
|
|
49
|
+
|
|
50
|
+
wb = openpyxl.load_workbook(filepath, data_only=True)
|
|
51
|
+
|
|
52
|
+
sheet = kwargs.get("sheet")
|
|
53
|
+
if sheet is not None:
|
|
54
|
+
if isinstance(sheet, int):
|
|
55
|
+
ws = wb.worksheets[sheet]
|
|
56
|
+
else:
|
|
57
|
+
ws = wb[sheet]
|
|
58
|
+
else:
|
|
59
|
+
ws = wb.active
|
|
60
|
+
|
|
61
|
+
# Read header row
|
|
62
|
+
header_row = [str(cell.value or "").strip() for cell in ws[1]]
|
|
63
|
+
|
|
64
|
+
# Auto-detect columns
|
|
65
|
+
name_col = _find_column(header_row, NAME_ALIASES)
|
|
66
|
+
value_col = _find_column(header_row, VALUE_ALIASES)
|
|
67
|
+
unit_col = _find_column(header_row, UNIT_ALIASES)
|
|
68
|
+
desc_col = _find_column(header_row, DESC_ALIASES)
|
|
69
|
+
|
|
70
|
+
# Fallback to positional: A=Name, B=Value, C=Unit, D=Description
|
|
71
|
+
if name_col is None:
|
|
72
|
+
name_col = 0
|
|
73
|
+
if value_col is None:
|
|
74
|
+
value_col = 1
|
|
75
|
+
if unit_col is None:
|
|
76
|
+
unit_col = 2 if len(header_row) > 2 else None
|
|
77
|
+
if desc_col is None:
|
|
78
|
+
desc_col = 3 if len(header_row) > 3 else None
|
|
79
|
+
|
|
80
|
+
constants: list[Constant] = []
|
|
81
|
+
for row in ws.iter_rows(min_row=2, values_only=True):
|
|
82
|
+
if not row or row[name_col] is None:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
name = str(row[name_col]).strip()
|
|
86
|
+
if not name:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
raw_value = row[value_col] if value_col < len(row) else None
|
|
90
|
+
if raw_value is None:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# Preserve numeric types
|
|
94
|
+
if isinstance(raw_value, (int, float)):
|
|
95
|
+
value = raw_value
|
|
96
|
+
else:
|
|
97
|
+
# Try to parse as number
|
|
98
|
+
try:
|
|
99
|
+
value = int(str(raw_value))
|
|
100
|
+
except ValueError:
|
|
101
|
+
try:
|
|
102
|
+
value = float(str(raw_value))
|
|
103
|
+
except ValueError:
|
|
104
|
+
value = str(raw_value)
|
|
105
|
+
|
|
106
|
+
unit = ""
|
|
107
|
+
if unit_col is not None and unit_col < len(row) and row[unit_col]:
|
|
108
|
+
unit = str(row[unit_col]).strip()
|
|
109
|
+
|
|
110
|
+
description = ""
|
|
111
|
+
if desc_col is not None and desc_col < len(row) and row[desc_col]:
|
|
112
|
+
description = str(row[desc_col]).strip()
|
|
113
|
+
|
|
114
|
+
constants.append(Constant(name=name, value=value, unit=unit, description=description))
|
|
115
|
+
|
|
116
|
+
return constants
|