Skip to content

Commit b5e677a

Browse files
authored
feat: add abstract app module and refactor config (#206)
# Improvements ## Type Enforcement Types are checked using a static analysis tool (mypy) to ensure nothing is None when it shouldn't be (or a Path when it should be a string) ## Network Caching All network requests are cached ## Config Enforcement Wrapper struct to ensure all config variables are set, if not the user is prompted (invalid answers are prompted again). Additionally configuration hooks were added so all UIs can refresh themselves if the config changes underneath them. A reload function has also been added (TUI only as it's for power users and offers no benefit to the cli) Config Format Change In the interest of consistency of variable names, the config keys have changed. Legacy values will continue to be read (and written). New keys take precedence. This new config can be copied to older versions of the installer and it should (mostly) work (not aware of any deficiencies however downgrading is hard to support). Legacy paths are moved to the new location. ## Graphical User Interface (tinker) Ensured that the ask function was never called. We want to use the given UI, as it's prettier. However if the case should arise where we accidentally access a variable before we set it, the user will see a pop-up with the one question. Data flow has been changed to pull all values from the config, it no longer stores copies. It also now populates all drop-downs in real-time as other drop-downs are changed, as a result there was no longer any need for the "Get EXE" and "Get Release List" buttons, so they were removed. Progress bar now considers the entire installation rather than "completing" after each step. ## Terminal User Interface (curses) Appears the same as before from the user's standpoint, however now there is a generic ask page, greatly cutting down on the number of queues/Events. There may be some more unused Queues/Events lingering, didn't see value in cleaning them up. ## Command Line Interface Prompts appears the same as before, progress bar is now prepended to status lines ## Misc other changes - renamed arch p7zip package to 7zip - use winecfg rather than winetricks to set win version # Closing Notes One goal of this refactor was to keep the code from being understood by someone who is already familiar with it. There is a couple new concepts, the abstract base class, reworked config, and network cache, but the core logic of how it works remains unchanged. If something isn't clear please ask.
1 parent ea4c7f2 commit b5e677a

28 files changed

+5070
-4608
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ env/
77
venv/
88
.venv/
99
.idea/
10-
*.egg-info
10+
*.egg-info
11+
.vscode

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
- 4.0.0-beta.6
4+
- Implemented network cache [N. Shaaban]
5+
- Refactored configuration handling (no user action required) [N. Shaaban]
6+
- 4.0.0-beta.5
7+
- Filter out broken Logos v39 release [N. Shaaban]
8+
- Fix #17 [T. H. Wright]
9+
- Make dependency lists more precise [M. Marti, N. Shaaban]
10+
- Fix #230 [N. Shaaban]
311
- 4.0.0-beta.4
412
- Fix #220 [N. Shaaban]
513
- 4.0.0-beta.3

ou_dedetai/app.py

+257
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
2+
import abc
3+
import logging
4+
import os
5+
from pathlib import Path
6+
import sys
7+
import threading
8+
from typing import Callable, NoReturn, Optional
9+
10+
from ou_dedetai import constants
11+
from ou_dedetai.constants import (
12+
PROMPT_OPTION_DIRECTORY,
13+
PROMPT_OPTION_FILE
14+
)
15+
16+
17+
class App(abc.ABC):
18+
# FIXME: consider weighting install steps. Different steps take different lengths
19+
installer_step_count: int = 0
20+
"""Total steps in the installer, only set the installation process has started."""
21+
installer_step: int = 1
22+
"""Step the installer is on. Starts at 0"""
23+
24+
_threads: list[threading.Thread]
25+
"""List of threads
26+
27+
Non-daemon threads will be joined before shutdown
28+
"""
29+
_last_status: Optional[str] = None
30+
"""The last status we had"""
31+
config_updated_hooks: list[Callable[[], None]] = []
32+
_config_updated_event: threading.Event = threading.Event()
33+
34+
def __init__(self, config, **kwargs) -> None:
35+
# This lazy load is required otherwise these would be circular imports
36+
from ou_dedetai.config import Config
37+
from ou_dedetai.logos import LogosManager
38+
from ou_dedetai.system import check_incompatibilities
39+
40+
self.conf = Config(config, self)
41+
self.logos = LogosManager(app=self)
42+
self._threads = []
43+
# Ensure everything is good to start
44+
check_incompatibilities(self)
45+
46+
def _config_updated_hook_runner():
47+
while True:
48+
self._config_updated_event.wait()
49+
self._config_updated_event.clear()
50+
for hook in self.config_updated_hooks:
51+
try:
52+
hook()
53+
except Exception:
54+
logging.exception("Failed to run config update hook")
55+
_config_updated_hook_runner.__name__ = "Config Update Hook"
56+
self.start_thread(_config_updated_hook_runner, daemon_bool=True)
57+
58+
def ask(self, question: str, options: list[str]) -> str:
59+
"""Asks the user a question with a list of supplied options
60+
61+
Returns the option the user picked.
62+
63+
If the internal ask function returns None, the process will exit with 1
64+
"""
65+
def validate_result(answer: str, options: list[str]) -> Optional[str]:
66+
special_cases = set([PROMPT_OPTION_DIRECTORY, PROMPT_OPTION_FILE])
67+
# These constants have special meaning, don't worry about them to start with
68+
simple_options = list(set(options) - special_cases)
69+
# This MUST have the same indexes as above
70+
simple_options_lower = [opt.lower() for opt in simple_options]
71+
72+
# Case sensitive check first
73+
if answer in simple_options:
74+
return answer
75+
# Also do a case insensitive match, no reason to fail due to casing
76+
if answer.lower() in simple_options_lower:
77+
# Return the correct casing to simplify the parsing of the ask result
78+
return simple_options[simple_options.index(answer.lower())]
79+
80+
# Now check the special cases
81+
if PROMPT_OPTION_FILE in options and Path(answer).is_file():
82+
return answer
83+
if PROMPT_OPTION_DIRECTORY in options and Path(answer).is_dir():
84+
return answer
85+
86+
# Not valid
87+
return None
88+
89+
# Check to see if we're supposed to prompt the user
90+
if self.conf._overrides.assume_yes:
91+
# Get the first non-dynamic option
92+
for option in options:
93+
if option not in [PROMPT_OPTION_DIRECTORY, PROMPT_OPTION_FILE]:
94+
return option
95+
96+
passed_options: list[str] | str = options
97+
if len(passed_options) == 1 and (
98+
PROMPT_OPTION_DIRECTORY in passed_options
99+
or PROMPT_OPTION_FILE in passed_options
100+
):
101+
# Set the only option to be the follow up prompt
102+
passed_options = options[0]
103+
elif passed_options is not None and self._exit_option is not None:
104+
passed_options = options + [self._exit_option]
105+
106+
answer = self._ask(question, passed_options)
107+
while answer is None or validate_result(answer, options) is None:
108+
invalid_response = "That response is not valid, please try again."
109+
new_question = f"{invalid_response}\n{question}"
110+
answer = self._ask(new_question, passed_options)
111+
112+
if answer is not None:
113+
answer = validate_result(answer, options)
114+
if answer is None:
115+
# Huh? coding error, this should have been checked earlier
116+
logging.critical("An invalid response slipped by, please report this incident to the developers") #noqa: E501
117+
self.exit("Failed to get a valid value from user")
118+
119+
if answer == self._exit_option:
120+
answer = None
121+
122+
if answer is None:
123+
self.exit("Failed to get a valid value from user")
124+
125+
return answer
126+
127+
def approve_or_exit(self, question: str, context: Optional[str] = None):
128+
"""Asks the user a question, if they refuse, shutdown"""
129+
if not self.approve(question, context):
130+
self.exit(f"User refused the prompt: {question}")
131+
132+
def approve(self, question: str, context: Optional[str] = None) -> bool:
133+
"""Asks the user a y/n question"""
134+
question = (f"{context}\n" if context else "") + question
135+
options = ["Yes", "No"]
136+
return self.ask(question, options) == "Yes"
137+
138+
def exit(self, reason: str, intended: bool = False) -> NoReturn:
139+
"""Exits the application cleanly with a reason."""
140+
logging.debug(f"Closing {constants.APP_NAME}.")
141+
# Shutdown logos/indexer if we spawned it
142+
self.logos.end_processes()
143+
# Join threads
144+
for thread in self._threads:
145+
# Only wait on non-daemon threads.
146+
if not thread.daemon:
147+
try:
148+
thread.join()
149+
except RuntimeError:
150+
# Will happen if we try to join the current thread
151+
pass
152+
# Remove pid file if exists
153+
try:
154+
os.remove(constants.PID_FILE)
155+
except FileNotFoundError: # no pid file when testing functions
156+
pass
157+
# exit from the process
158+
if intended:
159+
sys.exit(0)
160+
else:
161+
logging.critical(f"Cannot continue because {reason}\n{constants.SUPPORT_MESSAGE}") #noqa: E501
162+
sys.exit(1)
163+
164+
_exit_option: Optional[str] = "Exit"
165+
166+
@abc.abstractmethod
167+
def _ask(self, question: str, options: list[str] | str) -> Optional[str]:
168+
"""Implementation for asking a question pre-front end
169+
170+
Options may include ability to prompt for an additional value.
171+
Such as asking for one of strings or a directory.
172+
If the user selects choose a new directory, the
173+
implementations MUST handle the follow up prompt before returning
174+
175+
Options may be a single value,
176+
Implementations MUST handle this single option being a follow up prompt
177+
"""
178+
raise NotImplementedError()
179+
180+
def is_installed(self) -> bool:
181+
"""Returns whether the install was successful by
182+
checking if the installed exe exists and is executable"""
183+
if self.conf.logos_exe is not None:
184+
return os.access(self.conf.logos_exe, os.X_OK)
185+
return False
186+
187+
def status(self, message: str, percent: Optional[int | float] = None):
188+
"""A status update
189+
190+
Args:
191+
message: str - if it ends with a \r that signifies that this message is
192+
intended to be overrighten next time
193+
percent: Optional[int] - percent of the way through the current install step
194+
(if installing)
195+
"""
196+
# Check to see if we want to suppress all output
197+
if self.conf._overrides.quiet:
198+
return
199+
200+
if isinstance(percent, float):
201+
percent = round(percent * 100)
202+
# If we're installing
203+
if self.installer_step_count != 0:
204+
current_step_percent = percent or 0
205+
# We're further than the start of our current step, percent more
206+
installer_percent = round((self.installer_step * 100 + current_step_percent) / self.installer_step_count) # noqa: E501
207+
logging.debug(f"Install {installer_percent}: {message}")
208+
self._status(message, percent=installer_percent)
209+
else:
210+
# Otherwise just print status using the progress given
211+
logging.debug(f"{message}: {percent}")
212+
self._status(message, percent)
213+
self._last_status = message
214+
215+
@abc.abstractmethod
216+
def _status(self, message: str, percent: Optional[int] = None):
217+
"""Implementation for updating status pre-front end
218+
219+
Args:
220+
message: str - if it ends with a \r that signifies that this message is
221+
intended to be overrighten next time
222+
percent: Optional[int] - percent complete of the current overall operation
223+
if None that signifies we can't track the progress.
224+
Feel free to implement a spinner
225+
"""
226+
# De-dup
227+
if message != self._last_status:
228+
if message.endswith("\r"):
229+
print(f"{message}", end="\r")
230+
else:
231+
print(f"{message}")
232+
233+
@property
234+
def superuser_command(self) -> str:
235+
"""Command when root privileges are needed.
236+
237+
Raises:
238+
SuperuserCommandNotFound
239+
240+
May be sudo or pkexec for example"""
241+
from ou_dedetai.system import get_superuser_command
242+
return get_superuser_command()
243+
244+
def start_thread(self, task, *args, daemon_bool: bool = True, **kwargs):
245+
"""Starts a new thread
246+
247+
Non-daemon threads be joined before shutdown"""
248+
thread = threading.Thread(
249+
name=f"{constants.APP_NAME} {task}",
250+
target=task,
251+
daemon=daemon_bool,
252+
args=args,
253+
kwargs=kwargs
254+
)
255+
self._threads.append(thread)
256+
thread.start()
257+
return thread

0 commit comments

Comments
 (0)