Skip to content

Commit 965b10f

Browse files
committed
refactor: improve utility typing and edge-case handling
1 parent 3bb01de commit 965b10f

17 files changed

Lines changed: 334 additions & 299 deletions

README.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,34 +43,34 @@ pip install python-backpack
4343

4444
### File Utils (`backpack.file_utils`)
4545

46-
- `replace_strings_in_file(ascii_file: str, strings: list, new_string: str) -> None`
46+
- `replace_strings_in_file(ascii_file: str, strings: Sequence[str], new_string: str) -> None`
4747
- Replaces multiple string occurrences in a text file.
48-
- `remove_line_from_file(ascii_file: str, strings: list, verbose: bool = False) -> None`
49-
- Removes exact matching lines from a text file.
48+
- `remove_line_from_file(ascii_file: str, strings: Sequence[str], verbose: bool = False) -> None`
49+
- Removes every exact matching line while preserving retained line endings.
5050
- `file_is_writeable(filepath: str) -> bool`
5151
- Checks whether a file can be opened for read/write.
5252
- `get_version_from_filename(filename: str) -> str`
5353
- Extracts a numeric version token from a filename.
5454

5555
### Folder Utils (`backpack.folder_utils`)
5656

57-
- `browse_folder(folder: str) -> bool`
58-
- Opens a folder in Windows Explorer.
59-
- `create_folders(folders: list, force_empty: bool = False, verbose: bool = False)`
57+
- `browse_folder(folder: str | None) -> bool`
58+
- Opens a valid folder in Windows Explorer and returns `False` if it cannot be opened.
59+
- `create_folders(folders: Sequence[str], force_empty: bool = False, verbose: bool = False) -> None`
6060
- Creates multiple folders.
61-
- `create_folder(path: str, force_empty: bool = False, verbose: bool = True)`
61+
- `create_folder(path: str, force_empty: bool = False, verbose: bool = True) -> bool`
6262
- Creates a folder and optionally clears it if it already exists.
63-
- `remove_files_in_dir(path: str)`
63+
- `remove_files_in_dir(path: str) -> None`
6464
- Removes all files and subdirectories inside a directory.
65-
- `recursive_dir_copy(source_path: str, target_path: str)`
65+
- `recursive_dir_copy(source_path: str, target_path: str) -> None`
6666
- Recursively copies files and subfolders from source to target.
6767

6868
### JSON Utils (`backpack.json_utils`)
6969

7070
- `json_load(json_file: str) -> dict`
7171
- Loads JSON data from file with validation and error handling.
72-
- `json_save(data: dict, json_file: str) -> bool`
73-
- Saves a dictionary to JSON file.
72+
- `json_save(data: object, json_file: str) -> bool`
73+
- Saves a JSON-serializable value as UTF-8 and returns whether it succeeded.
7474

7575
### JSON Metadata (`backpack.json_metadata`)
7676

@@ -90,7 +90,7 @@ pip install python-backpack
9090
- `JsonUserSettings(folder: str, name: str)`
9191
- Saves and loads JSON settings in the current user's home directory.
9292
- Main public methods:
93-
- `save_settings(data: dict | None = None) -> bool | None`
93+
- `save_settings(data: dict | None = None) -> bool`
9494
- `load_settings() -> dict | bool`
9595

9696
### Logger (`backpack.logger`)
@@ -121,8 +121,8 @@ pip install python-backpack
121121

122122
- `random_string(length: int = 10) -> str`
123123
- Generates a random lowercase string.
124-
- `time_function_decorator(method: type)`
125-
- Decorator that logs execution time.
124+
- `time_function_decorator(method: Callable[P, R]) -> Callable[P, R]`
125+
- Decorator that logs execution time while preserving function metadata and return values.
126126

127127
## Quick Example
128128

@@ -134,7 +134,7 @@ from backpack.json_utils import json_save, json_load
134134

135135
@timed_lru_cache(seconds=60)
136136
def expensive_call():
137-
return {'ok': True}
137+
return {'ok': True}
138138

139139

140140
value = camelcase_to_snakecase('HTTPServer')

