termcore 0.2.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 (55) hide show
  1. termcore/__init__.py +6 -0
  2. termcore/cli/__init__.py +3 -0
  3. termcore/cli/output.py +70 -0
  4. termcore/io/__init__.py +7 -0
  5. termcore/io/app_state_storage.py +185 -0
  6. termcore/io/database.py +655 -0
  7. termcore/io/errors.py +71 -0
  8. termcore/io/file.py +263 -0
  9. termcore/io/textfile.py +92 -0
  10. termcore/py.typed +0 -0
  11. termcore/tui/__init__.py +9 -0
  12. termcore/tui/custom_bindings.py +551 -0
  13. termcore/tui/custom_widgets/__init__.py +1 -0
  14. termcore/tui/custom_widgets/custom_checkbox.py +41 -0
  15. termcore/tui/custom_widgets/custom_data_table.py +247 -0
  16. termcore/tui/custom_widgets/custom_selection.py +80 -0
  17. termcore/tui/custom_widgets/multiline_footer.py +240 -0
  18. termcore/tui/question_screen.py +107 -0
  19. termcore/tui/theme_loader.py +495 -0
  20. termcore/tui/themes/__init__.py +2 -0
  21. termcore/tui/themes/classic-black-saturated/style.css +57 -0
  22. termcore/tui/themes/classic-black-saturated/theme.py +22 -0
  23. termcore/tui/themes/classic-black-v1/style.css +57 -0
  24. termcore/tui/themes/classic-black-v1/theme.py +22 -0
  25. termcore/tui/themes/classic-black-v2/style.css +57 -0
  26. termcore/tui/themes/classic-black-v2/theme.py +24 -0
  27. termcore/tui/themes/classic-blue/theme.py +21 -0
  28. termcore/tui/themes/compact-gray/style.tcss +175 -0
  29. termcore/tui/themes/compact-gray/theme.py +22 -0
  30. termcore/tui/themes/mnml-black/style.css +57 -0
  31. termcore/tui/themes/mnml-black/theme.py +21 -0
  32. termcore/tui/themes/mnml-deepblack/style.css +57 -0
  33. termcore/tui/themes/mnml-deepblack/theme.py +21 -0
  34. termcore/tui/themes/pure-amber/theme.py +18 -0
  35. termcore/tui/themes/pure-black/theme.py +18 -0
  36. termcore/tui/themes/pure-blue/theme.py +18 -0
  37. termcore/tui/themes/pure-green/theme.py +18 -0
  38. termcore/tui/themes/pure-sweet16/theme.py +20 -0
  39. termcore/tui/themes/xplore-black/theme.py +18 -0
  40. termcore/tui/themes/xplore-blue/theme.py +18 -0
  41. termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
  42. termcore/tui/themes/xplore-teal/theme.py +18 -0
  43. termcore/util/__init__.py +10 -0
  44. termcore/util/datetime.py +173 -0
  45. termcore/util/debug.py +196 -0
  46. termcore/util/index.py +112 -0
  47. termcore/util/logger.py +36 -0
  48. termcore/util/singleton.py +46 -0
  49. termcore/util/string.py +263 -0
  50. termcore/util/validation.py +31 -0
  51. termcore/util/version.py +56 -0
  52. termcore-0.2.0.dist-info/METADATA +504 -0
  53. termcore-0.2.0.dist-info/RECORD +55 -0
  54. termcore-0.2.0.dist-info/WHEEL +4 -0
  55. termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,173 @@
