work-tracker 0.1.0__tar.gz

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 (73) hide show
  1. work_tracker-0.1.0/LICENSE +21 -0
  2. work_tracker-0.1.0/PKG-INFO +71 -0
  3. work_tracker-0.1.0/README.md +35 -0
  4. work_tracker-0.1.0/pyproject.toml +63 -0
  5. work_tracker-0.1.0/setup.cfg +4 -0
  6. work_tracker-0.1.0/work_tracker/__init__.py +8 -0
  7. work_tracker-0.1.0/work_tracker/_work_tracker.py +236 -0
  8. work_tracker-0.1.0/work_tracker/checkpoint_manager.py +56 -0
  9. work_tracker-0.1.0/work_tracker/command/__init__.py +1 -0
  10. work_tracker-0.1.0/work_tracker/command/command_handler.py +36 -0
  11. work_tracker-0.1.0/work_tracker/command/command_history.py +48 -0
  12. work_tracker-0.1.0/work_tracker/command/command_initializer.py +88 -0
  13. work_tracker-0.1.0/work_tracker/command/command_manager.py +85 -0
  14. work_tracker-0.1.0/work_tracker/command/command_parser.py +395 -0
  15. work_tracker-0.1.0/work_tracker/command/command_query_handler.py +93 -0
  16. work_tracker-0.1.0/work_tracker/command/command_text_parser.py +42 -0
  17. work_tracker-0.1.0/work_tracker/command/commands/__date.py +22 -0
  18. work_tracker-0.1.0/work_tracker/command/commands/__macro.py +54 -0
  19. work_tracker-0.1.0/work_tracker/command/commands/__time.py +48 -0
  20. work_tracker-0.1.0/work_tracker/command/commands/block.py +9 -0
  21. work_tracker-0.1.0/work_tracker/command/commands/calculate.py +9 -0
  22. work_tracker-0.1.0/work_tracker/command/commands/calendar.py +101 -0
  23. work_tracker-0.1.0/work_tracker/command/commands/checkpoint.py +46 -0
  24. work_tracker-0.1.0/work_tracker/command/commands/clear.py +42 -0
  25. work_tracker-0.1.0/work_tracker/command/commands/config.py +92 -0
  26. work_tracker-0.1.0/work_tracker/command/commands/dayoff.py +42 -0
  27. work_tracker-0.1.0/work_tracker/command/commands/days.py +92 -0
  28. work_tracker-0.1.0/work_tracker/command/commands/deletemacro.py +20 -0
  29. work_tracker-0.1.0/work_tracker/command/commands/done.py +38 -0
  30. work_tracker-0.1.0/work_tracker/command/commands/end.py +42 -0
  31. work_tracker-0.1.0/work_tracker/command/commands/exit.py +20 -0
  32. work_tracker-0.1.0/work_tracker/command/commands/fte.py +48 -0
  33. work_tracker-0.1.0/work_tracker/command/commands/help.py +116 -0
  34. work_tracker-0.1.0/work_tracker/command/commands/history.py +60 -0
  35. work_tracker-0.1.0/work_tracker/command/commands/holiday.py +27 -0
  36. work_tracker-0.1.0/work_tracker/command/commands/info.py +9 -0
  37. work_tracker-0.1.0/work_tracker/command/commands/key.py +39 -0
  38. work_tracker-0.1.0/work_tracker/command/commands/macro.py +86 -0
  39. work_tracker-0.1.0/work_tracker/command/commands/minutes.py +90 -0
  40. work_tracker-0.1.0/work_tracker/command/commands/office.py +28 -0
  41. work_tracker-0.1.0/work_tracker/command/commands/recalculate.py +9 -0
  42. work_tracker-0.1.0/work_tracker/command/commands/redo.py +22 -0
  43. work_tracker-0.1.0/work_tracker/command/commands/remote.py +28 -0
  44. work_tracker-0.1.0/work_tracker/command/commands/rollback.py +21 -0
  45. work_tracker-0.1.0/work_tracker/command/commands/rwr.py +50 -0
  46. work_tracker-0.1.0/work_tracker/command/commands/setup.py +9 -0
  47. work_tracker-0.1.0/work_tracker/command/commands/start.py +42 -0
  48. work_tracker-0.1.0/work_tracker/command/commands/status.py +92 -0
  49. work_tracker-0.1.0/work_tracker/command/commands/target.py +101 -0
  50. work_tracker-0.1.0/work_tracker/command/commands/tutorial.py +139 -0
  51. work_tracker-0.1.0/work_tracker/command/commands/undo.py +22 -0
  52. work_tracker-0.1.0/work_tracker/command/commands/version.py +21 -0
  53. work_tracker-0.1.0/work_tracker/command/commands/workday.py +27 -0
  54. work_tracker-0.1.0/work_tracker/command/commands/zero.py +37 -0
  55. work_tracker-0.1.0/work_tracker/command/common.py +734 -0
  56. work_tracker-0.1.0/work_tracker/command/macro_manager.py +118 -0
  57. work_tracker-0.1.0/work_tracker/common.py +442 -0
  58. work_tracker-0.1.0/work_tracker/config.py +101 -0
  59. work_tracker-0.1.0/work_tracker/data/default.config.yaml +31 -0
  60. work_tracker-0.1.0/work_tracker/data/default.macros.txt +6 -0
  61. work_tracker-0.1.0/work_tracker/error.py +180 -0
  62. work_tracker-0.1.0/work_tracker/main.py +31 -0
  63. work_tracker-0.1.0/work_tracker/text/__init__.py +0 -0
  64. work_tracker-0.1.0/work_tracker/text/common.py +228 -0
  65. work_tracker-0.1.0/work_tracker/text/input_command_completer.py +47 -0
  66. work_tracker-0.1.0/work_tracker/text/input_output_handler.py +53 -0
  67. work_tracker-0.1.0/work_tracker/version.py +2 -0
  68. work_tracker-0.1.0/work_tracker.egg-info/PKG-INFO +71 -0
  69. work_tracker-0.1.0/work_tracker.egg-info/SOURCES.txt +71 -0
  70. work_tracker-0.1.0/work_tracker.egg-info/dependency_links.txt +1 -0
  71. work_tracker-0.1.0/work_tracker.egg-info/entry_points.txt +2 -0
  72. work_tracker-0.1.0/work_tracker.egg-info/requires.txt +16 -0
  73. work_tracker-0.1.0/work_tracker.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Karol Kiszka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.2