backpack/cache.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,33 @@
55
# ----------------------------------------------------------------------------------------
66
from datetime import datetime, timedelta, timezone
77
from functools import lru_cache, wraps
8-
from typing import Any, Callable, TypeVar, cast
8+
from typing import Any, Callable, Protocol, TypeVar, cast
99

1010
from backpack.logger import get_logger
1111

1212
log = get_logger('Python Backpack - Cache')
1313

14-
F = TypeVar('F', bound=Callable[..., Any])
14+
R = TypeVar('R', covariant=True)
1515

1616

17-
def timed_lru_cache(seconds: int, maxsize: int = 128) -> Callable[[F], F]:
17+
class TimedCachedCallable(Protocol[R]):
18+
"""Callable returned by timed_lru_cache with cache control kwargs."""
19+
20+
def __call__(
21+
self,
22+
*args: Any,
23+
force_clear: bool = False,
24+
show_log: bool = False,
25+
**kwargs: Any,
26+
) -> R:
27+
"""Call the cached function, optionally forcing a cache clear first."""
28+
...
29+
30+
31+
def timed_lru_cache(
32+
seconds: int,
33+
maxsize: int = 128,
34+
) -> Callable[[Callable[..., R]], TimedCachedCallable[R]]:
1835
"""Lru_cache with expiration time.
1936
2037
Args:
@@ -39,13 +56,18 @@ def my_function():
3956
4057
"""
4158

42-
def wrapper_cache(func: F) -> F:
59+
def wrapper_cache(func: Callable[..., R]) -> TimedCachedCallable[R]:
4360
cached_func = cast(Any, lru_cache(maxsize=maxsize)(func))
4461
cached_func.lifetime = timedelta(seconds=seconds)
4562
cached_func.expiration = datetime.now(timezone.utc) + cached_func.lifetime
4663

4764
@wraps(func)
48-
def wrapped_func(*args, force_clear: bool = False, show_log: bool = False, **kwargs):
65+
def wrapped_func(
66+
*args: Any,
67+
force_clear: bool = False,
68+
show_log: bool = False,
69+
**kwargs: Any,
70+
) -> R:
4971
"""Wrapper function for lru_cache with expiration time.
5072
5173
Args:
@@ -66,6 +88,6 @@ def wrapped_func(*args, force_clear: bool = False, show_log: bool = False, **kwa
6688

6789
return cached_func(*args, **kwargs)
6890

69-
return cast(F, wrapped_func)
91+
return cast(TimedCachedCallable[R], wrapped_func)
7092

7193
return wrapper_cache

backpack/file_utils.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44
# https://github.com/MaxRocamora/python-backpack
55
# ----------------------------------------------------------------------------------------
66

7-
import contextlib
7+
from collections.abc import Sequence
88

99
from backpack.logger import get_logger
1010

1111
log = get_logger('Python Backpack - FileUtils')
1212

1313

14-
def replace_strings_in_file(ascii_file: str, strings: list, new_string: str) -> None:
14+
def replace_strings_in_file(
15+
ascii_file: str,
16+
strings: Sequence[str],
17+
new_string: str,
18+
) -> None:
1519
"""Opens ascii file and replaces all occurrences from strings into new_string.
1620
1721
In this class we use a full path to avoid use of os.dirname, which
@@ -28,21 +32,24 @@ def replace_strings_in_file(ascii_file: str, strings: list, new_string: str) ->
2832

2933
log.info(f'Replacing Strings, Opening File: {ascii_file}')
3034

31-
with open(ascii_file) as f:
35+
with open(ascii_file, newline='') as f:
3236
file_data = f.read()
3337
for i in strings:
3438
log.info('Finding: %s', i)
3539
log.info('Replacing for: %s', new_string)
3640
log.info('-' * 50)
3741
file_data = file_data.replace(i, new_string)
3842

39-
with open(ascii_file, 'w') as f:
43+
with open(ascii_file, 'w', newline='') as f:
4044
f.write(file_data)
41-
f.close()
4245
log.info(f'Closing File: {ascii_file}')
4346

