cashd-core 0.3.2__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.
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: cashd_core
3
+ Version: 0.3.2
4
+ Summary: Core functionality of Cashd and it's variants.
5
+ Author-email: "Vitor F. Lins" <vflins@live.com>
6
+ Keywords: backend,
7
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: SQLAlchemy==2.*
20
+ Requires-Dist: phonenumbers>=8.13.31
21
+ Requires-Dist: pyshortcuts==1.9.0
22
+ Requires-Dist: PyGObject; sys_platform == "linux"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
25
+ Requires-Dist: black>=24.4.2; extra == "dev"
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "cashd_core"
3
+ authors = [
4
+ {name = "Vitor F. Lins", email = "vflins@live.com"},
5
+ ]
6
+ description = "Core functionality of Cashd and it's variants."
7
+ requires-python = ">=3.10"
8
+ keywords = ["backend", ""]
9
+ license = {file = "LICENSE"}
10
+ readme = "README.md"
11
+ classifiers = [
12
+ "Topic :: Office/Business :: Financial :: Accounting",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Development Status :: 4 - Beta",
21
+ "License :: OSI Approved :: MIT License"
22
+ ]
23
+ version = "0.3.2"
24
+ dependencies = [
25
+ "SQLAlchemy==2.*",
26
+ "phonenumbers>=8.13.31",
27
+ "pyshortcuts==1.9.0",
28
+ "PyGObject; sys_platform == 'linux'"
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev=[
33
+ "pytest-cov>=5.0.0",
34
+ "black>=24.4.2",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["setuptools", "setuptools-scm", "wheel"]
39
+ build-backend = "setuptools.build_meta"
40
+
41
+ [project.scripts]
42
+ cashd-server = "cashd.app:start_cashd"
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = "tests"
46
+ pythonpath = "src"
47
+ addopts = "--cov=src --cov-report html"
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
51
+ namespaces = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ from . import data, prefs, const
@@ -0,0 +1,280 @@
1
+ from os import path, rename, makedirs
2
+ from sys import platform
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+ import configparser
6
+ import sqlite3
7
+ import logging
8
+ import shutil
9
+
10
+ from cashd_core.prefs import BackupPrefsHandler
11
+
12
+ ####################
13
+ # GLOBAL VARS
14
+ ####################
15
+
16
+ if platform == "win32":
17
+ CASHD_FILES_PATH = Path.home().joinpath("AppData", "Local", "Cashd")
18
+ CONFIG_PATH = Path(CASHD_FILES_PATH, "configs")
19
+ LOG_PATH = Path(CASHD_FILES_PATH, "logs")
20
+ else:
21
+ CASHD_FILES_PATH = Path.home().joinpath(".local", "share", "Cashd")
22
+ CONFIG_PATH = Path.home().joinpath(".config", "Cashd")
23
+ LOG_PATH = Path.home().joinpath(".local", "state", "Cashd", "logs")
24
+
25
+ CONFIG_FILE = Path(CONFIG_PATH, "backup.ini")
26
+ LOG_FILE = path.join(LOG_PATH, "backup.log")
27
+ DB_FILE = path.join(CASHD_FILES_PATH, "data", "database.db")
28
+ BACKUP_PATH = path.join(CASHD_FILES_PATH, "data", "backup")
29
+
30
+ for dirpath in [CASHD_FILES_PATH, CONFIG_PATH, LOG_PATH, BACKUP_PATH]:
31
+ makedirs(dirpath, exist_ok=True)
32
+
33
+ settings = BackupPrefsHandler()
34
+
35
+ logger = logging.getLogger("cashd.backup")
36
+ logger.setLevel(logging.DEBUG)
37
+ logger.propagate = False
38
+
39
+ log_fmt = logging.Formatter("%(asctime)s :: %(levelname)s %(message)s")
40
+ log_handler = logging.FileHandler(LOG_FILE)
41
+ log_handler.setLevel(logging.DEBUG)
42
+ log_handler.setFormatter(log_fmt)
43
+
44
+ logger.addHandler(log_handler)
45
+
46
+
47
+ ####################
48
+ # UTILS
49
+ ####################
50
+
51
+
52
+ class BackupPlacesSource:
53
+ def __init__(self):
54
+ """Mimics `cashd_core.data._DataSource` behavior providing interaction to the
55
+ list of backup places.
56
+ """
57
+
58
+ @property
59
+ def current_data(self) -> list[str]:
60
+ return settings.read_backup_places()
61
+
62
+
63
+ def copy_file(source_path: str, target_dir: str, _raise: bool = False):
64
+ """Copies a file to `target_dir`.
65
+
66
+ :param source_path: Full path to the file to be copied.
67
+ :param target_dir: Directory where the file will be copied into.
68
+ :param _raise: Boolean indicating if errors should also be raised, if false, errors
69
+ will only be silently logged to a log file.
70
+
71
+ :raises NotADirectoryError: If `target_dir` is not a valid directory and `_raise=True`.
72
+ :raises FileNotFoundError: If `source_path` is not a valid file and `_raise=True`.
73
+ """
74
+ logger.debug("function call: copy_file")
75
+ if not path.exists(target_dir):
76
+ err_msg = f"'{target_dir}' does not exist."
77
+ logger.error(err_msg)
78
+ if _raise:
79
+ raise NotADirectoryError(err_msg)
80
+ now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
81
+ try:
82
+ filename = f"backup_{now}.db"
83
+ shutil.copyfile(source_path, path.join(target_dir, filename))
84
+ logger.info(f"Copia de '{source_path}' criada em '{target_dir}'")
85
+ except FileNotFoundError as xpt:
86
+ logger.error(f"Erro realizando copia: {xpt}.", exc_info=True)
87
+ if _raise:
88
+ raise xpt
89
+
90
+
91
+ def rename_on_db_folder(current: str, new: str, _raise: bool = False):
92
+ """Renames a file in the same folder where `DB_FILE` is located. If the renaming
93
+ operation fails because the file is in use, it makes a copy with the new name instead
94
+ of renaming.
95
+
96
+ :param current: Current name of the file.
97
+ :param new: Name that the file will be renamed into.
98
+ :param _raise: Boolean indicating if errors should also be raised, if false, errors
99
+ will only be silently logged to a log file.
100
+
101
+ :raises: The last error received if both operations fail and `_raise=True`.
102
+ """
103
+ logger.debug("function call: rename_on_db_folder")
104
+ current, new = str(current), str(new)
105
+
106
+ db_folder = path.split(DB_FILE)[0]
107
+ path_to_current = path.join(db_folder, current)
108
+ path_to_new = path.join(db_folder, new)
109
+
110
+ try:
111
+ rename(path_to_current, path_to_new)
112
+ logger.info(f"{path_to_current} renomeado como {path_to_new}")
113
+ except WindowsError:
114
+ shutil.copy(path_to_current, path_to_new)
115
+ except Exception as xpt:
116
+ logger.error(f"Erro renomeando {path_to_current}: {
117
+ xpt}", exc_info=True)
118
+ if _raise:
119
+ raise xpt
120
+
121
+
122
+ def check_sqlite(file: str, _raise: bool = False) -> bool | None:
123
+ """Checks if `file` represents a sqlite database. All unexpected errors are only
124
+ logged to `backup.log`, but can also be raised if `_raise=True`.
125
+
126
+ :param file: Path to the file to be checked.
127
+ :param _raise: Boolean indicating if errors should also be raised, if false, errors
128
+ will only be silently logged to a log file.
129
+
130
+ :returns: A boolean value indicating if `file` is a SQLite database, or None if an
131
+ unexpected error is catched.
132
+ """
133
+ logger.debug("function call: check_sqlite")
134
+ if not path.exists(file):
135
+ msg = f"{file=} does not exist."
136
+ logger.error(msg)
137
+ if _raise:
138
+ raise FileExistsError(msg)
139
+ try:
140
+ con = sqlite3.connect(file)
141
+ except sqlite3.OperationalError as err:
142
+ return False
143
+ cursor = con.cursor()
144
+ try:
145
+ res = cursor.execute(f"PRAGMA schema_version;").fetchone()[0]
146
+ return type(res) is int
147
+ except sqlite3.DatabaseError as err:
148
+ return False
149
+ except Exception as err:
150
+ logger.critical(
151
+ f"Unexpected error validating {file}: {str(err)}", exc_info=True
152
+ )
153
+ if _raise:
154
+ raise err
155
+ return None
156
+ finally:
157
+ con.close()
158
+
159
+
160
+ def read_db_size(file_path: str = DB_FILE) -> int:
161
+ """Gives the size (kilobytes) of the provided file, 0 (zero) if it doesn't exist."""
162
+ logger.debug("function call: read_db_size")
163
+ try:
164
+ return path.getsize(file_path)
165
+ except Exception as xpt:
166
+ logger.error(f"Erro lendo tamanho do arquivo: {str(xpt)}")
167
+ return 0
168
+
169
+
170
+ ####################
171
+ # LEITURAS
172
+ ####################
173
+
174
+
175
+ def read_last_recorded_size(config_file: str = CONFIG_FILE):
176
+ """Reads last recorded DB file size from config file."""
177
+ logger.debug("function call: read_last_recorded_size")
178
+ config = configparser.ConfigParser()
179
+ config.read(config_file)
180
+ if "file_sizes" in config:
181
+ return config["file_sizes"].getint("dbsize", fallback=None)
182
+ return 0
183
+
184
+
185
+ ####################
186
+ # ESCRITAS
187
+ ####################
188
+
189
+
190
+ def write_current_size(
191
+ current_size: int | None = read_db_size(), settings: BackupPrefsHandler = settings
192
+ ) -> None:
193
+ """Writes current database size to `backup.ini`."""
194
+ logger.debug("function call: write_current_size")
195
+ settings.write_dbsize(current_size)
196
+
197
+
198
+ def write_add_backup_place(path: str, settings: BackupPrefsHandler = settings) -> None:
199
+ """Includes the input `path` in the 'backup_places' option in `backup.ini`."""
200
+ logger.debug("function call: write_add_backup_place")
201
+ settings.add_backup_place(path)
202
+
203
+
204
+ def write_rm_backup_place(idx: int, settings: BackupPrefsHandler = settings) -> None:
205
+ """Removes the `idx`-th item from the 'backup_places' list in `backup.ini`."""
206
+ logger.debug("function call: write_rm_backup_place")
207
+ settings.rm_backup_place(idx)
208
+
209
+
210
+ def load(file: str, _raise: bool = False) -> None:
211
+ """If `file` is a valid SQLite database, loads it as the current database in Cashd.
212
+ If a database is already present, it will be renamed to a unique name and will be
213
+ kept in the directory.
214
+
215
+ :param file: Full path to the file that be loaded as the new database.
216
+ :param _raise: All unexpected errors are only logged to `backup.log`, but can also
217
+ be raised if `_raise=True`.
218
+ """
219
+ logger.debug("function call: load")
220
+ db_is_present = path.isfile(DB_FILE)
221
+
222
+ if not check_sqlite(file):
223
+ msg = f"Cannot load non-SQLite file: '{file}'."
224
+ logger.error(msg)
225
+ if _raise:
226
+ raise OSError(msg)
227
+
228
+ if db_is_present:
229
+ now = datetime.now()
230
+ dbfilename = path.split(DB_FILE)[1]
231
+ stashfilename = f"stashed{now}.db".replace(":", "-")
232
+ rename_on_db_folder(dbfilename, stashfilename)
233
+
234
+ try:
235
+ shutil.copyfile(file, DB_FILE)
236
+ except shutil.SameFileError:
237
+ pass
238
+
239
+
240
+ def run(
241
+ force: bool = False, settings: BackupPrefsHandler = settings, _raise: bool = False
242
+ ) -> None:
243
+ """Copies the database file to the local backup folder and to the folders listed in
244
+ the 'backup_places' option in `backup.ini`.
245
+
246
+ :param force: Using `force=False` will only make a copy if the file has increased in
247
+ size, compared to what's recorded in 'file_sizes'. Otherwise, will copy anyway.
248
+ :param settings: Settings handler that will be used to read the database file's size.
249
+ :param _raise: All unexpected errors are only logged to `backup.log`, but can also be
250
+ raised if `_raise=True`.
251
+ """
252
+ backup_places: list = settings.read_backup_places()
253
+ error_was_raised = False
254
+
255
+ current_size = read_db_size()
256
+ previous_size = settings.read_dbsize()
257
+
258
+ if not force:
259
+ if current_size <= previous_size:
260
+ return
261
+ settings.write_dbsize(current_size)
262
+
263
+ try:
264
+ backup_places = [i for i in [BACKUP_PATH] + backup_places if i != ""]
265
+ for place in backup_places:
266
+ try:
267
+ copy_file(DB_FILE, place, _raise=_raise)
268
+ except Exception as err:
269
+ logger.error(
270
+ f"Nao foi possivel salvar em '{place}': {err}", exc_info=True
271
+ )
272
+ if _raise:
273
+ error_was_raised = True
274
+ except Exception as err:
275
+ logger.error(f"Erro inesperado durante o backup: {err}", exc_info=True)
276
+ finally:
277
+ if error_was_raised:
278
+ raise NotADirectoryError(
279
+ f"Erro em alguma etapa do backup, verifique o log: {LOG_FILE}"
280
+ )
@@ -0,0 +1,95 @@
1
+ from sys import platform
2
+ from pathlib import Path
3
+ import subprocess
4
+
5
+
6
+ def sys_dark_mode() -> bool:
7
+ """Detects if dark mode is currently enabled in the OS.
8
+
9
+ :returns: `True` if dark mode is enabled, `False` otherwise.
10
+ """
11
+ match platform:
12
+ case "linux":
13
+ return _linux_dark_mode()
14
+ case "win32":
15
+ return False # ignore while dark theme is not implemented for winforms
16
+ # return _windows_dark_mode()
17
+ case _:
18
+ Warning(f"Not capable of detecting {platform=} dark mode.")
19
+ return False
20
+
21
+
22
+ def _windows_dark_mode() -> bool:
23
+ """Dectects if dark mode is enabled in a Windows system."""
24
+ # stackoverflow.com/a/65349866
25
+ # github.com/albertosottile/darkdetect/blob/master/darkdetect/_windows_detect.py
26
+ try:
27
+ import winreg
28
+ except ImportError:
29
+ return False
30
+ try:
31
+ key = winreg.OpenKey(
32
+ winreg.HKEY_CURRENT_USER,
33
+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize",
34
+ )
35
+ subkey = winreg.QueryValueEx(key=key, name="AppsUseLightTheme")[0]
36
+ # subkey value is 1 when the system is using light mode
37
+ return subkey == 0
38
+ except FileNotFoundError:
39
+ # assume light mode when key is not found
40
+ return False
41
+
42
+
43
+ def _linux_dark_mode() -> bool:
44
+ """Detects if dark mode is enabled in a GTK3/4 based GNU/Linux system.
45
+ Will depend on `gsettings` being installed in the system to work properly.
46
+ """
47
+ result = subprocess.run(
48
+ ["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"],
49
+ capture_output=True,
50
+ text=True
51
+ )
52
+ return "dark" in result.stdout.lower()
53
+
54
+
55
+ RESOURCES_DIR = Path(Path(__file__).parent, "resources")
56
+
57
+ FONT_SIZE = 10
58
+ MAIN_WINDOW_SIZE = (660, 560)
59
+
60
+ NA_VALUE = "N/D"
61
+ MAX_ALLOWED_VALUE = 99999999
62
+ SMALL_FONT_SIZE = abs(FONT_SIZE * 0.8)
63
+ BIG_FONT_SIZE = abs(FONT_SIZE * 1.15)
64
+ CONTENT_WIDTH = 560
65
+ FORM_WIDTH = 395
66
+
67
+ ESTADOS = [
68
+ "AC",
69
+ "AL",
70
+ "AP",
71
+ "AM",
72
+ "BA",
73
+ "CE",
74
+ "DF",
75
+ "ES",
76
+ "GO",
77
+ "MA",
78
+ "MT",
79
+ "MS",
80
+ "MG",
81
+ "PA",
82
+ "PB",
83
+ "PR",
84
+ "PE",
85
+ "PI",
86
+ "RJ",
87
+ "RN",
88
+ "RS",
89
+ "RO",
90
+ "RR",
91
+ "SC",
92
+ "SP",
93
+ "SE",
94
+ "TO",
95
+ ]