2
+ Name: work-tracker
3
+ Version: 0.1.0
4
+ Summary: A Python library that helps tracking your work time.
5
+ Author-email: Karol Kiszka <karolkisz22@gmail.com>
6
+ License: MIT
7
+ Project-URL: source, https://github.com/kiszkacy/work-tracker
8
+ Keywords: work,time,tracker
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Environment :: Console
18
+ Requires-Python: <4.0,>=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: workalendar~=17.0.0
22
+ Requires-Dist: pydantic~=2.10.5
23
+ Requires-Dist: appdirs~=1.4.4
24
+ Requires-Dist: path~=17.1.0
25
+ Requires-Dist: PyYAML~=6.0.2
26
+ Requires-Dist: pyperclip~=1.9.0
27
+ Requires-Dist: colorama~=0.4.6
28
+ Requires-Dist: multimethod~=2.0
29
+ Requires-Dist: prompt_toolkit~=3.0.48
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest~=8.3.4; extra == "dev"
32
+ Requires-Dist: pytest-mock~=3.14.0; extra == "dev"
33
+ Requires-Dist: pytest-order~=1.3.0; extra == "dev"
34
+ Requires-Dist: pytest-random-order~=1.1.1; extra == "dev"
35
+ Requires-Dist: pytest-cov~=6.0.0; extra == "dev"
36
+
37
+
38
+ # WorkTracker
39
+
40
+
41
+ ![Python version](https://img.shields.io/badge/python-%3E%3D%203.10-blue.svg)
42
+ ![License](https://img.shields.io/badge/license-MIT-green.svg)
43
+ ![PyPI](https://img.shields.io/pypi/v/work-tracker.svg)
44
+ [![wakatime](https://wakatime.com/badge/user/92cf3ac1-102d-4d79-b9a8-c960cf206839/project/8c8f1653-1d14-4d7e-90bc-01591ea36159.svg)](https://wakatime.com/badge/user/92cf3ac1-102d-4d79-b9a8-c960cf206839/project/8c8f1653-1d14-4d7e-90bc-01591ea36159 "Total time spent coding")
45
+
46
+ **WorkTracker** is a Python library that helps you track your time spent at work and manage your work schedule, providing a simple way to monitor your working hours.
47
+
48
+ > **Note:** This library is still in very early development. Many features may not be fully stable, and things could and probably will break with future updates until the final release.
49
+
50
+
51
+ ## Installation
52
+
53
+ Before installing **WorkTracker**, ensure you have Python 3.10 or a newer version installed. You can download and install it from the [official Python website](https://www.python.org/downloads/).
54
+
55
+ To install the latest **WorkTracker** version, run the following command in your terminal:
56
+
57
+ ```bash
58
+ pip install work-tracker
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ Once installed, you can launch the **WorkTracker** by running the following command in your terminal:
64
+
65
+ ```bash
66
+ work-tracker
67
+ ```
68
+
69
+ ## License
70
+
71
+ This project is licensed under the MIT License.
@@ -0,0 +1,35 @@
1
+
2
+ # WorkTracker
3
+
4
+
5
+ ![Python version](https://img.shields.io/badge/python-%3E%3D%203.10-blue.svg)
6
+ ![License](https://img.shields.io/badge/license-MIT-green.svg)
7
+ ![PyPI](https://img.shields.io/pypi/v/work-tracker.svg)
8
+ [![wakatime](https://wakatime.com/badge/user/92cf3ac1-102d-4d79-b9a8-c960cf206839/project/8c8f1653-1d14-4d7e-90bc-01591ea36159.svg)](https://wakatime.com/badge/user/92cf3ac1-102d-4d79-b9a8-c960cf206839/project/8c8f1653-1d14-4d7e-90bc-01591ea36159 "Total time spent coding")
9
+
10
+ **WorkTracker** is a Python library that helps you track your time spent at work and manage your work schedule, providing a simple way to monitor your working hours.
11
+
12
+ > **Note:** This library is still in very early development. Many features may not be fully stable, and things could and probably will break with future updates until the final release.
13
+
14
+
15
+ ## Installation
16
+
17
+ Before installing **WorkTracker**, ensure you have Python 3.10 or a newer version installed. You can download and install it from the [official Python website](https://www.python.org/downloads/).
18
+
19
+ To install the latest **WorkTracker** version, run the following command in your terminal:
20
+
21
+ ```bash
22
+ pip install work-tracker
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Once installed, you can launch the **WorkTracker** by running the following command in your terminal:
28
+
29
+ ```bash
30
+ work-tracker
31
+ ```
32
+
33
+ ## License
34
+
35
+ This project is licensed under the MIT License.
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "work-tracker"
3
+ description = "A Python library that helps tracking your work time."
4
+ authors = [
5
+ {name = "Karol Kiszka", email = "karolkisz22@gmail.com"}
6
+ ]
7
+ readme = "README.md"
8
+ license = { text = "MIT" }
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.10",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Environment :: Console",
19
+ ]
20
+ dependencies = [
21
+ "workalendar~=17.0.0",
22
+ "pydantic~=2.10.5",
23
+ "appdirs~=1.4.4",
24
+ "path~=17.1.0",
25
+ "PyYAML~=6.0.2",
26
+ "pyperclip~=1.9.0",
27
+ "colorama~=0.4.6",
28
+ "multimethod~=2.0",
29
+ "prompt_toolkit~=3.0.48",
30
+ ]
31
+ requires-python = ">=3.10,<4.0"
32
+ keywords = ["work", "time", "tracker"]
33
+ dynamic = ["version"]
34
+
35
+ [project.scripts]
36
+ work-tracker = "work_tracker.main:main"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest~=8.3.4",
41
+ "pytest-mock~=3.14.0",
42
+ "pytest-order~=1.3.0",
43
+ "pytest-random-order~=1.1.1",
44
+ "pytest-cov~=6.0.0",
45
+ ]
46
+
47
+ [project.urls]
48
+ source = "https://github.com/kiszkacy/work-tracker"
49
+
50
+ [build-system]
51
+ requires = ["setuptools", "wheel"]
52
+ build-backend = "setuptools.build_meta"
53
+
54
+ [tool.setuptools]
55
+ packages = ["work_tracker"]
56
+
57
+ [tool.setuptools.dynamic]
58
+ version = {attr = "work_tracker.version.__version__"}
59
+
60
+ [tool.coverage.report]
61
+ include_namespace_packages = true
62
+ omit = ["**/__init__.py"]
63
+ skip_empty = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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