Skip to content

Commit 20d2e76

Browse files
committed
style: Refactor code for consistency and readability across multiple files
1 parent 76eab07 commit 20d2e76

15 files changed

+297
-55
lines changed

main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from utils.updater import check_update_available
1717

1818
# Handle PyInstaller frozen executable path resolution
19-
if getattr(sys, 'frozen', False):
19+
if getattr(sys, "frozen", False):
2020
# Running as compiled executable (PyInstaller)
2121
BASE_DIR = os.path.dirname(sys.executable)
2222
else:
@@ -170,7 +170,11 @@ def mainmenu(
170170
def main():
171171
# Use the global BASE_DIR for PyInstaller compatibility
172172
script_dir = BASE_DIR
173-
script_name = os.path.basename(__file__) if not getattr(sys, 'frozen', False) else "Money4Band"
173+
script_name = (
174+
os.path.basename(__file__)
175+
if not getattr(sys, "frozen", False)
176+
else "Money4Band"
177+
)
174178

175179
# Parse command-line arguments
176180
parser = argparse.ArgumentParser(description="Run the script.")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ dependencies = [
1515
"psutil"
1616
]
1717

18-
[project.optional-dependencies]
18+
[dependency-groups]
1919
dev = [
2020
"ruff>=0.12.0",
2121
"pytest>=8.0.0",

tests/test_fn_install_docker.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_install_docker_windows(
100100
],
101101
stdout=subprocess.PIPE,
102102
stderr=subprocess.PIPE,
103-
universal_newlines=True,
103+
text=True,
104104
shell=True,
105105
check=True,
106106
)
@@ -112,6 +112,7 @@ def test_install_docker_windows(
112112
os.getenv("ProgramFiles"), "Docker", "Docker", "Docker Desktop.exe"
113113
)
114114
],
115+
check=False,
115116
shell=True,
116117
)
117118

