work-tracker 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.
Files changed (68) hide show
  1. work_tracker/__init__.py +8 -0
  2. work_tracker/_work_tracker.py +236 -0
  3. work_tracker/checkpoint_manager.py +56 -0
  4. work_tracker/command/__init__.py +1 -0
  5. work_tracker/command/command_handler.py +36 -0
  6. work_tracker/command/command_history.py +48 -0
  7. work_tracker/command/command_initializer.py +88 -0
  8. work_tracker/command/command_manager.py +85 -0
  9. work_tracker/command/command_parser.py +395 -0
  10. work_tracker/command/command_query_handler.py +93 -0
  11. work_tracker/command/command_text_parser.py +42 -0
  12. work_tracker/command/commands/__date.py +22 -0
  13. work_tracker/command/commands/__macro.py +54 -0
  14. work_tracker/command/commands/__time.py +48 -0
  15. work_tracker/command/commands/block.py +9 -0
  16. work_tracker/command/commands/calculate.py +9 -0
  17. work_tracker/command/commands/calendar.py +101 -0
  18. work_tracker/command/commands/checkpoint.py +46 -0
  19. work_tracker/command/commands/clear.py +42 -0
  20. work_tracker/command/commands/config.py +92 -0
  21. work_tracker/command/commands/dayoff.py +42 -0
  22. work_tracker/command/commands/days.py +92 -0
  23. work_tracker/command/commands/deletemacro.py +20 -0
  24. work_tracker/command/commands/done.py +38 -0
  25. work_tracker/command/commands/end.py +42 -0
  26. work_tracker/command/commands/exit.py +20 -0
  27. work_tracker/command/commands/fte.py +48 -0
  28. work_tracker/command/commands/help.py +116 -0
  29. work_tracker/command/commands/history.py +60 -0
  30. work_tracker/command/commands/holiday.py +27 -0
  31. work_tracker/command/commands/info.py +9 -0
  32. work_tracker/command/commands/key.py +39 -0
  33. work_tracker/command/commands/macro.py +86 -0
  34. work_tracker/command/commands/minutes.py +90 -0
  35. work_tracker/command/commands/office.py +28 -0
  36. work_tracker/command/commands/recalculate.py +9 -0
  37. work_tracker/command/commands/redo.py +22 -0
  38. work_tracker/command/commands/remote.py +28 -0
  39. work_tracker/command/commands/rollback.py +21 -0
  40. work_tracker/command/commands/rwr.py +50 -0
  41. work_tracker/command/commands/setup.py +9 -0
  42. work_tracker/command/commands/start.py +42 -0
  43. work_tracker/command/commands/status.py +92 -0
  44. work_tracker/command/commands/target.py +101 -0
  45. work_tracker/command/commands/tutorial.py +139 -0
  46. work_tracker/command/commands/undo.py +22 -0
  47. work_tracker/command/commands/version.py +21 -0
  48. work_tracker/command/commands/workday.py +27 -0
  49. work_tracker/command/commands/zero.py +37 -0
  50. work_tracker/command/common.py +734 -0
  51. work_tracker/command/macro_manager.py +118 -0
  52. work_tracker/common.py +442 -0
  53. work_tracker/config.py +101 -0
  54. work_tracker/data/default.config.yaml +31 -0
  55. work_tracker/data/default.macros.txt +6 -0
  56. work_tracker/error.py +180 -0
  57. work_tracker/main.py +31 -0
  58. work_tracker/text/__init__.py +0 -0
  59. work_tracker/text/common.py +228 -0
  60. work_tracker/text/input_command_completer.py +47 -0
  61. work_tracker/text/input_output_handler.py +53 -0
  62. work_tracker/version.py +2 -0
  63. work_tracker-0.1.0.dist-info/LICENSE +21 -0
  64. work_tracker-0.1.0.dist-info/METADATA +71 -0
  65. work_tracker-0.1.0.dist-info/RECORD +68 -0
  66. work_tracker-0.1.0.dist-info/WHEEL +5 -0
  67. work_tracker-0.1.0.dist-info/entry_points.txt +2 -0
  68. work_tracker-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ from .version import VERSION, __version__
