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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""JSON renderer — writes constants to a structured JSON file.
|
|
2
|
+
|
|
3
|
+
Output format:
|
|
4
|
+
{
|
|
5
|
+
"_meta": {
|
|
6
|
+
"generator": "consync",
|
|
7
|
+
"source": "constants.xlsx",
|
|
8
|
+
"synced": "2026-05-05T14:30:00"
|
|
9
|
+
},
|
|
10
|
+
"constants": [
|
|
11
|
+
{
|
|
12
|
+
"name": "R_SENSE",
|
|
13
|
+
"value": 1.9999999999910001,
|
|
14
|
+
"unit": "Ohm",
|
|
15
|
+
"description": "Current sense resistor"
|
|
16
|
+
},
|
|
17
|
+
...
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from consync.models import Constant, MappingConfig
|
|
29
|
+
from consync.renderers import register
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register("json")
|
|
33
|
+
def render_json(
|
|
34
|
+
constants: list[Constant],
|
|
35
|
+
filepath: str | Path,
|
|
36
|
+
*,
|
|
37
|
+
config: MappingConfig | None = None,
|
|
38
|
+
**kwargs,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Render constants as a JSON file with metadata."""
|
|
41
|
+
filepath = Path(filepath)
|
|
42
|
+
source_name = config.source if config else kwargs.get("source", "unknown")
|
|
43
|
+
prefix = config.prefix if config else kwargs.get("prefix", "")
|
|
44
|
+
|
|
45
|
+
output = {
|
|
46
|
+
"_meta": {
|
|
47
|
+
"generator": "consync",
|
|
48
|
+
"source": source_name,
|
|
49
|
+
"synced": datetime.now().isoformat(timespec="seconds"),
|
|
50
|
+
},
|
|
51
|
+
"constants": [],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for c in constants:
|
|
55
|
+
name = prefix + c.name
|
|
56
|
+
if config and config.uppercase_names:
|
|
57
|
+
name = name.upper()
|
|
58
|
+
|
|
59
|
+
entry: dict = {"name": name, "value": c.value}
|
|
60
|
+
if c.unit:
|
|
61
|
+
entry["unit"] = c.unit
|
|
62
|
+
if c.description:
|
|
63
|
+
entry["description"] = c.description
|
|
64
|
+
|
|
65
|
+
output["constants"].append(entry)
|
|
66
|
+
|
|
67
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
filepath.write_text(
|
|
69
|
+
json.dumps(output, indent=2, ensure_ascii=False) + "\n",
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Python constants renderer — generates a Python module with typed constants.
|
|
2
|
+
|
|
3
|
+
Output format:
|
|
4
|
+
# AUTO-GENERATED by consync — do not edit by hand.
|
|
5
|
+
# Source: constants.xlsx | Synced: 2026-05-05 14:30:00
|
|
6
|
+
|
|
7
|
+
R_SENSE: float = 1.9999999999910001 # Ohm — Current sense resistor
|
|
8
|
+
R_PULLUP: int = 4706 # Ohm — I2C pull-up resistor
|
|
9
|
+
C_FILTER: float = 4.7832940000000000e-07 # F — Input filter capacitor
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from consync.models import Constant, MappingConfig
|
|
18
|
+
from consync.precision import format_float
|
|
19
|
+
from consync.renderers import register
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register("python")
|
|
23
|
+
def render_python(
|
|
24
|
+
constants: list[Constant],
|
|
25
|
+
filepath: str | Path,
|
|
26
|
+
*,
|
|
27
|
+
config: MappingConfig | None = None,
|
|
28
|
+
**kwargs,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Render constants as a Python module with type annotations."""
|
|
31
|
+
filepath = Path(filepath)
|
|
32
|
+
precision = config.precision if config else kwargs.get("precision", 17)
|
|
33
|
+
source_name = config.source if config else kwargs.get("source", "unknown")
|
|
34
|
+
prefix = config.prefix if config else kwargs.get("prefix", "")
|
|
35
|
+
|
|
36
|
+
lines = [
|
|
37
|
+
f'"""AUTO-GENERATED by consync — do not edit by hand."""',
|
|
38
|
+
f"# Source: {source_name} | Synced: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
39
|
+
f"# Regenerate: consync sync",
|
|
40
|
+
f"",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Calculate alignment
|
|
44
|
+
names = [prefix + c.name for c in constants]
|
|
45
|
+
max_name = max((len(n) for n in names), default=10)
|
|
46
|
+
|
|
47
|
+
for c in constants:
|
|
48
|
+
name = prefix + c.name
|
|
49
|
+
if config and config.uppercase_names:
|
|
50
|
+
name = name.upper()
|
|
51
|
+
|
|
52
|
+
# Build inline comment
|
|
53
|
+
parts = [p for p in (c.unit, c.description) if p]
|
|
54
|
+
comment = f" # {' — '.join(parts)}" if parts else ""
|
|
55
|
+
|
|
56
|
+
# Format value with type annotation
|
|
57
|
+
if isinstance(c.value, list):
|
|
58
|
+
# Array → list[int] / list[float] / list[str]
|
|
59
|
+
if c.value and isinstance(c.value[0], int):
|
|
60
|
+
type_hint = "list[int]"
|
|
61
|
+
val_str = repr(c.value)
|
|
62
|
+
elif c.value and isinstance(c.value[0], float):
|
|
63
|
+
type_hint = "list[float]"
|
|
64
|
+
val_str = "[" + ", ".join(format_float(v, precision) for v in c.value) + "]"
|
|
65
|
+
else:
|
|
66
|
+
type_hint = "list[str]"
|
|
67
|
+
val_str = repr(c.value)
|
|
68
|
+
elif isinstance(c.value, int):
|
|
69
|
+
type_hint = "int"
|
|
70
|
+
val_str = str(c.value)
|
|
71
|
+
elif isinstance(c.value, float):
|
|
72
|
+
type_hint = "float"
|
|
73
|
+
val_str = format_float(c.value, precision)
|
|
74
|
+
else:
|
|
75
|
+
type_hint = "str"
|
|
76
|
+
val_str = repr(c.value)
|
|
77
|
+
|
|
78
|
+
decl = f"{name}: {type_hint}"
|
|
79
|
+
lines.append(f"{decl:<{max_name + 8}} = {val_str}{comment}")
|
|
80
|
+
|
|
81
|
+
lines.append("") # trailing newline
|
|
82
|
+
|
|
83
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
filepath.write_text("\n".join(lines), encoding="utf-8")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Rust constants renderer — generates const declarations.
|
|
2
|
+
|
|
3
|
+
Output format:
|
|
4
|
+
//! AUTO-GENERATED by consync — do not edit by hand.
|
|
5
|
+
//! Source: constants.xlsx | Synced: 2026-05-05 14:30:00
|
|
6
|
+
|
|
7
|
+
/// Current sense resistor (Ohm)
|
|
8
|
+
pub const R_SENSE: f64 = 1.9999999999910001;
|
|
9
|
+
|
|
10
|
+
/// I2C pull-up resistor (Ohm)
|
|
11
|
+
pub const R_PULLUP: f64 = 4706.0;
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from consync.models import Constant, MappingConfig
|
|
20
|
+
from consync.precision import format_float
|
|
21
|
+
from consync.renderers import register
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@register("rust")
|
|
25
|
+
def render_rust(
|
|
26
|
+
constants: list[Constant],
|
|
27
|
+
filepath: str | Path,
|
|
28
|
+
*,
|
|
29
|
+
config: MappingConfig | None = None,
|
|
30
|
+
**kwargs,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Render constants as Rust const declarations."""
|
|
33
|
+
filepath = Path(filepath)
|
|
34
|
+
precision = config.precision if config else kwargs.get("precision", 17)
|
|
35
|
+
source_name = config.source if config else kwargs.get("source", "unknown")
|
|
36
|
+
prefix = config.prefix if config else kwargs.get("prefix", "")
|
|
37
|
+
|
|
38
|
+
lines = [
|
|
39
|
+
f"//! AUTO-GENERATED by consync — do not edit by hand.",
|
|
40
|
+
f"//! Source: {source_name} | Synced: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
41
|
+
f"//! Regenerate: consync sync",
|
|
42
|
+
f"",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for c in constants:
|
|
46
|
+
name = prefix + c.name
|
|
47
|
+
if config and config.uppercase_names:
|
|
48
|
+
name = name.upper()
|
|
49
|
+
|
|
50
|
+
# Doc comment
|
|
51
|
+
parts = [p for p in (c.description, f"({c.unit})" if c.unit else "") if p]
|
|
52
|
+
if parts:
|
|
53
|
+
lines.append(f"/// {' '.join(parts)}")
|
|
54
|
+
|
|
55
|
+
# Type and value
|
|
56
|
+
if isinstance(c.value, list):
|
|
57
|
+
# Array → pub const X: [i64; N] = [1, 2, 3];
|
|
58
|
+
if c.value and isinstance(c.value[0], int):
|
|
59
|
+
type_name = "i64"
|
|
60
|
+
vals = ", ".join(str(v) for v in c.value)
|
|
61
|
+
elif c.value and isinstance(c.value[0], float):
|
|
62
|
+
type_name = "f64"
|
|
63
|
+
formatted = []
|
|
64
|
+
for v in c.value:
|
|
65
|
+
s = format_float(v, precision)
|
|
66
|
+
if "." not in s and "e" not in s and "E" not in s:
|
|
67
|
+
s += ".0"
|
|
68
|
+
formatted.append(s)
|
|
69
|
+
vals = ", ".join(formatted)
|
|
70
|
+
else:
|
|
71
|
+
# String arrays — use &str
|
|
72
|
+
type_name = "&str"
|
|
73
|
+
vals = ", ".join(f'"{v}"' for v in c.value)
|
|
74
|
+
lines.append(f"pub const {name}: [{type_name}; {len(c.value)}] = [{vals}];")
|
|
75
|
+
elif isinstance(c.value, int):
|
|
76
|
+
type_name = "i64"
|
|
77
|
+
val_str = str(c.value)
|
|
78
|
+
lines.append(f"pub const {name}: {type_name} = {val_str};")
|
|
79
|
+
else:
|
|
80
|
+
type_name = "f64"
|
|
81
|
+
val_str = format_float(c.value, precision)
|
|
82
|
+
# Rust requires decimal point or scientific notation for f64 literals
|
|
83
|
+
if "." not in val_str and "e" not in val_str and "E" not in val_str:
|
|
84
|
+
val_str += ".0"
|
|
85
|
+
lines.append(f"pub const {name}: {type_name} = {val_str};")
|
|
86
|
+
|
|
87
|
+
lines.append("")
|
|
88
|
+
|
|
89
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
filepath.write_text("\n".join(lines), encoding="utf-8")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Verilog/SystemVerilog renderer — generates parameter declarations.
|
|
2
|
+
|
|
3
|
+
Output format (Verilog):
|
|
4
|
+
// AUTO-GENERATED by consync — do not edit by hand.
|
|
5
|
+
// Source: constants.xlsx | Synced: 2026-05-05 14:30:00
|
|
6
|
+
|
|
7
|
+
parameter real R_SENSE = 1.9999999999910001; // Ohm — Current sense resistor
|
|
8
|
+
parameter real R_PULLUP = 4706.0; // Ohm — I2C pull-up resistor
|
|
9
|
+
parameter real FREQ_SWITCH = 299872.93847293; // Hz — Switching frequency
|
|
10
|
+
|
|
11
|
+
Output format (module wrapper):
|
|
12
|
+
module design_params;
|
|
13
|
+
parameter real R_SENSE = 1.9999999999910001;
|
|
14
|
+
...
|
|
15
|
+
endmodule
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from consync.models import Constant, MappingConfig
|
|
24
|
+
from consync.precision import format_float
|
|
25
|
+
from consync.renderers import register
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@register("verilog")
|
|
29
|
+
def render_verilog(
|
|
30
|
+
constants: list[Constant],
|
|
31
|
+
filepath: str | Path,
|
|
32
|
+
*,
|
|
33
|
+
config: MappingConfig | None = None,
|
|
34
|
+
**kwargs,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Render constants as Verilog/SystemVerilog parameters."""
|
|
37
|
+
filepath = Path(filepath)
|
|
38
|
+
precision = config.precision if config else kwargs.get("precision", 17)
|
|
39
|
+
source_name = config.source if config else kwargs.get("source", "unknown")
|
|
40
|
+
prefix = config.prefix if config else kwargs.get("prefix", "")
|
|
41
|
+
module_name = config.module_name if config else kwargs.get("module_name", "")
|
|
42
|
+
|
|
43
|
+
lines = [
|
|
44
|
+
f"// AUTO-GENERATED by consync — do not edit by hand.",
|
|
45
|
+
f"// Source: {source_name} | Synced: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
46
|
+
f"// Regenerate: consync sync",
|
|
47
|
+
f"",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
indent = ""
|
|
51
|
+
if module_name:
|
|
52
|
+
lines.append(f"module {module_name};")
|
|
53
|
+
lines.append("")
|
|
54
|
+
indent = " "
|
|
55
|
+
|
|
56
|
+
# Calculate alignment
|
|
57
|
+
names = [prefix + c.name for c in constants]
|
|
58
|
+
name_width = max((len(n) for n in names), default=20) + 2
|
|
59
|
+
|
|
60
|
+
for c in constants:
|
|
61
|
+
name = prefix + c.name
|
|
62
|
+
if config and config.uppercase_names:
|
|
63
|
+
name = name.upper()
|
|
64
|
+
|
|
65
|
+
# Verilog types
|
|
66
|
+
if isinstance(c.value, int):
|
|
67
|
+
type_kw = "integer"
|
|
68
|
+
val_str = str(c.value)
|
|
69
|
+
else:
|
|
70
|
+
type_kw = "real"
|
|
71
|
+
val_str = format_float(c.value, precision)
|
|
72
|
+
# Verilog real literals need a decimal point
|
|
73
|
+
if "." not in val_str and "e" not in val_str and "E" not in val_str:
|
|
74
|
+
val_str += ".0"
|
|
75
|
+
|
|
76
|
+
# Comment
|
|
77
|
+
parts = [p for p in (c.unit, c.description) if p]
|
|
78
|
+
comment = f" // {' — '.join(parts)}" if parts else ""
|
|
79
|
+
|
|
80
|
+
lines.append(f"{indent}parameter {type_kw} {name:<{name_width}} = {val_str};{comment}")
|
|
81
|
+
|
|
82
|
+
if module_name:
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append("endmodule")
|
|
85
|
+
|
|
86
|
+
lines.append("")
|
|
87
|
+
|
|
88
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
filepath.write_text("\n".join(lines), encoding="utf-8")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""VHDL renderer — generates constant declarations in a package.
|
|
2
|
+
|
|
3
|
+
Output format:
|
|
4
|
+
-- AUTO-GENERATED by consync — do not edit by hand.
|
|
5
|
+
-- Source: constants.xlsx | Synced: 2026-05-05 14:30:00
|
|
6
|
+
|
|
7
|
+
library ieee;
|
|
8
|
+
use ieee.std_logic_1164.all;
|
|
9
|
+
use ieee.math_real.all;
|
|
10
|
+
|
|
11
|
+
package hw_constants is
|
|
12
|
+
constant R_SENSE : real := 1.9999999999910001; -- Ohm | Current sense resistor
|
|
13
|
+
constant R_PULLUP : real := 4706.0; -- Ohm | I2C pull-up resistor
|
|
14
|
+
constant FREQ_SWITCH : real := 299872.93847293; -- Hz | Switching frequency
|
|
15
|
+
end package hw_constants;
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from consync.models import Constant, MappingConfig
|
|
24
|
+
from consync.precision import format_float
|
|
25
|
+
from consync.renderers import register
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@register("vhdl")
|
|
29
|
+
def render_vhdl(
|
|
30
|
+
constants: list[Constant],
|
|
31
|
+
filepath: str | Path,
|
|
32
|
+
*,
|
|
33
|
+
config: MappingConfig | None = None,
|
|
34
|
+
**kwargs,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Render constants as a VHDL package."""
|
|
37
|
+
filepath = Path(filepath)
|
|
38
|
+
precision = config.precision if config else kwargs.get("precision", 17)
|
|
39
|
+
source_name = config.source if config else kwargs.get("source", "unknown")
|
|
40
|
+
prefix = config.prefix if config else kwargs.get("prefix", "")
|
|
41
|
+
module_name = config.module_name if config else kwargs.get("module_name", "")
|
|
42
|
+
|
|
43
|
+
# Package name: from module_name or derive from filename
|
|
44
|
+
pkg_name = module_name or filepath.stem.replace("-", "_").replace(".", "_")
|
|
45
|
+
|
|
46
|
+
lines = [
|
|
47
|
+
f"-- AUTO-GENERATED by consync — do not edit by hand.",
|
|
48
|
+
f"-- Source: {source_name} | Synced: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
49
|
+
f"-- Regenerate: consync sync",
|
|
50
|
+
f"",
|
|
51
|
+
f"library ieee;",
|
|
52
|
+
f"use ieee.std_logic_1164.all;",
|
|
53
|
+
f"use ieee.math_real.all;",
|
|
54
|
+
f"",
|
|
55
|
+
f"package {pkg_name} is",
|
|
56
|
+
f"",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Calculate alignment
|
|
60
|
+
names = [prefix + c.name for c in constants]
|
|
61
|
+
name_width = max((len(n) for n in names), default=20) + 2
|
|
62
|
+
|
|
63
|
+
for c in constants:
|
|
64
|
+
name = prefix + c.name
|
|
65
|
+
if config and config.uppercase_names:
|
|
66
|
+
name = name.upper()
|
|
67
|
+
|
|
68
|
+
# VHDL types
|
|
69
|
+
if isinstance(c.value, int):
|
|
70
|
+
type_name = "integer"
|
|
71
|
+
val_str = str(c.value)
|
|
72
|
+
else:
|
|
73
|
+
type_name = "real"
|
|
74
|
+
val_str = format_float(c.value, precision)
|
|
75
|
+
# VHDL real literals need a decimal point
|
|
76
|
+
if "." not in val_str and "e" not in val_str and "E" not in val_str:
|
|
77
|
+
val_str += ".0"
|
|
78
|
+
|
|
79
|
+
# Comment
|
|
80
|
+
parts = [p for p in (c.unit, c.description) if p]
|
|
81
|
+
comment = f" -- {' | '.join(parts)}" if parts else ""
|
|
82
|
+
|
|
83
|
+
lines.append(
|
|
84
|
+
f" constant {name:<{name_width}} : {type_name} := {val_str};{comment}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
lines += [
|
|
88
|
+
f"",
|
|
89
|
+
f"end package {pkg_name};",
|
|
90
|
+
f"",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
filepath.write_text("\n".join(lines), encoding="utf-8")
|
consync/state.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""State management — hash-based change detection for sync.
|
|
2
|
+
|
|
3
|
+
Tracks MD5 hashes of constant data (name+value pairs) to detect which
|
|
4
|
+
side changed since last sync. This enables smart bidirectional sync:
|
|
5
|
+
|
|
6
|
+
- If only source changed → sync source → target
|
|
7
|
+
- If only target changed → sync target → source
|
|
8
|
+
- If both changed → conflict (resolve per on_conflict setting)
|
|
9
|
+
- If neither changed → skip (already in sync)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from consync.models import Constant
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_hash(constants: list[Constant]) -> str:
|
|
22
|
+
"""Compute a stable hash of constant name+value pairs.
|
|
23
|
+
|
|
24
|
+
Only hashes names and values (not unit/description) since those
|
|
25
|
+
are the semantically meaningful content for sync detection.
|
|
26
|
+
"""
|
|
27
|
+
key = json.dumps(
|
|
28
|
+
{c.name: c.value for c in constants},
|
|
29
|
+
sort_keys=True,
|
|
30
|
+
default=str,
|
|
31
|
+
)
|
|
32
|
+
return hashlib.md5(key.encode()).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SyncState:
|
|
36
|
+
"""Persistent sync state stored in .consync.state.json."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, state_file: Path):
|
|
39
|
+
self.state_file = state_file
|
|
40
|
+
self._data: dict = {}
|
|
41
|
+
self._load()
|
|
42
|
+
|
|
43
|
+
def _load(self):
|
|
44
|
+
if self.state_file.exists():
|
|
45
|
+
try:
|
|
46
|
+
self._data = json.loads(self.state_file.read_text())
|
|
47
|
+
except (json.JSONDecodeError, OSError):
|
|
48
|
+
self._data = {}
|
|
49
|
+
else:
|
|
50
|
+
self._data = {}
|
|
51
|
+
|
|
52
|
+
def _save(self):
|
|
53
|
+
self.state_file.write_text(json.dumps(self._data, indent=2) + "\n")
|
|
54
|
+
|
|
55
|
+
def get_hash(self, mapping_key: str, side: str) -> str | None:
|
|
56
|
+
"""Get the stored hash for a mapping side ('source' or 'target')."""
|
|
57
|
+
entry = self._data.get(mapping_key, {})
|
|
58
|
+
return entry.get(side)
|
|
59
|
+
|
|
60
|
+
def set_hash(self, mapping_key: str, source_hash: str, target_hash: str):
|
|
61
|
+
"""Update stored hashes after a successful sync."""
|
|
62
|
+
self._data[mapping_key] = {
|
|
63
|
+
"source": source_hash,
|
|
64
|
+
"target": target_hash,
|
|
65
|
+
}
|
|
66
|
+
self._save()
|
|
67
|
+
|
|
68
|
+
def mapping_key(self, source: str, target: str) -> str:
|
|
69
|
+
"""Generate a stable key for a source/target pair."""
|
|
70
|
+
return f"{source}::{target}"
|
|
71
|
+
|
|
72
|
+
def clear(self):
|
|
73
|
+
"""Reset all state."""
|
|
74
|
+
self._data = {}
|
|
75
|
+
if self.state_file.exists():
|
|
76
|
+
self.state_file.unlink()
|