Skip to content

Commit bf43625

Browse files
committed
add: first commit
1 parent 14c4407 commit bf43625

6 files changed

Lines changed: 340 additions & 0 deletions

File tree

tests/updater/__init__.py

Whitespace-only changes.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Unit tests for updater.components """
2+
import logging
3+
import pytest
4+
from pathlib import Path
5+
from unittest.mock import patch, mock_open
6+
7+
from updater.components import (
8+
ComponentConfig, ComponentStatus, load_components
9+
)
10+
11+
BUNDLE_YAML = """
12+
pool_internal_minutes: 30
13+
components:
14+
- name: klipper
15+
- type: git
16+
- path: ~/klipper
17+
- service: klipper.service
18+
- reset_mode: hard
19+
- order: 1
20+
- name: BlocksScreen
21+
- type: git
22+
- path: ~/BlocksScreen
23+
- service: BlocksScreen.service
24+
- reset_mode: hard
25+
- order: 99
26+
"""
27+
28+
OVERRIDE_YAML = """
29+
components:
30+
- name: klipper
31+
path: ~/costum_klipper
32+
- name: my-plugin
33+
type: git
34+
path: ~/my-plugin
35+
service: my-plugin.service
36+
reset_mode: hard
37+
order: 5
38+
"""
39+
40+
def _mock_load(bundled: str, override: str | None = None):
41+
import updater.components as mod
42+
bundled_path = Path(mod.__file__).parent / "components.yaml"
43+
44+
def fake_open(path, *a, **kw):
45+
if Path(path) == bundled_path:
46+
return mock_open(read_data=bundled)()
47+
if override is not None:
48+
return mock_open(read_data=override)()
49+
raise FileNotFoundError(Path)
50+
return fake_open
51+
52+
53+
class TestLoadComponents:
54+
def test_load_return_sorted_by_order(self):
55+
with patch("builtins.open", _mock_load(BUNDLE_YAML)), patch("pathlib.Path.exists", return_value=False), patch("pathlib.Path.is_dir", return_value=True):
56+
components = load_components()
57+
names = [c.name for c in components]
58+
assert names.index("klipper") < names.index("BlocksScreen")
59+
60+
def test_missing_override_file_silently_ignored(self):
61+
with patch("builtins.open", _mock_load(BUNDLE_YAML)), patch("pathlib.Path.exists", return_value=False), patch("pathlib.Path.is_dir", return_value=True):
62+
components = load_components()
63+
assert len(components) >= 2
64+
65+
class TestYamlMerge:
66+
def test_ovvrides_path_by_name(self):
67+
with patch("builtins.open", _mock_load(BUNDLE_YAML)), patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.is_dir", return_value=True):
68+
components = load_components()
69+
klipper = next(c for c in components if c.name == "klipper")
70+
assert str(klipper.path).endswith("costum_klipper")
71+
assert klipper.service == "klipper.service"
72+
73+
def test_appends_new_componentself(self):
74+
with patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)), patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.is_dir", return_value=True):
75+
components = load_components()
76+
assert any(c.name == "my-plugin" for c in components)
77+
def test_keeps_bundled_fields_not_in_override(self):
78+
with patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)), patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.is_dir", return_value=True):
79+
components = load_components()
80+
klipper = next(c for c in components if c.name == "klipper")
81+
assert klipper.type == "git"
82+
assert klipper.reset_mode == "hard"
83+
84+
def test_syntax_error_falls_back_to_bundled(self, caplog):
85+
with patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)), patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.is_dir", return_value=True), \
86+
caplog.at_level(logging.ERROR, logger="updater.components"):
87+
components = load_components()
88+
names = [c.name for c in components]
89+
assert "klipper" in names
90+
assert "my-plugin" not in names
91+
92+
93+
class TestValidation:
94+
def test_invalid_component_skipped_with_warning(self, caplog):
95+
bad_yaml = """
96+
components:
97+
- name: bad
98+
type: git
99+
path: /etc/passwd
100+
service: bad.service
101+
order: 10
102+
"""
103+
with patch("builtins.open", _mock_load(bad_yaml)), \
104+
patch("pathlib.Path.exists", return_value=True), \
105+
caplog.at_level(logging.WARNING, logger="updater.components"):
106+
components = load_components()
107+
assert not any(c.name == "bad" for c in components)
108+
assert any("bad" in r.message for r in caplog.records)
109+
110+
def test_path_outside_home_rejected(self):
111+
outside_yaml = """
112+
components:
113+
- name: outside
114+
type: git
115+
path: /etc/outside
116+
service: outside.service
117+
order: 10
118+
"""
119+
with patch("builtins.open", _mock_load(outside_yaml)), \
120+
patch("pathlib.Path.exists", return_value=False):
121+
components = load_components()
122+
assert not any(c.name == "outside" for c in components)
123+
124+
def test_service_name_with_slash_rejected(self):
125+
bad_yaml = """
126+
components:
127+
- name: bad
128+
type: git
129+
path: ~/bad
130+
service: ../bad.service
131+
order: 10
132+
"""
133+
with patch("builtins.open", _mock_load(bad_yaml)), \
134+
patch("pathlib.Path.exists", return_value=False):
135+
components = load_components()
136+
assert not any(c.name == "evil" for c in components)
137+
138+
def test_service_name_with_shell_metachar_rejected(self):
139+
for metachar in [";", "&", "|", "$", "`"]:
140+
bad_yaml = f"""
141+
components:
142+
- name: bad
143+
type: git
144+
path: ~/bad
145+
service: bad{metachar}cmd.service
146+
order: 10
147+
"""
148+
with patch("builtins.open", _mock_load(bad_yaml)), \
149+
patch("pathlib.Path.exists", return_value=False):
150+
components = load_components()
151+
assert not any(c.name == "bad" for c in components), f"metachar {metachar!r} not rejected"
152+
153+
def test_service_name_valid_pattern_accepted(self):
154+
good_yaml = """
155+
components:
156+
- name: my-plugin
157+
type: git
158+
path: ~/my-plugin
159+
service: my-plugin.service
160+
order: 5
161+
"""
162+
with patch("builtins.open", _mock_load(good_yaml)), \
163+
patch("pathlib.Path.exists", return_value=False), \
164+
patch("pathlib.Path.is_dir", return_value=True):
165+
components = load_components()
166+
assert any(c.name == "my-plugin" for c in components)
167+

updater/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .components import load_components
2+
from .models import ComponentConfig, ComponentStatus
3+
4+
__all__ = ["ComponentConfig", "ComponentStatus", "load_components"]

updater/components.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import re
5+
from pathlib import Path
6+
7+
import yaml
8+
9+
from .models import ComponentConfig, ComponentStatus
10+
11+
__all__ = ["ComponentConfig", "ComponentStatus", "load_components"]
12+
13+
logger = logging.getLogger(__name__)
14+
15+
_SERVICE_RE = re.compile(r"^[a-zA-Z0-9@:._-]+\.service$")
16+
_SERVICE_BANNED = set("/\\..;&|$`") | {" ", "\t"}
17+
18+
OVERRIDE_PATH = Path("~/printer_data/config/blockscreen_updater.yaml").expanduser()
19+
20+
21+
def _validate_service(name: str) -> bool:
22+
if not name:
23+
return False
24+
if any(ch in _SERVICE_BANNED for ch in name):
25+
return False
26+
return bool(_SERVICE_RE.match(name))
27+
28+
29+
def _validate_component(data: dict) -> ComponentConfig | None:
30+
name = data.get("name", "")
31+
if not isinstance(name, str) or not name:
32+
logger.warning("Component missing name, skipped")
33+
return None
34+
35+
comp_type = data.get("type", "")
36+
if comp_type not in ("git", "apt"):
37+
logger.warning("Component %r has invalid type %r — skipped", name, comp_type)
38+
return None
39+
40+
if comp_type == "git":
41+
raw_path = data.get("path")
42+
if not raw_path:
43+
logger.warning("Component %r missing path — skipped", name)
44+
return None
45+
resolved = Path(str(raw_path)).expanduser().resolve()
46+
if not resolved.is_relative_to(Path.home()):
47+
logger.warning("Component %r path escapes home dir — skipped", name)
48+
return None
49+
50+
service = data.get("service")
51+
if service is not None and not _validate_service(str(service)):
52+
logger.warning(
53+
"Component %r has invalid service %r, skipped", name, service
54+
)
55+
return None
56+
57+
return ComponentConfig(
58+
name=name,
59+
kind=comp_type,
60+
path=resolved,
61+
service=str(service) if service else None,
62+
reset_mode=data.get("reset_mode", "hard"),
63+
order=int(data.get("order", 50)),
64+
)
65+
return ComponentConfig(name=name, kind="apt", order=int(data.get("order", 50)))
66+
67+
68+
def _merge(base: list[dict], override: list[dict]) -> list[dict]:
69+
base_by_name = {c["name"]: dict(c) for c in base if "name" in c}
70+
for entry in override:
71+
entry_name = entry.get("name")
72+
if not entry_name:
73+
continue
74+
if entry_name in base_by_name:
75+
for k, v in entry.items():
76+
if v is not None:
77+
base_by_name[entry_name][k] = v
78+
else:
79+
base_by_name[entry_name] = dict(entry)
80+
return list(base_by_name.values())
81+
82+
83+
def load_components() -> list[ComponentConfig]:
84+
"""Load and validate component definitions from bundled YAML, merged with user override."""
85+
bundled_path = Path(__file__).parent / "components.yaml"
86+
with open(bundled_path) as f:
87+
bundled_data = yaml.safe_load(f)
88+
89+
raw_components: list[dict] = bundled_data.get("components", [])
90+
91+
if OVERRIDE_PATH.exists():
92+
try:
93+
with open(OVERRIDE_PATH) as f:
94+
override_data = yaml.safe_load(f)
95+
if isinstance(override_data, dict):
96+
raw_components = _merge(
97+
raw_components,
98+
override_data.get("components", []),
99+
)
100+
except Exception as exc:
101+
logger.error(
102+
"Failed to load override YAML %s: %s",
103+
OVERRIDE_PATH,
104+
exc,
105+
)
106+
configs: list[ComponentConfig] = []
107+
for entry in raw_components:
108+
cfg = _validate_component(entry)
109+
if cfg is not None:
110+
configs.append(cfg)
111+
configs.sort(key=lambda c: c.order)
112+
return configs

updater/components.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
pool_interval_minutes: 1440
2+
3+
components:
4+
- name: klipper
5+
type: git
6+
path: ~/klipper
7+
service: klipper.service
8+
reset_mode: hard
9+
order: 1
10+
11+
- name: moonraker
12+
type: git
13+
path: ~/moonraker/
14+
service: moonraker.service
15+
reset_mode: hard
16+
order: 2
17+
18+
- name: RF50-Klipper
19+
type: git
20+
path: ~/RF50-Klipper
21+
reset_mode: hard
22+
order: 3
23+
24+
- name: system
25+
type: apt
26+
order: 4
27+
28+
- name: BlocksScreen
29+
type: git
30+
path: ~/BlocksScreen
31+
service: BlocksScreen.service
32+
reset_mode: hard
33+
order: 99

updater/models.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
6+
7+
@dataclass
8+
class ComponentConfig:
9+
name: str
10+
kind: str
11+
path: Path | None = None
12+
service: str | None = None
13+
reset_mode: str = "hard"
14+
order: int = 50
15+
16+
17+
@dataclass
18+
class ComponentStatus:
19+
name: str
20+
commits_behind: int = 0
21+
current_hash: str = ""
22+
remote_url: str = ""
23+
packages_upgradable: int = 0
24+
error: str | None = None

0 commit comments

Comments
 (0)