@@ -158,8 +159,10 @@ def test_install_docker_macos(self, mock_run, mock_download_file):
158159
@patch("utils.fn_install_docker.install_docker_linux")
159160
@patch("utils.fn_install_docker.install_docker_windows")
160161
@patch("utils.fn_install_docker.install_docker_macos")
162+
@patch("utils.fn_install_docker.is_docker_installed", return_value=False)
161163
def test_main(
162164
self,
165+
mock_is_docker_installed,
163166
mock_install_macos,
164167
mock_install_windows,
165168
mock_install_linux,

tests/test_generator.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import os
2+
import tempfile
13
import unittest
24

3-
from utils.generator import generate_device_name, generate_uuid, validate_uuid
5+
import yaml
6+
7+
from utils.generator import (
8+
assemble_docker_compose,
9+
generate_device_name,
10+
generate_env_file,
11+
generate_uuid,
12+
validate_uuid,
13+
)
414

515

616
class TestGeneratorFunctions(unittest.TestCase):
@@ -22,5 +32,132 @@ def test_generate_device_name(self):
2232
self.assertIn(device_name.split("_")[1], animals)
2333

2434

35+
# ── Minimal configs shared by env-file and compose tests ─────────────────────
36+
37+
_M4B_CFG = {
38+
"project": {"project_version": "test"},
39+
"network": {"subnet": "172.19.0.0", "netmask": "24"},
40+
"system": {"default_docker_platform": "linux/amd64"},
41+
"watchtower": {"enable_labels": True, "scope": "money4band"},
42+
}
43+
44+
_APP_CFG = {"apps": [], "extra-apps": []}
45+
46+
_USER_CFG_BASE = {
47+
"device_info": {"device_name": "testdev"},
48+
"resource_limits": {},
49+
"apps": {},
50+
"proxies": {"enabled": False, "url": "", "url_example": ""},
51+
"notifications": {"enabled": False, "url": ""},
52+
"m4b_dashboard": {"enabled": False},
53+
"watchtower": {"enabled": True},
54+
"compose_config_common": {
55+
"network": {
56+
"driver": "${NETWORK_DRIVER}",
57+
"subnet": "${NETWORK_SUBNET}",
58+
"netmask": "${NETWORK_NETMASK}",
59+
},
60+
"watchtower_service": {
61+
"proxy_disabled": {
62+
"container_name": "${DEVICE_NAME}_watchtower",
63+
"image": "nickfedor/watchtower:latest",
64+
"environment": ["WATCHTOWER_SCOPE=${M4B_WATCHTOWER_SCOPE}"],
65+
"labels": ["com.centurylinklabs.watchtower.enable=true"],
66+
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
67+
"restart": "always",
68+
},
69+
"proxy_enabled": {
70+
"container_name": "${DEVICE_NAME}_watchtower",
71+
"image": "nickfedor/watchtower:latest",
72+
"environment": ["WATCHTOWER_SCOPE=${M4B_WATCHTOWER_SCOPE}"],
73+
"labels": ["com.centurylinklabs.watchtower.enable=true"],
74+
"volumes": ["/var/run/docker.sock:/var/run/docker.sock"],
75+
"restart": "always",
76+
},
77+
},
78+
"m4b_dashboard_service": {
79+
"container_name": "${DEVICE_NAME}_m4b_dashboard",
80+
"image": "nginx:alpine-slim",
81+
"restart": "always",
82+
},
83+
"proxy_service": {
84+
"container_name": "${DEVICE_NAME}_proxy",
85+
"image": "xjasonlyu/tun2socks",
86+
"restart": "always",
87+
},
88+
},
89+
}
90+
91+
92+
class TestGenerateEnvFileWatchtower(unittest.TestCase):
93+
"""Verify M4B_WATCHTOWER_LABELS and M4B_WATCHTOWER_SCOPE are always emitted."""
94+
95+
def _run(self, m4b_cfg, user_cfg=None):
96+
if user_cfg is None:
97+
user_cfg = _USER_CFG_BASE.copy()
98+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".env") as f:
99+
env_path = f.name
100+
try:
101+
generate_env_file(m4b_cfg, _APP_CFG, user_cfg, env_path)
102+
return open(env_path).read()
103+
finally:
104+
if os.path.exists(env_path):
105+
os.unlink(env_path)
106+
107+
def test_default_scope_emitted(self):
108+
"""Default scope values from m4b-config appear in .env."""
109+
content = self._run(_M4B_CFG)
110+
self.assertIn("M4B_WATCHTOWER_LABELS=true", content)
111+
self.assertIn("M4B_WATCHTOWER_SCOPE=money4band", content)
112+
113+
def test_custom_scope_emitted(self):
114+
"""Custom scope/labels values from m4b-config appear in .env."""
115+
cfg = dict(
116+
_M4B_CFG, watchtower={"enable_labels": False, "scope": "custom-scope"}
117+
)
118+
content = self._run(cfg)
119+
self.assertIn("M4B_WATCHTOWER_LABELS=false", content)
120+
self.assertIn("M4B_WATCHTOWER_SCOPE=custom-scope", content)
121+
122+
def test_missing_watchtower_key_uses_defaults(self):
123+
"""When watchtower key is absent in m4b-config, hardcoded defaults kick in."""
124+
cfg = {k: v for k, v in _M4B_CFG.items() if k != "watchtower"}
125+
content = self._run(cfg)
126+
self.assertIn("M4B_WATCHTOWER_LABELS=true", content)
127+
self.assertIn("M4B_WATCHTOWER_SCOPE=money4band", content)
128+
129+
130+
class TestAssembleDockerComposeWatchtower(unittest.TestCase):
131+
"""Verify the watchtower.enabled toggle controls Watchtower service inclusion."""
132+
133+
def _compose(self, watchtower_enabled: bool) -> dict:
134+
import copy
135+
136+
user_cfg = copy.deepcopy(_USER_CFG_BASE)
137+
user_cfg["watchtower"]["enabled"] = watchtower_enabled
138+
139+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml") as f:
140+
compose_path = f.name
141+
try:
142+
assemble_docker_compose(
143+
_M4B_CFG, _APP_CFG, user_cfg, compose_path, is_main_instance=True
144+
)
145+
with open(compose_path) as f:
146+
return yaml.safe_load(f) or {}
147+
finally:
148+
if os.path.exists(compose_path):
149+
os.unlink(compose_path)
150+
151+
def test_watchtower_service_included_when_enabled(self):
152+
"""watchtower service is present in compose when watchtower.enabled is True."""
153+
doc = self._compose(watchtower_enabled=True)
154+
self.assertIn("watchtower", doc.get("services", {}))
155+
156+
def test_watchtower_service_omitted_when_disabled(self):
157+
"""watchtower service is absent from compose when watchtower.enabled is False."""
158+
doc = self._compose(watchtower_enabled=False)
159+
self.assertNotIn("watchtower", doc.get("services", {}))
160+
161+
25162
if __name__ == "__main__":
26163
unittest.main()