4447

45-
def remove_line_from_file(ascii_file: str, strings: list, verbose: bool = False) -> None:
48+
def remove_line_from_file(
49+
ascii_file: str,
50+
strings: Sequence[str],
51+
verbose: bool = False,
52+
) -> None:
4653
"""Removes given lines from ascii file.
4754
4855
Args:
@@ -51,21 +58,22 @@ def remove_line_from_file(ascii_file: str, strings: list, verbose: bool = False)
5158
verbose: (bool) if true, prints removed lines
5259
"""
5360

54-
with open(ascii_file) as f:
55-
file_content = f.read().splitlines()
61+
retained_lines = []
62+
with open(ascii_file, newline='') as f:
63+
file_content = f.readlines()
5664

5765
for line in file_content:
66+
value = line.rstrip('\r\n')
5867
if verbose:
59-
log.info(f'Checking line: {line}')
60-
with contextlib.suppress(ValueError):
61-
if line in strings:
62-
if verbose:
63-
log.info(f'Removing line: {line}')
64-
file_content.pop(file_content.index(line))
65-
66-
with open(ascii_file, 'w') as f:
67-
contents = '\n'.join(file_content)
68-
f.write(contents)
68+
log.info(f'Checking line: {value}')
69+
if value in strings:
70+
if verbose:
71+
log.info(f'Removing line: {value}')
72+
else:
73+
retained_lines.append(line)
74+
75+
with open(ascii_file, 'w', newline='') as f:
76+
f.writelines(retained_lines)
6977

7078

7179
def file_is_writeable(filepath: str) -> bool:

backpack/folder_utils.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,37 @@
77
import os
88
import shutil
99
import subprocess
10+
from collections.abc import Sequence
1011

1112
from backpack.logger import get_logger
1213

1314
log = get_logger('Python Backpack - FolderUtils')
1415

1516

16-
def browse_folder(folder: str) -> bool:
17+
def browse_folder(folder: str | None) -> bool:
1718
"""Open windows explorer on folder.
1819
1920
Args:
2021
folder: (string path) folder to open
2122
"""
2223
if folder and os.path.isdir(folder):
23-
subprocess.Popen(f'explorer {os.path.abspath(folder)}')
24-
return True
24+
try:
25+
subprocess.Popen(['explorer', os.path.abspath(folder)])
26+
return True
27+
except OSError as error:
28+
log.warning(f'Unable to open folder {folder}')
29+
log.error(str(error))
30+
return False
2531

2632
log.warning(f'Unable to open folder {folder}')
2733
return False
2834

2935

30-
def create_folders(folders: list, force_empty: bool = False, verbose: bool = False):
36+
def create_folders(
37+
folders: Sequence[str],
38+
force_empty: bool = False,
39+
verbose: bool = False,
40+
) -> None:
3141
"""Creates multiple folders on disc.
3242
3343
Args:
@@ -39,7 +49,7 @@ def create_folders(folders: list, force_empty: bool = False, verbose: bool = Fal
3949
create_folder(folder, force_empty=force_empty, verbose=verbose)
4050

4151

42-
def create_folder(path: str, force_empty: bool = False, verbose: bool = True):
52+
def create_folder(path: str, force_empty: bool = False, verbose: bool = True) -> bool:
4353
"""Creates a folder.
4454
4555
Args:
@@ -63,7 +73,7 @@ def create_folder(path: str, force_empty: bool = False, verbose: bool = True):
6373
return True
6474

6575

66-
def remove_files_in_dir(path: str):
76+
def remove_files_in_dir(path: str) -> None:
6777
"""Clears all content in given directory."""
6878
for root, dirs, files in os.walk(path):
6979
for f in files:
@@ -72,7 +82,7 @@ def remove_files_in_dir(path: str):
7282
shutil.rmtree(os.path.join(root, d))
7383

7484

