Skip to content

Commit 86d56a5

Browse files
committed
feat: support case insensitive scoop manifest data
1 parent 1b6b40c commit 86d56a5

2 files changed

Lines changed: 90 additions & 20 deletions

File tree

src/py_app_dev/core/scoop_wrapper.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from functools import cmp_to_key
66
from pathlib import Path
77
from tempfile import TemporaryDirectory
8-
from typing import Any
8+
from typing import Any, Optional
99

10-
from mashumaro import DataClassDictMixin
10+
from mashumaro import field_options
11+
from mashumaro.config import BaseConfig
1112
from mashumaro.mixins.json import DataClassJSONMixin
1213

1314
from .exceptions import UserNotificationException
@@ -53,13 +54,63 @@ def to_int(s: Any) -> int:
5354
return 0
5455

5556

57+
class BaseConfigJSONMixin(DataClassJSONMixin):
58+
class Config(BaseConfig):
59+
omit_none = True
60+
serialize_by_alias = True
61+
62+
def to_json_string(self) -> str:
63+
return json.dumps(self.to_dict(), indent=2)
64+
65+
def to_json_file(self, file_path: Path) -> None:
66+
file_path.write_text(self.to_json_string())
67+
68+
5669
@dataclass
57-
class ScoopFileElement(DataClassDictMixin):
70+
class ScoopFileElement(BaseConfigJSONMixin):
5871
"""Represents an app or bucket entry in the scoopfile.json."""
5972

60-
name: str = field(metadata={"alias": "Name"})
61-
source: str = field(metadata={"alias": "Source"})
62-
version: str | None = field(default=None, metadata={"alias": "Version"})
73+
_name_lc: Optional[str] = field(default=None, metadata=field_options(alias="name"))
74+
_name_uc: Optional[str] = field(default=None, metadata=field_options(alias="Name"))
75+
#: Source bucket
76+
_source_lc: Optional[str] = field(default=None, metadata=field_options(alias="source"))
77+
_source_uc: Optional[str] = field(default=None, metadata=field_options(alias="Source"))
78+
79+
_version_lc: Optional[str] = field(default=None, metadata=field_options(alias="version"))
80+
_version_uc: Optional[str] = field(default=None, metadata=field_options(alias="Version"))
81+
82+
@property
83+
def name(self) -> str:
84+
if self._name_uc:
85+
return self._name_uc
86+
elif self._name_lc:
87+
return self._name_lc
88+
else:
89+
raise UserNotificationException("ScoopApp must have a 'Name' or 'name' field defined.")
90+
91+
@property
92+
def source(self) -> str:
93+
if self._source_uc:
94+
return self._source_uc
95+
elif self._source_lc:
96+
return self._source_lc
97+
else:
98+
raise UserNotificationException("ScoopApp must have a 'Source' or 'source' field defined.")
99+
100+
@property
101+
def version(self) -> Optional[str]:
102+
if self._version_uc:
103+
return self._version_uc
104+
elif self._version_lc:
105+
return self._version_lc
106+
else:
107+
return None
108+
109+
def __post_init__(self) -> None:
110+
if not self._name_lc and not self._name_uc:
111+
raise UserNotificationException("Scoop element must have a 'Name' or 'name' field defined.")
112+
if not self._source_lc and not self._source_uc:
113+
raise UserNotificationException("Scoop element must have a 'Source' or 'source' field defined.")
63114

64115
def __hash__(self) -> int:
65116
return hash(f"{self.name}-{self.source}-{self.version}")
@@ -75,7 +126,7 @@ def __eq__(self, other: object) -> bool:
75126

76127

77128
@dataclass
78-
class ScoopInstallConfigFile(DataClassJSONMixin):
129+
class ScoopInstallConfigFile(BaseConfigJSONMixin):
79130
"""Represents the structure of the scoopfile.json."""
80131

81132
buckets: list[ScoopFileElement]
@@ -94,10 +145,6 @@ def from_file(cls, scoop_file: Path) -> "ScoopInstallConfigFile":
94145
with open(scoop_file) as f:
95146
return cls.from_dict(json.load(f))
96147

97-
def to_file(self, scoop_file: Path) -> None:
98-
with open(scoop_file, "w") as f:
99-
json.dump(self.to_dict(), f, indent=4)
100-
101148