tests/test_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_load_json_config_file(self, mock_file):
2020

2121
config = load_json_config(config_path)
2222
self.assertEqual(config, expected_config)
23-
mock_file.assert_called_once_with(config_path, "r")
23+
mock_file.assert_called_once_with(config_path)
2424

2525
def test_load_json_config_dict(self):
2626
"""

tests/test_networker.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
Test networker module functionality.
33
"""
44

5-
import socket
65
import unittest
76
from unittest.mock import patch
87

@@ -141,11 +140,11 @@ def test_find_next_available_port_empty_exclude_ports(self):
141140
def test_find_next_available_port_realistic_scenario(self):
142141
"""
143142
Test a realistic scenario of assigning multiple sequential ports.
144-
143+
145144
This test simulates the scenario in assign_app_ports where we need to assign
146145
5 ports starting from base 50000, while avoiding both system-used ports
147146
(50001, 50003, 50005) and already assigned ports in the same loop.
148-
147+
149148
Expected result: [50000, 50002, 50004, 50006, 50007]
150149
- 50000: free, assigned
151150
- 50001: used by system, skip

tests/test_port_logic.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,40 @@ def test_generate_env_file_handles_list_ports(self):
190190
self.assertIn("WIPTER_PORT_2=6081", env_content)
191191
# Should NOT have non-indexed WIPTER_PORT for multiple ports
192192

193+
# Watchtower scope vars are always emitted — defaults when watchtower key absent
194+
self.assertIn("M4B_WATCHTOWER_LABELS=true", env_content)
195+
self.assertIn("M4B_WATCHTOWER_SCOPE=money4band", env_content)
196+
197+
finally:
198+
if os.path.exists(env_path):
199+
os.unlink(env_path)
200+
201+
def test_generate_env_file_watchtower_vars_configurable(self):
202+
"""Watchtower scope vars respect m4b_config overrides."""
203+
m4b_config = {
204+
"network": {"subnet": "172.19.7.0", "netmask": "27"},
205+
"system": {},
206+
"watchtower": {"enable_labels": False, "scope": "custom-scope"},
207+
}
208+
app_config = {"apps": []}
209+
user_config = {
210+
"device_info": {"device_name": "testdev"},
211+
"resource_limits": {},
212+
"apps": {},
213+
}
214+
215+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".env") as f:
216+
env_path = f.name
217+
218+
try:
219+
generate_env_file(m4b_config, app_config, user_config, env_path)
220+
221+
with open(env_path) as f:
222+
env_content = f.read()
223+
224+
self.assertIn("M4B_WATCHTOWER_LABELS=false", env_content)
225+
self.assertIn("M4B_WATCHTOWER_SCOPE=custom-scope", env_content)
226+
193227
finally:
194228
if os.path.exists(env_path):
195229
os.unlink(env_path)

tests/test_prompt_helper.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ def test_ask_email_empty_then_valid(self, mock_input):
1414
self.assertEqual(ask_email("Enter email:"), "test@example.com")
1515

1616
@patch("builtins.input", return_value="")
17-
def test_ask_string_empty_allowed(self, mock_input):
18-
self.assertEqual(ask_string("Enter string:", empty_allowed=True), "")
17+
def test_ask_string_returns_default_when_empty(self, mock_input):
18+
"""When input is empty and a default is provided, the default is returned."""
19+
self.assertEqual(ask_string("Enter string:", default="fallback"), "fallback")
1920

2021
@patch("builtins.input", return_value="non-empty string")
2122
def test_ask_string_not_empty(self, mock_input):

tests/test_reset_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ def test_reset_config(self):
2626
dest_path = os.path.join(self.dest_dir, "test-config.json")
2727

2828
reset_config_main(
29-
app_config=None,
30-
m4b_config=None,
31-
user_config=None,
29+
app_config_path=None,
30+
m4b_config_path=None,
31+
user_config_path=None,
3232
src_path=src_path,
3333
dest_path=dest_path,
3434
)

utils/fn_setupApps.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ def assign_app_ports(
207207
port_candidate = base_port_for_app_instance + i
208208

209209
# Find next available port starting from the calculated candidate
210-
available_port = find_next_available_port(port_candidate, exclude_ports=assigned_ports)
210+
available_port = find_next_available_port(
211+
port_candidate, exclude_ports=assigned_ports
212+
)
211213
assigned_ports.append(available_port)
212214

213215
# Log the port assignment
@@ -453,7 +455,7 @@ def _configure_apps(
453455
"""
454456
# Track port_app_index separately - only incremented for apps WITH ports
455457
port_app_index = app_index_offset
456-
458+
457459
for app in apps:
458460
app_name = app["name"].lower()
459461
config = user_config["apps"].get(app_name, {})
@@ -505,7 +507,9 @@ def _configure_apps(
505507
"detected_docker_arch", "amd64"
506508
)
507509
config["docker_platform"] = f"linux/{detected_docker_arch}"
508-
logging.info(f"Docker platform for {app_name} set to: {config['docker_platform']}")
510+
logging.info(
511+
f"Docker platform for {app_name} set to: {config['docker_platform']}"
512+
)
509513