75-
def recursive_dir_copy(source_path: str, target_path: str):
85+
def recursive_dir_copy(source_path: str, target_path: str) -> None:
7686
"""Copy all files src dir to dest dir, including sub-directories.
7787
7888
Args:

backpack/json_metadata.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Maximiliano Rocamora / maxirocamora@gmail.com
44
# https://github.com/MaxRocamora/python-backpack
55
# ----------------------------------------------------------------------------------------
6+
import getpass
67
import inspect
78
import os
89
import platform
@@ -97,7 +98,9 @@ def insert_class(self, _class: type) -> None:
9798
attributes = {}
9899
for name in dir(_class):
99100
value = getattr(_class, name)
100-
if not name.startswith('__') and not inspect.ismethod(value):
101+
if not name.startswith('__') and not (
102+
inspect.isroutine(value) or inspect.isdatadescriptor(value)
103+
):
101104
attributes[name] = value
102105

103106
self._data = attributes
@@ -113,7 +116,7 @@ def _system_data(self) -> dict:
113116
'app': os.path.basename(sys.executable),
114117
'PC': str(platform.node()),
115118
'python_version': sys.version,
116-
'User': str(os.getenv('username')),
119+
'User': getpass.getuser(),
117120
'time': self._current_time_metadata(),
118121
}
119122

backpack/json_user_settings.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ def __init__(self, folder: str, name: str) -> None:
2727
folder (str): name of sub folder inside user path. Defaults to 'json_settings'.
2828
name (str): name used for the json file. Defaults to 'user_data'.
2929
"""
30-
self.name = name
30+
self.filename = name
3131
self.folder = folder
3232
self._user_data = {}
3333
self._verify_path()
3434

3535
@property
3636
def filepath(self) -> str:
3737
"""Returns user filepath."""
38-
path = os.path.join(self.os_user_folder, self.folder, f'{self.name}.json')
38+
path = os.path.join(self.os_user_folder, self.folder, f'{self.filename}.json')
3939
return os.path.abspath(path)
4040

4141
@property
@@ -60,14 +60,14 @@ def _verify_path(self) -> bool:
6060

6161
return True
6262

63-
def save_settings(self, data: dict | None = None) -> bool | None:
63+
def save_settings(self, data: dict | None = None) -> bool:
6464
"""Saves a dictionary into a json file (os user path).
6565
6666
Args:
67-
data (dictionary): info dictionary to save, if not provided,
67+
data (dict): info dictionary to save, if not provided,
6868
saves instead local self.user_data property
6969
Returns:
70-
bool | None: True if file was saved, False if error, None if no data to save.
70+
bool: True if the file was saved, otherwise False.
7171
"""
7272
if data is None:
7373
data = self.user_data

backpack/json_utils.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,32 @@ def json_load(json_file: str) -> dict:
1818
if not os.path.exists(json_file):
1919
raise OSError(f'json_load: File not found: {json_file}.')
2020

21-
with open(json_file) as json_file_opened:
21+
with open(json_file, encoding='utf-8') as json_file_opened:
2222
try:
2323
value = json.load(json_file_opened)
2424
except ValueError as e:
25-
json_file_opened.close()
2625
raise OSError(f'{json_file} \n JSON File issue: {str(e)}') from e
2726

2827
return value
2928

3029

31-
def json_save(data: dict, json_file: str) -> bool:
30+
def json_save(data: object, json_file: str) -> bool:
3231
"""Saves a dictionary into a json file.
3332
3433
Args:
35-
data: (dict) dictionary to save
34+
data: value to serialize as JSON
3635
json_file: (string filepath) json file to save data.
3736
3837
Returns:
3938
bool (True if success)
4039
"""
4140

42-
if not os.path.exists(os.path.dirname(json_file)):
43-
os.makedirs(os.path.dirname(json_file), exist_ok=True)
41+
parent_dir = os.path.dirname(json_file)
42+
if parent_dir and not os.path.exists(parent_dir):
43+
os.makedirs(parent_dir, exist_ok=True)
4444

4545
try:
46-
with open(json_file, 'w') as f:
46+
with open(json_file, 'w', encoding='utf-8') as f:
4747
json.dump(data, f, sort_keys=True, indent=4)
4848
return True
4949

0 commit comments

Comments
 (0)