102149
@dataclass
103150
class InstalledScoopApp:
@@ -269,7 +316,7 @@ def do_install_missing(
269316
# Create a temporary scoopfile with the remaining apps to install and install them
270317
with TemporaryDirectory() as tmp_dir:
271318
tmp_scoop_file = Path(tmp_dir).joinpath("scoopfile.json")
272-
ScoopInstallConfigFile(scoop_install_config.buckets, apps_to_install).to_file(tmp_scoop_file)
319+
ScoopInstallConfigFile(scoop_install_config.buckets, apps_to_install).to_json_file(tmp_scoop_file)
273320
self.run_powershell_command(f"{self.scoop_script} import {tmp_scoop_file}")
274321
return apps_to_install
275322

tests/test_scoop_wrapper.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ def create_scoop_wrapper(scoop_executable: Path | None) -> ScoopWrapper:
3131
return scoop_wrapper
3232

3333

34+
def test_scoop_data_case_insensitive() -> None:
35+
scoop_content = {
36+
"buckets": [{"Name": "my_bucket", "Source": "https://github.com/my/bucket"}],
37+
"apps": [
38+
{
39+
"Name": "app1",
40+
"Source": "my_bucket",
41+
"Version": "1.0.0",
42+
},
43+
{
44+
"name": "app2",
45+
"source": "my_bucket",
46+
"version": "2.0.0",
47+
},
48+
],
49+
}
50+
content = ScoopInstallConfigFile.from_dict(scoop_content)
51+
assert content.buckets[0].name == "my_bucket"
52+
assert {app.name for app in content.apps} == {"app1", "app2"}
53+
# Check serialization back to dict
54+
assert json.loads(content.to_json_string()) == scoop_content
55+
56+
3457
def test_scoop_installed(tmp_path: Path) -> None:
3558
scoop_exec = tmp_path / "scoop" / "my_scoop.ps1"
3659
scoop_exec.parent.mkdir(parents=True)
@@ -203,7 +226,7 @@ def test_scoop_file_parsing(tmp_path: Path) -> None:
203226
"required_apps, installed_apps, expected_app_versions, expected_exception",
204227
[
205228
(
206-
[ScoopFileElement(name="app1", source="test", version=None)],
229+
[ScoopFileElement.from_dict({"name": "app1", "source": "test", "version": None})],
207230
[
208231
InstalledScoopApp(
209232
name="app1",
@@ -226,7 +249,7 @@ def test_scoop_file_parsing(tmp_path: Path) -> None:
226249
None,
227250
),
228251
(
229-
[ScoopFileElement(name="app2", source="test", version="1.0.0")],
252+
[ScoopFileElement.from_dict({"name": "app2", "source": "test", "version": "1.0.0"})],
230253
[
231254
InstalledScoopApp(
232255
name="app2",
@@ -241,7 +264,7 @@ def test_scoop_file_parsing(tmp_path: Path) -> None:
241264
None,
242265
),
243266
(
244-
[ScoopFileElement(name="app3", source="test", version="1.0.0")],
267+
[ScoopFileElement.from_dict({"name": "app3", "source": "test", "version": "1.0.0"})],
245268
[
246269
InstalledScoopApp(
247270
name="app3",
@@ -256,7 +279,7 @@ def test_scoop_file_parsing(tmp_path: Path) -> None:
256279
UserNotificationException, # version mismatch
257280
),
258281
(
259-
[ScoopFileElement(name="app4", source="test", version=None)],
282+
[ScoopFileElement.from_dict({"name": "app4", "source": "test", "version": None})],
260283
[
261284
InstalledScoopApp(
262285
name="app1",
@@ -289,10 +312,10 @@ def test_map_required_apps_to_installed_apps(
289312
def test_do_install_missing(scoop_dir: Path) -> None:
290313
# Create a mock for scoop_install_config
291314
scoop_install_config = ScoopInstallConfigFile(
292-
buckets=[ScoopFileElement(name="bucket1", source="source1")],
315+
buckets=[ScoopFileElement.from_dict({"name": "bucket1", "source": "source1"})],
293316
apps=[
294-
ScoopFileElement(name="app1", source="source1"),
295-
ScoopFileElement(name="app2", source="source2"),
317+
ScoopFileElement.from_dict({"name": "app1", "source": "source1"}),
318+
ScoopFileElement.from_dict({"name": "app2", "source": "source2"}),
296319
],
297320
)
298321

@@ -354,7 +377,7 @@ def create_installed_list(apps: list[tuple[str, str]]) -> list[InstalledScoopApp
354377
def create_required_list(
355378
apps: list[tuple[str, str, str | None]],
356379
) -> list[ScoopFileElement]:
357-
return [ScoopFileElement(name=name, source=src, version=ver) for name, src, ver in apps]
380+
return [ScoopFileElement.from_dict({"name": name, "source": src, "version": ver}) for name, src, ver in apps]
358381

359382

360383
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)