1
+ """
2
+ Converting between UNIX timestamps and date strings.
3
+
4
+ This module offers convenient static methods for working with dates and times,
5
+ including:
6
+
7
+ - Converting UNIX timestamps to formatted date strings and vice versa.
8
+ - Calculating the number of days between two timestamps.
9
+ - Retrieving today's date or timestamp at midnight.
10
+
11
+ It supports both German-style date formatting ("DD.MM.YYYY") and ISO-style
12
+ formatting ("YYYY-MM-DD") for flexibility in various locales, selected through
13
+ the `DateFormat` enum.
14
+
15
+ Every conversion is anchored in the machine's local time zone, and says so:
16
+ the datetimes built here carry their offset instead of leaving it implied. A
17
+ naive timestamp changes meaning with the daylight saving switch, with the
18
+ machine and on serialisation, and the error surfaces months later in wrongly
19
+ sorted or wrongly calculated data rather than at the call site.
20
+
21
+ """
22
+
23
+ from datetime import UTC, datetime, time
24
+ from enum import Enum
25
+
26
+ # Without this, `from .datetime import *` in the package __init__ re-exports
27
+ # the imported `datetime` class, which then shadows this very module: after
28
+ # importing termcore, `termcore.util.datetime` is the class, not the module.
29
+ __all__ = [
30
+ "DateFormat",
31
+ "date_diff",
32
+ "date_to_timestamp",
33
+ "timestamp_to_date",
34
+ "today_date",
35
+ "today_timestamp",
36
+ ]
37
+
38
+
39
+ class DateFormat(Enum):
40
+ """
41
+ The date layouts these functions read and write.
42
+
43
+ Values are `strftime` patterns, so they can be handed to `strftime` and
44
+ `strptime` directly.
45
+ """
46
+
47
+ GERMAN = "%d.%m.%Y"
48
+ ISO = "%Y-%m-%d"
49
+
50
+
51
+ def timestamp_to_date(
52
+ timestamp: int | None, date_format: DateFormat = DateFormat.GERMAN
53
+ ) -> str:
54
+ """
55
+ Converts a UNIX timestamp into a date string.
56
+
57
+ Parameters
58
+ ----------
59
+ timestamp : int or None
60
+ UNIX timestamp.
61
+ date_format : DateFormat, optional
62
+ Layout of the returned string, German by default.
63
+
64
+ Returns
65
+ -------
66
+ str
67
+ The date as seen in the local time zone. Empty string if the given
68
+ timestamp is None.
69
+ """
70
+ # Return empty string if the given timestamp is None
71
+ if timestamp is None:
72
+ return ""
73
+
74
+ # Convert timestamp to a date in the local zone
75
+ date_obj = datetime.fromtimestamp(timestamp, tz=UTC).astimezone()
76
+
77
+ return date_obj.strftime(date_format.value)
78
+
79
+
80
+ def date_to_timestamp(
81
+ date_str: str, date_format: DateFormat = DateFormat.GERMAN
82
+ ) -> int | None:
83
+ """
84
+ Converts a date string into a UNIX timestamp.
85
+
86
+ The date is read as local midnight.
87
+
88
+ Parameters
89
+ ----------
90
+ date_str : str
91
+ The date, laid out as `date_format` describes.
92
+ date_format : DateFormat, optional
93
+ Layout the string is expected in, German by default.
94
+
95
+ Returns
96
+ -------
97
+ int or None
98
+ Unix timestamp (number of seconds since 1970-01-01), or None if the
99
+ string does not match the expected layout.
100
+ """
101
+ # Check if the date string matches the expected format
102
+ try:
103
+ date_obj = datetime.strptime(date_str, date_format.value) # noqa: DTZ007
104
+ except ValueError:
105
+ return None
106
+
107
+ # astimezone() reads a naive value as local time and attaches the matching
108
+ # offset - the assumption this module makes explicit rather than inherits
109
+ return int(date_obj.astimezone().timestamp())
110
+
111
+
112
+ def date_diff(timestamp1: int, timestamp2: int) -> int:
113
+ """
114
+ Calculates the number of calendar days between two timestamps.
115
+
116
+ Counted as local calendar dates rather than as 86400-second blocks, so
117
+ the time of day does not matter and a span crossing a daylight saving
118
+ switch is not off by one. Two moments on the same local day are 0 days
119
+ apart, whether they are a second or twenty hours apart.
120
+
121
+ Parameters
122
+ ----------
123
+ timestamp1 : int
124
+ UNIX time stamp 1.
125
+ timestamp2 : int
126
+ UNIX time stamp 2.
127
+
128
+ Returns
129
+ -------
130
+ int
131
+ Number of days between the given timestamps, negative when the
132
+ second one is the later of the two.
133
+ """
134
+ first = datetime.fromtimestamp(timestamp1, tz=UTC).astimezone().date()
135
+ second = datetime.fromtimestamp(timestamp2, tz=UTC).astimezone().date()
136
+
137
+ return (first - second).days
138
+
139
+
140
+ def today_timestamp() -> int:
141
+ """
142
+ Returns the time stamp of today for the time 00:00 h.
143
+
144
+ Returns
145
+ -------
146
+ int
147
+ UNIX timestamp of local midnight.
148
+ """
149
+ # Date of today in the local zone
150
+ today = datetime.now(UTC).astimezone().date()
151
+
152
+ # Combine with time 00:00 and anchor it in the local zone
153
+ midnight = datetime.combine(today, time.min).astimezone()
154
+
155
+ # Return unix timestamp
156
+ return int(midnight.timestamp())
157
+
158
+
159
+ def today_date(date_format: DateFormat = DateFormat.GERMAN) -> str:
160
+ """
161
+ Returns the date of today as a string.
162
+
163
+ Parameters
164
+ ----------
165
+ date_format : DateFormat, optional
166
+ Layout of the returned string, German by default.
167
+
168
+ Returns
169
+ -------
170
+ str
171
+ Today's date in the local time zone.
172
+ """
173
+ return timestamp_to_date(today_timestamp(), date_format)
termcore/util/debug.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ Decorators for inspecting calls and measuring execution time.
3
+
4
+ This module offers decorators that can be used to:
5
+
6
+ - Log function calls, including arguments, keyword arguments and return values.
7
+ - Measure and display execution time of functions, with optional nanosecond
8
+ precision.
9
+
10
+ These utilities are useful for tracking function behavior and identifying
11
+ performance bottlenecks during runtime, without requiring changes to the
12
+ function logic itself.
13
+
14
+ Examples
15
+ --------
16
+ Printing arguments
17
+ ~~~~~~~~~~~~~~~~~~
18
+
19
+ >>> @print_arguments
20
+ ... def do_something(t: str, n: int) -> None:
21
+ ... print(f'I am not doing anything with string "{t}" and number {str(n)}.')
22
+ ...
23
+ >>> do_something('ABC', 11)
24
+
25
+ Measuring execution time of a function
26
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
27
+
28
+ >>> @timing()
29
+ ... def iterate_something() -> None:
30
+ ... v: int = 0
31
+ ... for i in range(10**7):
32
+ ... v += 1
33
+ ...
34
+ >>> iterate_something()
35
+
36
+ """
37
+
38
+ import logging
39
+ import time
40
+ from collections.abc import Callable
41
+ from functools import wraps
42
+ from typing import ParamSpec, TypeVar
43
+
44
+ __all__ = [
45
+ "print_arguments",
46
+ "timing",
47
+ "timing_ns",
48
+ ]
49
+
50
+ _logger = logging.getLogger(__name__)
51
+
52
+ P = ParamSpec("P")
53
+ R = TypeVar("R")
54
+
55
+
56
+ def print_arguments[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
57
+ """
58
+ Logs name, arguments and return value on every call.
59
+
60
+ Parameters
61
+ ----------
62
+ fn : Callable
63
+ The function to be decorated.
64
+
65
+ Returns
66
+ -------
67
+ Callable
68
+ A wrapper function that adds debugging output.
69
+ """
70
+ @wraps(fn) # Without this fn.__name__ would be empty
71
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
72
+ """
73
+ Wrapper function that prints the function's arguments and return value.
74
+
75
+ Parameters
76
+ ----------
77
+ *args : P.args
78
+ Positional arguments.
79
+ **kwargs : P.kwargs
80
+ Keyword arguments.
81
+
82
+ Returns
83
+ -------
84
+ R
85
+ The original return value of the decorated function.
86
+ """
87
+ # Report the call through the logger, so a consumer can filter
88
+ # or silence it like any other diagnostic
89
+ _logger.debug("Function %s called", fn.__name__)
90
+ _logger.debug("Args: %r", args)
91
+ _logger.debug("Kwargs: %r", kwargs)
92
+
93
+ # Call function
94
+ fn_result = fn(*args, **kwargs)
95
+
96
+ _logger.debug(
97
+ "Function %s returns: %r", fn.__name__, fn_result
98
+ )
99
+
100
+ return fn_result
101
+
102
+ return wrapper
103
+
104
+
105
+ def timing() -> Callable[[Callable[P, R]], Callable[P, R]]:
106
+ """
107
+ A decorator factory that measures the execution time in seconds.
108
+
109
+ Returns
110
+ -------
111
+ Callable
112
+ A decorator that wraps the target function with timing logic.
113
+ """
114
+ return _timing(time.perf_counter, "s")
115
+
116
+
117
+ def timing_ns() -> Callable[[Callable[P, R]], Callable[P, R]]:
118
+ """
119
+ A decorator factory that measures the execution time in nanoseconds.
120
+
121
+ Returns
122
+ -------
123
+ Callable
124
+ A decorator that wraps the target function with timing logic.
125
+ """
126
+ return _timing(time.perf_counter_ns, "ns")
127
+
128
+
129
+ def _timing(
130
+ time_fn: Callable[[], float], time_scale: str
131
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
132
+ """
133
+ Builds the timing decorator around the given clock.
134
+
135
+ Parameters
136
+ ----------
137
+ time_fn : Callable
138
+ The clock to read, in the unit `time_scale` names.
139
+ time_scale : str
140
+ Unit suffix written to the log.
141
+
142
+ Returns
143
+ -------
144
+ Callable
145
+ A decorator that wraps the target function with timing logic.
146
+ """
147
+ def wrap_with_timing(fn: Callable[P, R]) -> Callable[P, R]:
148
+ """
149
+ Wraps the function so its execution time is measured.
150
+
151
+ Parameters
152
+ ----------
153
+ fn : Callable
154
+ Reference to the function.
155
+
156
+ Returns
157
+ -------
158
+ Callable
159
+ A wrapped version of the function with timing logic.
160
+ """
161
+ @wraps(fn)
162
+ def timer(*args: P.args, **kwargs: P.kwargs) -> R:
163
+ """
164
+ Measures the execution time of the function call.
165
+
166
+ Parameters
167
+ ----------
168
+ *args : P.args
169
+ Positional arguments.
170
+ **kwargs : P.kwargs
171
+ Keyword arguments.
172
+
173
+ Returns
174
+ -------
175
+ R
176
+ The original return value of the decorated function.
177
+ """
178
+ # Store start time
179
+ start_time = time_fn()
180
+
181
+ # Call function
182
+ fn_result = fn(*args, **kwargs)
183
+
184
+ # Store end time + calculate and print execution time
185
+ end_time = time_fn()
186
+ duration = end_time - start_time
187
+ _logger.debug(
188
+ "Function %s took: %s %s",
189
+ fn.__name__, duration, time_scale
190
+ )
191
+
192
+ return fn_result
193
+
194
+ return timer
195
+
196
+ return wrap_with_timing
termcore/util/index.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Index arithmetic for navigating lists.
3
+
4
+ In particular the cyclic movement selection lists in a terminal user
5
+ interface are expected to have.
6
+
7
+ Two functions rather than one with a switch: `next_index` wraps around at
8
+ both ends, `clamped_index` stops at them. Which one a view wants is a
9
+ property of that view, and naming it at the call site says so.
10
+
11
+ """
12
+
13
+
14
+
15
+ __all__ = [
16
+ "clamped_index",
17
+ "next_index",
18
+ ]
19
+
20
+
21
+ def next_index(current_index: int, length: int, direction: int = 1) -> int:
22
+ """
23
+ Calculates the next index in a list, wrapping around at both ends.
24
+
25
+ Parameters
26
+ ----------
27
+ current_index : int
28
+ The current index in the list.
29
+ length : int
30
+ The number of items in the list.
31
+ direction : int, optional
32
+ The direction and size of the movement (1 for one step forward,
33
+ -1 for one step backward).
34
+
35
+ Returns
36
+ -------
37
+ int
38
+ The calculated next index. An empty list always yields 0.
39
+
40
+ Notes
41
+ -----
42
+ The expression `(current_index + direction) % length` keeps the index
43
+ within the valid range from `0` to `length - 1`. The modulo operator
44
+ makes it "wrap around":
45
+
46
+ - If the index moves past the end of the list, it wraps back
47
+ to 0.
48
+ - If the index moves before the beginning, it wraps to the
49
+ last position.
50
+
51
+ Examples
52
+ --------
53
+ For a list of length 5 (indices 0 to 4):
54
+
55
+ >>> next_index(4, 5) # moves from the end to the start
56
+ 0
57
+ >>> next_index(0, 5, direction=-1) # moves from the start to the end
58
+ 4
59
+ >>> next_index(2, 5) # normal forward movement
60
+ 3
61
+ >>> next_index(2, 5, direction=-1) # normal backward movement
62
+ 1
63
+
64
+ An empty list has no valid index, so 0 is returned:
65
+
66
+ >>> next_index(0, 0)
67
+ 0
68
+ """
69
+ # An empty list has no index to move to - and % 0 would raise
70
+ if length <= 0:
71
+ return 0
72
+
73
+ return (current_index + direction) % length
74
+
75
+
76
+ def clamped_index(current_index: int, length: int, direction: int = 1) -> int:
77
+ """
78
+ Calculates the next index in a list, stopping at both ends.
79
+
80
+ Parameters
81
+ ----------
82
+ current_index : int
83
+ The current index in the list.
84
+ length : int
85
+ The number of items in the list.
86
+ direction : int, optional
87
+ The direction and size of the movement (1 for one step forward,
88
+ -1 for one step backward).
89
+
90
+ Returns
91
+ -------
92
+ int
93
+ The calculated next index, never outside the list. An empty list
94
+ always yields 0.
95
+
96
+ Examples
97
+ --------
98
+ For a list of length 5 (indices 0 to 4):
99
+
100
+ >>> clamped_index(4, 5) # already at the end, stays there
101
+ 4
102
+ >>> clamped_index(0, 5, direction=-1) # already at the start
103
+ 0
104
+ >>> clamped_index(2, 5) # normal forward movement
105
+ 3
106
+ >>> clamped_index(0, 5, direction=10) # a step past the end is clamped
107
+ 4
108
+ """
109
+ if length <= 0:
110
+ return 0
111
+
112
+ return max(0, min(current_index + direction, length - 1))
@@ -0,0 +1,36 @@
1
+ """Sets up file and console logging for an application."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ __all__ = [
7
+ "setup_logging",
8
+ ]
9
+
10
+
11
+ def setup_logging(appname: str, level: int = logging.DEBUG) -> None:
12
+ """
13
+ Sets up logging for the application.
14
+
15
+ Logs are stored in the user's home directory, under
16
+ `.local/state/{appname}/app.log`.
17
+
18
+ Parameters
19
+ ----------
20
+ appname : str
21
+ The name of the application, used to create a unique log directory.
22
+ level : int, optional
23
+ The logging level (e.g., logging.DEBUG, logging.INFO),
24
+ by default logging.DEBUG.
25
+ """
26
+ log_dir = Path.home() / ".local" / "state" / appname
27
+ log_dir.mkdir(parents=True, exist_ok=True)
28
+
29
+ log_file = log_dir / "app.log"
30
+
31
+ logging.basicConfig(
32
+ level=level,
33
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
+ datefmt="%Y-%m-%d %H:%M:%S",
35
+ handlers=[logging.FileHandler(log_file, encoding="utf-8")],
36
+ )
@@ -0,0 +1,46 @@
1
+ """The metaclass implementing the singleton pattern."""
2
+
3
+ from typing import Any, cast
4
+
5
+ __all__ = [
6
+ "Singleton",
7
+ ]
8
+
9
+ # Keyed by class, so that two classes using this metaclass keep their own
10
+ # instance. Holding the registry here rather than on the metaclass is also
11
+ # what lets __call__ report the constructed class as its return type.
12
+ _instances: dict[type, object] = {}
13
+
14
+
15
+ class Singleton(type):
16
+ """
17
+ Metaclass for the singleton pattern.
18
+
19
+ Ensures that a class has only one instance and provides global access
20
+ to it.
21
+
22
+ Examples
23
+ --------
24
+ >>> class SomeClass(metaclass=Singleton):
25
+ ... pass
26
+ >>> SomeClass() is SomeClass()
27
+ True
28
+ """
29
+
30
+ def __call__[T](cls: type[T], *args: Any, **kwargs: Any) -> T: # noqa: ANN401
31
+ """
32
+ Returns the one instance, building it on the first call.
33
+
34
+ The arguments are whatever the class itself takes, which is what Any
35
+ says here. The return type is the class, so that a consumer sees the
36
+ type it asked for instead of `object`.
37
+ """
38
+ if cls not in _instances:
39
+ _instances[cls] = super().__call__(*args, **kwargs) # pyright: ignore[reportAny]
40
+
41
+ return cast("T", _instances[cls])
42
+
43
+ @property
44
+ def instance(cls) -> object | None:
45
+ """Returns the single instance, or None before it was built."""
46
+ return _instances.get(cls)