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/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
@@ -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