2
+ from ._work_tracker import WorkTracker
3
+
4
+ __all__ = (
5
+ "__version__",
6
+ "VERSION",
7
+ "WorkTracker",
8
+ )
@@ -0,0 +1,236 @@
1
+ import calendar
2
+ import datetime
3
+ import shutil
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import traceback
8
+ from pathlib import Path
9
+
10
+ from workalendar.registry import registry
11
+
12
+ from work_tracker import __version__
13
+ from work_tracker.checkpoint_manager import CheckpointManager
14
+ from work_tracker.command.command_parser import CommandParser
15
+ from work_tracker.command.command_query_handler import CommandQueryHandler
16
+ from work_tracker.command.common import ParseResult
17
+ from work_tracker.common import AppData, Date, Mode, AppState, get_data_path
18
+ from work_tracker.text.common import Color
19
+ from work_tracker.text.input_output_handler import InputOutputHandler
20
+ from .config import Config
21
+ from .error import VersionCheckError
22
+
23
+
24
+ class WorkTracker:
25
+ def __init__(self):
26
+ self.state: AppState = AppState()
27
+ self.data: AppData
28
+ self.io: InputOutputHandler
29
+ self.command_handler: CommandQueryHandler
30
+ self._initialized: bool = False
31
+
32
+ def initialize(self, check_is_new_version_available: bool = True):
33
+ self._initialized = True
34
+
35
+ if is_first_time_launch := self._is_first_time_launch():
36
+ self._create_basic_files()
37
+ if was_version_file_missing := self._is_version_file_missing():
38
+ self._update_version_file()
39
+ if was_config_file_missing := self._is_config_file_missing():
40
+ self._create_default_config_file()
41
+ if was_macros_file_missing := self._is_macros_file_missing():
42
+ self._create_default_macros_file()
43
+ if was_updated_since_last_launch := self._was_updated_since_last_launch():
44
+ self._update_version_file()
45
+
46
+ # config access check must be before io, because io uses config values
47
+ if not Config.ready():
48
+ raise RuntimeError("Something went wrong trying to access config.")
49
+
50
+ self._initialize_io()
51
+ if is_first_time_launch:
52
+ self._first_time_prompt()
53
+ else:
54
+ if was_version_file_missing:
55
+ self.io.output("WARNING: version file could not be found. This may lead to unpredicted behavior.", color=Color.Yellow)
56
+ if was_config_file_missing:
57
+ self.io.output("WARNING: config file could not be found. Default config will be used instead.", color=Color.Yellow)
58
+ if was_macros_file_missing:
59
+ self.io.output("WARNING: macros file could not be found. Default macros will be used instead.", color=Color.Yellow)
60
+
61
+ if not is_first_time_launch:
62
+ self._load_data()
63
+ if self.data is None:
64
+ self.io.output("WARNING: it appears that no saved data file was found to load from. As a result, the app will default to the first-time launch screen.", color=Color.Yellow)
65
+ self._first_time_prompt()
66
+ elif not self.data.is_latest_data_version():
67
+ self.data.update_data_to_latest_version()
68
+
69
+ self._initialize_command_handler()
70
+ self._clear_old_cache()
71
+
72
+ if not was_updated_since_last_launch and check_is_new_version_available and (latest_version := self._is_new_version_available()) is not None:
73
+ self._display_new_version_available_message(latest_version)
74
+ self.io.output(f"Using {Color.Brightblue.value}WorkTracker{Color.Clear.value} version {Color.Brightblue.value}{__version__}{Color.Clear.value}.")
75
+
76
+ def _is_first_time_launch(self) -> bool:
77
+ expected_files: list[Path] = [get_data_path().joinpath("version"), get_data_path().joinpath("config.yaml"), get_data_path().joinpath("macros.txt")]
78
+ return all(not path.exists() for path in expected_files)
79
+
80
+ def _create_basic_files(self):
81
+ self._create_default_config_file()
82
+ self._create_default_macros_file()
83
+
84
+ def _create_default_config_file(self):
85
+ shutil.copy(Path(__file__).parent.joinpath("data/default.config.yaml"), get_data_path().joinpath("config.yaml"))
86
+
87
+ def _create_default_macros_file(self):
88
+ shutil.copy(Path(__file__).parent.joinpath("data/default.macros.txt"), get_data_path().joinpath("macros.txt"))
89
+
90
+ def _initialize_io(self):
91
+ self.io = InputOutputHandler()
92
+
93
+ def _load_data(self):
94
+ self.data = CheckpointManager.load_latest()
95
+
96
+ def _first_time_prompt(self):
97
+ self.io.write("Welcome to WorkTracker!", color=Color.Brightblue)
98
+ self.io.write("WorkTracker helps you track your work hours efficiently and manage your schedule with ease.", color=Color.Cyan)
99
+ self.io.output(f"Before you start using it, please provide your country code. This will allow {Color.Brightblue.value}WorkTracker{Color.Reset.value} to automatically import all relevant holidays and mark your non-working days accordingly.")
100
+
101
+ country_code: str
102
+ valid_codes: list[str] = sorted(list(registry.get_calendars().keys()))
103
+ while True:
104
+ country_code = self.io.input(f"{Config.data.input.prefix} ", custom_autocomplete=valid_codes).upper()
105
+ if country_code in valid_codes: # TODO use of private method
106
+ break
107
+ else:
108
+ self.io.output("Invalid country code. Please input valid contry code.", color=Color.Brightred)
109
+
110
+ self.io.output("Would you like to run the initial setup? This process can take some time but is recommended, as it enables the app to automatically fill your calendar with the suggested work schedule.")
111
+ user_input: str = self.io.input(f"{Config.data.input.prefix} ", custom_autocomplete=["yes", "no"])
112
+ if "yes".startswith(user_input):
113
+ self.io.output("Setup command is not yet implemented. Skipping setup phase...", color=Color.Brightred)
114
+ else:
115
+ self.io.output(f"Initial setup skipped. You can always run setup later by using {Color.Brightblue.value}setup{Color.Reset.value} command.")
116
+
117
+ self.data = AppData(country_code=country_code)
118
+ CheckpointManager.save("initial", self.data)
119
+
120
+ self.io.write(f"Everything is set up and ready.", color=Color.Cyan, end=" ")
121
+ self.io.write(f"To view a list of available commands type {Color.Brightblue.value}help{Color.Reset.value}.", end=" ")
122
+ self.io.write(f"For detailed information about a specific command use {Color.Brightblue.value}help <command_name>{Color.Reset.value}.", end=" ")
123
+ self.io.write(f"It is recommended that you use {Color.Brightblue.value}tutorial{Color.Reset.value} command to quickly get familiar with the available features.", end=" ")
124
+ self.io.output(f"Remember to leave {Color.Brightblue.value}WorkTracker{Color.Reset.value} by using {Color.Brightblue.value}exit{Color.Reset.value} command to avoid {Color.Underline.value}{Color.Brightred.value}loss of data{Color.Reset.value}!")
125
+
126
+ def _is_version_file_missing(self) -> bool:
127
+ return not get_data_path().joinpath("version").exists() # TODO make file name a constant
128
+
129
+ def _is_config_file_missing(self) -> bool:
130
+ return not get_data_path().joinpath("config.yaml").exists() # TODO make file name a constant
131
+
132
+ def _is_macros_file_missing(self) -> bool:
133
+ return not get_data_path().joinpath("macros.txt").exists() # TODO make file name a constant
134
+
135
+ def _was_updated_since_last_launch(self) -> bool:
136
+ path: Path = get_data_path().joinpath("version")
137
+ if not path.exists():
138
+ raise RuntimeError("Something went wrong when trying to access version file.")
139
+
140
+ with open(path, "r") as file:
141
+ version: str = file.read()
142
+
143
+ return version < __version__
144
+
145
+ def _update_version_file(self):
146
+ with open(get_data_path().joinpath("version"), "w") as file:
147
+ file.write(__version__)
148
+
149
+ def _initialize_command_handler(self):
150
+ self.command_handler = CommandQueryHandler(self.data, self.io, self.state)
151
+
152
+ def _is_new_version_available(self) -> str | None:
153
+ try:
154
+ installed_version: str = __version__
155
+ result = subprocess.run(
156
+ ["pip", "index", "versions", "work-tracker"],
157
+ capture_output=True, text=True
158
+ )
159
+ if result.returncode != 0:
160
+ self.io.output("WARNING: unable to retrieve data from pip while checking for available updates.", color=Color.Yellow)
161
+ return None
162
+
163
+ latest_version: str = result.stdout.strip().split("\n")[-1].split()[-1]
164
+ return latest_version if installed_version != latest_version else None
165
+ except subprocess.SubprocessError as e:
166
+ raise VersionCheckError("Subprocess error during version check.")
167
+ except Exception as e:
168
+ raise VersionCheckError(f"Unexpected error: {e}")
169
+
170
+ def _display_new_version_available_message(self, version: str):
171
+ self.io.write(f"New version {Color.Brightcyan.value}{version}{Color.Reset.value} is available!", end=" ")
172
+ self.io.output(f"Update with {Color.Brightblue.value}pip install --upgrade work-tracker{Color.Reset.value}.")
173
+
174
+ def _clear_old_cache(self):
175
+ CheckpointManager.clear_cache()
176
+
177
+ def _at_crash_exit(self, crash_message: str | None = None, exception: Exception | None = None):
178
+ CheckpointManager.save(f"crash-{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}", self.data)
179
+ with open(get_data_path().joinpath(f"crash-log-{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.txt"), "w") as file:
180
+ if crash_message is not None:
181
+ file.write(crash_message)
182
+ if exception is not None:
183
+ traceback.print_exc(file=file)
184
+ sys.exit()
185
+
186
+ def _at_exit(self):
187
+ CheckpointManager.save(f"{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}", self.data)
188
+ sys.exit()
189
+
190
+ def handle_exit_signal(self): # TODO check if this works | no it doesnt :(
191
+ self._at_crash_exit(crash_message=f"received exit signal")
192
+
193
+ def _run(self):
194
+ error_log_last_processed_input: str = "None"
195
+ try:
196
+ while True:
197
+ today: Date = Date.today()
198
+ mode: Mode = self.state.mode
199
+ date: Date = self.state.active_date
200
+ prefix: str = (
201
+ f"{Config.data.input.prefix} " if mode == Mode.Today else
202
+ f"[{date.day:02}.{date.month:02}.{date.year}]{Config.data.input.prefix} " if mode == Mode.Day and today.year != date.year else
203
+ f"[{date.day:02}.{date.month:02}]{Config.data.input.prefix} " if mode == Mode.Day and today.year == date.year else
204
+ f"[{calendar.month_abbr[date.month].lower()}]{Config.data.input.prefix} " if mode == Mode.Month else
205
+ "?> "
206
+ )
207
+
208
+ user_input: str = self.get_user_input(prefix)
209
+ error_log_last_processed_input = user_input
210
+
211
+ result: ParseResult = CommandParser.parse(user_input)
212
+ if result.error:
213
+ self.io.output(f"ERROR: {result.error.message or 'missing error description'}", color=Color.Brightred)
214
+ continue
215
+
216
+ for query in result.queries:
217
+ self.command_handler.run(query)
218
+ except KeyboardInterrupt:
219
+ self.io.output("UNSAFE EXIT: saving data...", color=Color.Brightred, end="")
220
+ self._at_exit()
221
+ except Exception as exception:
222
+ self.io.output("FATAL ERROR: saving data and creating error log...", color=Color.Brightred, end="")
223
+ self._at_crash_exit(crash_message=f"{error_log_last_processed_input}\n\n", exception=exception)
224
+
225
+ def start(self):
226
+ if not self._initialized:
227
+ self.initialize()
228
+
229
+ # TODO add atexit.register() to automatically save data somewhere here ?
230
+ signal.signal(signal.SIGINT, self.handle_exit_signal)
231
+ signal.signal(signal.SIGTERM, self.handle_exit_signal)
232
+
233
+ self._run()
234
+
235
+ def get_user_input(self, prefix: str) -> str:
236
+ return self.io.input(prefix)
@@ -0,0 +1,56 @@
1
+ import datetime
2
+ import lzma
3
+ import os
4
+ import pickle
5
+
6
+ from path import Path
7
+
8
+ from .common import AppData, get_data_path, get_cache_path
9
+
10
+
11
+ class CheckpointManager:
12
+ @classmethod
13
+ def load(cls, identifier: str, manual_checkpoint: bool = False) -> AppData | None: # TODO os exceptions
14
+ if manual_checkpoint:
15
+ for checkpoint_path in cls.all_manual_checkpoints():
16
+ name, date = checkpoint_path.name.removesuffix('.save.checkpoint').split("__") # TODO hardcoded '.save.checkpoint'
17
+ if name == identifier:
18
+ identifier = f"{name}__{date}"
19
+
20
+ path: Path = (get_data_path() if not manual_checkpoint else get_cache_path()).joinpath(f"{identifier}.save.checkpoint")
21
+ if not path.exists():
22
+ return None
23
+ with lzma.open(path, "rb") as file:
24
+ data: AppData = pickle.load(file)
25
+ return data
26
+
27
+ @staticmethod
28
+ def save(identifier: str, data: AppData, manual_checkpoint: bool = False): # TODO os exceptions
29
+ full_identifier: str = identifier if not manual_checkpoint else f"{identifier}__{datetime.datetime.now().strftime('%H-%M-%S')}"
30
+ path: Path = (get_data_path() if not manual_checkpoint else get_cache_path()).joinpath(f"{full_identifier}.save.checkpoint")
31
+ with lzma.open(path, "wb") as file:
32
+ pickle.dump(data, file)
33
+
34
+ @classmethod
35
+ def load_latest(cls) -> AppData | None: # TODO os exceptions
36
+ files: list[Path] = cls.all_automatic_checkpoints()
37
+ if not files:
38
+ return None
39
+
40
+ newest_file: str = max(files, key=os.path.getctime) # TODO this sorting might be unclear for user
41
+ return cls.load(os.path.basename(newest_file.rstrip(".save.checkpoint")))
42
+
43
+ @staticmethod
44
+ def all_automatic_checkpoints() -> list[Path]: # TODO os exceptions
45
+ return [get_data_path().joinpath(file) for file in os.listdir(get_data_path()) if file.endswith(".save.checkpoint") and os.path.isfile(get_data_path().joinpath(file))]
46
+
47
+ @staticmethod
48
+ def all_manual_checkpoints() -> list[Path]: # TODO os exceptions
49
+ return [get_cache_path().joinpath(file) for file in os.listdir(get_cache_path()) if file.endswith(".save.checkpoint") and os.path.isfile(get_cache_path().joinpath(file))]
50
+
51
+ @staticmethod
52
+ def clear_cache():
53
+ for name in os.listdir(get_cache_path()):
54
+ if not os.path.isfile(get_cache_path().joinpath(name)):
55
+ continue
56
+ os.remove(get_cache_path().joinpath(name))
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,36 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+
4
+ from work_tracker.command.command_parser import CommandParser
5
+ from work_tracker.command.common import CommandArgument, AdditionalInputArgument, CommandQuery
6
+ from work_tracker.error import CommandError
7
+ from work_tracker.common import AppData, Date, ReadonlyAppState
8
+ from work_tracker.config import Config
9
+ from work_tracker.text.input_output_handler import InputOutputHandler
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CommandHandlerResult:
14
+ undoable: bool
15
+ error: CommandError | None = None
16
+ change_active_date: Date | None = None
17
+ change_state_by: int | None = None
18
+ execute_after: list[CommandQuery] | None = None
19
+
20
+
21
+ class CommandHandler(ABC):
22
+ def __init__(self, work_data: AppData, io: InputOutputHandler):
23
+ self.data: AppData = work_data
24
+ self.io: InputOutputHandler = io
25
+
26
+ @property
27
+ def command_name(self) -> str:
28
+ return self.__class__.__name__.split("Handler")[0].lower()
29
+
30
+ @abstractmethod
31
+ def handle(self, dates: list[Date], date_count: int, arguments: list[CommandArgument], argument_count: int, state: ReadonlyAppState) -> CommandHandlerResult:
32
+ raise NotImplementedError()
33
+
34
+ def get_additional_input(self, custom_autocomplete: list[str] = None) -> list[AdditionalInputArgument]:
35
+ text: str = self.io.input(f"{Config.data.input.sub_prefix} ", show_autocomplete=custom_autocomplete is not None, custom_autocomplete=custom_autocomplete)
36
+ return CommandParser.parse_arguments(text)
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class CommandHistoryEntry:
6
+ state_key: str
7
+ command: str
8
+
9
+
10
+ class CommandHistory:
11
+ def __init__(self, max_size: int):
12
+ self._max_size: int = max_size
13
+ self._current_state_index: int = -1
14
+ self._states: list[CommandHistoryEntry] = []
15
+
16
+ def add(self, state_key: str, command_used: str):
17
+ if self._current_state_index < self._max_size:
18
+ self._states = self._states[:self._current_state_index + 1]
19
+
20
+ if self._current_state_index == self._max_size:
21
+ self._states.pop(0)
22
+ else:
23
+ self._current_state_index += 1
24
+
25
+ self._states.append(CommandHistoryEntry(
26
+ state_key=state_key,
27
+ command=command_used
28
+ ))
29
+
30
+ def undo(self) -> str | None:
31
+ if self._current_state_index > 0:
32
+ self._current_state_index -= 1
33
+ return self._states[self._current_state_index].state_key
34
+ return None
35
+
36
+ def redo(self) -> str | None:
37
+ if self._current_state_index < len(self._states) - 1:
38
+ self._current_state_index += 1
39
+ return self._states[self._current_state_index].state_key
40
+ return None
41
+
42
+ @property
43
+ def states(self) -> tuple[CommandHistoryEntry]:
44
+ return tuple(self._states)
45
+
46
+ @property
47
+ def current_state_index(self) -> int:
48
+ return self._current_state_index
@@ -0,0 +1,88 @@
1
+ import re
2
+
3
+ from work_tracker.command.common import Command, CommandTemplate
4
+
5
+
6
+ class CommandInitializer:
7
+ _shortest_command_string: dict[str, str] = {} # key: value -> shortest_command_string: command_name
8
+
9
+ @classmethod
10
+ def reset(cls):
11
+ cls._shortest_command_string.clear()
12
+
13
+ @classmethod
14
+ def initialize_commands(cls, commands: list[CommandTemplate]) -> list[Command]:
15
+ cls.reset()
16
+
17
+ commands_dict: dict[str, CommandTemplate] = {command.name: command for command in commands}
18
+ if len(commands_dict.keys()) != len(commands):
19
+ raise RuntimeError(f"Could not initialize commands, there are duplicated command names.")
20
+
21
+ cls._find_not_conflicting_shortest_command_string_for(commands)
22
+
23
+ return [
24
+ Command(
25
+ name=command_name,
26
+ help=commands_dict[command_name].help,
27
+ abbreviations=commands_dict[command_name].abbreviations,
28
+ shortest_valid_string=shortest_string,
29
+ snake_case_name=re.sub(r'[-. ]', '_', command_name),
30
+ camel_case_name="".join(word.capitalize() for word in re.sub(r'[-. ]', '_', command_name).split('_')),
31
+ valid_argument_types=sorted(commands_dict[command_name].valid_argument_types, key=len),
32
+ supported_modes=commands_dict[command_name].supported_modes,
33
+ ) for shortest_string, command_name in sorted(cls._shortest_command_string.items())
34
+ ]
35
+
36
+ @classmethod
37
+ def _find_not_conflicting_shortest_command_string_for(cls, commands: list[CommandTemplate]):
38
+ target_string_length: int = 1
39
+ abbreviations: set[str] = cls._get_abbreviations(commands)
40
+
41
+ commands_left: list[str] = [command.name for command in commands]
42
+ while len(commands_left) != 0:
43
+ commands_left = cls._find_not_conflicting_command_string_of_length_for(commands_left, abbreviations, target_string_length)
44
+ target_string_length += 1
45
+
46
+ @classmethod
47
+ def _get_abbreviations(cls, commands: list[CommandTemplate]) -> set[str]:
48
+ abbreviations: set[str] = set()
49
+ for command in commands:
50
+ for abbreviation in command.abbreviations:
51
+ if abbreviation in abbreviations:
52
+ raise RuntimeError(f"Could not initialize commands, found duplicated command abbreviation: '{abbreviation}'.")
53
+ abbreviations.add(abbreviation)
54
+ return abbreviations
55
+
56
+ @classmethod
57
+ def _find_not_conflicting_command_string_of_length_for(cls, commands: list[str], abbreviations: set[str], target_string_length: int) -> list[str]: # TODO check if this can be simplified
58
+ remaining_commands: set[str] = set()
59
+ substrings_to_pop: set[str] = set()
60
+
61
+ for command in commands:
62
+ command_substring: str = command[:target_string_length]
63
+ substring_is_full_command_name: bool = len(command_substring) == len(command)
64
+ substring_is_an_abbreviation: bool = command_substring in abbreviations
65
+ substring_already_exists: bool = command_substring in cls._shortest_command_string.keys()
66
+ found_substring_is_full_command_name: bool = command_substring == cls._shortest_command_string.get(command_substring)
67
+
68
+ if substring_is_an_abbreviation and substring_is_full_command_name:
69
+ raise RuntimeError(f"Command '{command}' could not be shorten and is equal to one of the special abbreviations of another command.")
70
+ elif substring_is_an_abbreviation:
71
+ remaining_commands.add(command)
72
+ elif substring_already_exists and substring_is_full_command_name:
73
+ remaining_commands.add(cls._shortest_command_string[command_substring])
74
+ substrings_to_pop.add(command_substring)
75
+ cls._shortest_command_string[command_substring] = command
76
+ elif substring_already_exists and not found_substring_is_full_command_name:
77
+ remaining_commands.add(cls._shortest_command_string[command_substring])
78
+ remaining_commands.add(command)
79
+ substrings_to_pop.add(command_substring)
80
+ elif not substring_already_exists or substring_is_full_command_name:
81
+ cls._shortest_command_string[command_substring] = command
82
+ else:
83
+ remaining_commands.add(command)
84
+
85
+ for substring in substrings_to_pop:
86
+ cls._shortest_command_string.pop(substring)
87
+
88
+ return list(remaining_commands)
@@ -0,0 +1,85 @@
1
+ from work_tracker.command.command_initializer import CommandInitializer
2
+ from work_tracker.command.common import Command, TimeArgument, CommandHelp, _global_command_templates
3
+ from work_tracker.common import Mode
4
+
5
+
6
+ class CommandManager:
7
+ _commands: list[Command] = []
8
+
9
+ # special commands
10
+ _time_command: Command = Command(
11
+ help=CommandHelp(
12
+ full_use_case_template="",
13
+ short_help_description="",
14
+ full_help_description="",
15
+ use_case_description=[],
16
+ ),
17
+ supported_modes=set(Mode),
18
+ abbreviations=[],
19
+ name="__time",
20
+ shortest_valid_string="",
21
+ snake_case_name="__time",
22
+ camel_case_name="__Time",
23
+ valid_argument_types=[[TimeArgument]]
24
+ )
25
+ _date_command: Command = Command(
26
+ help=CommandHelp(
27
+ full_use_case_template="",
28
+ short_help_description="",
29
+ full_help_description="",
30
+ use_case_description=[],
31
+ ),
32
+ supported_modes=set(Mode),
33
+ abbreviations=[],
34
+ name="__date",
35
+ shortest_valid_string="",
36
+ snake_case_name="__date",
37
+ camel_case_name="__Date",
38
+ valid_argument_types=[[]]
39
+ )
40
+ _macro_execute_command: Command = Command(
41
+ help=CommandHelp(
42
+ full_use_case_template="",
43
+ short_help_description="",
44
+ full_help_description="",
45
+ use_case_description=[],
46
+ ),
47
+ supported_modes=set(Mode),
48
+ abbreviations=[],
49
+ name="__macro",
50
+ shortest_valid_string="",
51
+ snake_case_name="__macro",
52
+ camel_case_name="__Macro",
53
+ valid_argument_types=[[object, ...]]
54
+ )
55
+
56
+ @classmethod
57
+ @property
58
+ def time_command(cls) -> Command:
59
+ return cls._time_command
60
+
61
+ @classmethod
62
+ @property
63
+ def date_command(cls) -> Command:
64
+ return cls._date_command
65
+
66
+ @classmethod
67
+ @property
68
+ def macro_execute_command(cls) -> Command:
69
+ return cls._macro_execute_command
70
+
71
+ @classmethod
72
+ @property
73
+ def commands(cls) -> list[Command]:
74
+ if len(cls._commands) == 0:
75
+ cls._commands = CommandInitializer.initialize_commands(_global_command_templates)
76
+ return cls._commands
77
+
78
+ @classmethod
79
+ def get_command_by_name(cls, name: str) -> Command | None:
80
+ filtered_commands: list[Command] = [command for command in cls.commands if command.name == name]
81
+ command_found: bool = len(filtered_commands) == 1
82
+ if command_found:
83
+ return filtered_commands[0]
84
+ else:
85
+ return None