510514
user_config["apps"][app_name] = config
511515

@@ -538,10 +542,15 @@ def configure_extra_apps(
538542
# Extra apps port_app_index starts AFTER regular apps WITH ports
539543
# Count only apps that have ports in compose_config
540544
port_consuming_apps = sum(
541-
1 for app in app_config.get("apps", []) if "ports" in app.get("compose_config", {})
545+
1
546+
for app in app_config.get("apps", [])
547+
if "ports" in app.get("compose_config", {})
542548
)
543549
_configure_apps(
544-
user_config, app_config["extra-apps"], m4b_config, app_index_offset=port_consuming_apps
550+
user_config,
551+
app_config["extra-apps"],
552+
m4b_config,
553+
app_index_offset=port_consuming_apps,
545554
)
546555

547556

@@ -677,7 +686,8 @@ def setup_m4b_dashboard(user_config: dict[str, Any]) -> None:
677686
# Ask if user wants to enable the dashboard
678687
if ask_question_yn(
679688
"Do you want to enable the M4B web dashboard? "
680-
"(A simple local web page to access apps dashboards)"
689+
"(A simple local web page to access apps dashboards)",
690+
default=True,
681691
):
682692
logging.info("User decided to enable the M4B web dashboard.")
683693

@@ -728,7 +738,8 @@ def setup_watchtower(user_config: dict[str, Any]) -> None:
728738

729739
if ask_question_yn(
730740
"Do you want M4B to manage container auto-updates via its built-in Watchtower?\n"
731-
"(Disable only if another Watchtower instance is already running on this host)"
741+
"(Disable only if another Watchtower instance is already running on this host)",
742+
default=True,
732743
):
733744
watchtower_config["enabled"] = True
734745
print("M4B built-in Watchtower enabled. Container images will be auto-updated.")
@@ -1012,7 +1023,7 @@ def main(app_config_path: str, m4b_config_path: str, user_config_path: str) -> N
10121023
if user_config.get("watchtower", {}).get("enabled", True):
10131024
setup_notifications(user_config)
10141025
else:
1015-
user_config["notifications"]["enabled"] = False
1026+
user_config.setdefault("notifications", {})["enabled"] = False
10161027
logging.info("Skipping notification setup: Watchtower is disabled.")
10171028
# Step 5: Set up M4B dashboard
10181029
setup_m4b_dashboard(user_config)

0 commit comments

Comments
 (0)