Compare commits

..

114 Commits

Author SHA1 Message Date
Clemens Schwaighofer
37a197e7f1 v0.23.0: json dumps updates for functions, safe dict dump 2025-09-03 18:15:48 +09:00
Clemens Schwaighofer
74cb3d2c54 dump_data and new json_dumps
dump_data adds flag to dump without indent

json_dumps is dump_data like, but will be geared towards secure dump of dict to json for strage
2025-09-03 18:14:26 +09:00
Clemens Schwaighofer
d19abcabc7 v0.22.6: Empty settings loader config for just data load 2025-08-26 14:40:22 +09:00
Clemens Schwaighofer
f8ae6609c7 Allow empty config settings for settings loader if only loading is needed 2025-08-26 14:38:55 +09:00
Clemens Schwaighofer
cbd39ff161 v0.22.5: settings loader clean up 2025-08-26 14:33:26 +09:00
Clemens Schwaighofer
f8905a176c Fix settings loader
Remove all class vars for vars that are only used in the loader itsef
- entry_split_char
- entry_convert
- entry_set_empty

The self.settings varr was never used, removed

The config file path exists check is moved to the config data loader

The internal _check_settings_abort is now __check_settings_abort to make it private

lock file updates
2025-08-26 14:29:52 +09:00
Clemens Schwaighofer
847288e91f Add a security md file 2025-08-26 14:15:14 +09:00
Clemens Schwaighofer
446d9d5217 Log documentation updates 2025-08-18 14:35:14 +09:00
Clemens Schwaighofer
3a7a1659f0 Log remove auto close log queue logic 2025-08-05 16:21:11 +09:00
Clemens Schwaighofer
bc23006a34 disable the auto close of the log queue
This causes problems with logger clean up
2025-08-05 16:20:13 +09:00
Clemens Schwaighofer
6090995eba v0.22.3: Fixes in Log for atexit calls for queue close 2025-08-05 13:24:16 +09:00
Clemens Schwaighofer
60db747d6d More fixes for the queue clean up
Changed that we call stop_listener and not _cleanup on exit
Then call _cleanup from the stop listener
We only need that if we have listeners (queue) anyway
2025-08-05 13:22:54 +09:00
Clemens Schwaighofer
a7a4141f58 v0.22.2: Log remove __del__ call for clean up, this broke everything 2025-08-05 10:37:57 +09:00
Clemens Schwaighofer
2b04cbe239 Remove Log __del__ cleanup 2025-08-05 10:36:49 +09:00
Clemens Schwaighofer
765cc061c1 v0.22.1: Log update with closing queue on exit or abort 2025-08-05 10:33:55 +09:00
Clemens Schwaighofer
80319385f0 Add Log exist queue clean up if queue is set
to avoid hung threads on errors
2025-08-05 10:32:33 +09:00
Clemens Schwaighofer
29dd906fe0 v0.22.0: per run log file rotate 2025-08-01 16:04:18 +09:00
Clemens Schwaighofer
d5dc4028c3 Merge branch 'development' 2025-08-01 16:02:40 +09:00
Clemens Schwaighofer
0df049d453 Add per run log rotate flag
This flag will use the normal file handler with a file name that has date + time + milliseconds
to create a new file each time the script is run
2025-08-01 16:01:50 +09:00
Clemens Schwaighofer
0bd7c1f685 v0.21.1: Update convert time string to skip any numbers 2025-07-29 09:30:56 +09:00
Clemens Schwaighofer
2f08ecabbf For convert time string, skip convert if incoming value is a number of any type
Any float number will be rounded, and everything that is any kind of number will be then converted to int and returned
The rest will be converted to string and normal convert is run
2025-07-29 09:29:38 +09:00
Clemens Schwaighofer
12af1c80dc v0.21.0: string with time units to seconds int 2025-07-29 09:15:20 +09:00
Clemens Schwaighofer
a52b6e0a55 Merge branch 'development' 2025-07-29 09:14:11 +09:00
Clemens Schwaighofer
a586cf65e2 Convert string with time units to seconds 2025-07-29 09:13:36 +09:00
Clemens Schwaighofer
e2e7882bfa Log exception with new exception_stack call, exception_stack method added to the debug helpers 2025-07-28 15:27:55 +09:00
Clemens Schwaighofer
4f9c2b9d5f Add exception stack caller and add this to the logger exception call
So we get the location of the exception in the console log too
2025-07-28 15:26:23 +09:00
Clemens Schwaighofer
5203bcf1ea v0.19.1: Log exception call, add call stack to the console log output 2025-07-28 14:32:56 +09:00
Clemens Schwaighofer
f1e3bc8559 For Log exception write to ERROR, add the stack trace too 2025-07-28 14:32:14 +09:00
Clemens Schwaighofer
b97ca6f064 v0.19.0: add http basic auth creator method 2025-07-26 11:27:10 +09:00
Clemens Schwaighofer
d1ea9874da Add HTTP basic auth builder 2025-07-26 11:26:09 +09:00
Clemens Schwaighofer
3cd3f87d68 v0.18.2: dump data parameter change to Any 2025-07-26 10:52:48 +09:00
Clemens Schwaighofer
582937b866 dump_data is now ANY, we do the detail dump type in the run later 2025-07-26 10:51:37 +09:00
Clemens Schwaighofer
2b8240c156 v0.18.1: bug fix for find_in_array_from_list search key check 2025-07-25 15:58:59 +09:00
Clemens Schwaighofer
abf4b7ac89 Bug fix for find_in_array_from_list because of keys order 2025-07-25 15:57:48 +09:00
Clemens Schwaighofer
9c49f83c16 v0.18.0: array_search deprecation in change for find_in_array_from_list with correct parameter order 2025-07-25 15:50:58 +09:00
Clemens Schwaighofer
3a625ed0ee Merge branch 'master' into development 2025-07-25 15:49:58 +09:00
Clemens Schwaighofer
2cfbf4bb90 Update data search for iterators
array_search name is deprecated
use find_in_array_from_list
- change parameter order
data (search in) comes before search_params list
- created a TypedDict for the array search params dict entry
2025-07-25 15:48:37 +09:00
Clemens Schwaighofer
5767533668 v0.17.0: exceptions handling added for csv file reading 2025-07-25 10:25:44 +09:00
Clemens Schwaighofer
24798f19ca Add CSV Exceptions 2025-07-25 10:23:52 +09:00
Clemens Schwaighofer
26f8249187 v0.16.0: trackeback call stack reader method fix 2025-07-24 10:53:44 +09:00
Clemens Schwaighofer
dcefa564da Fix stack stack traceback call
It now works correct with start and skip_last settings, the method is now called "call_stack"

Also added auto reset if no output (start too hight) and optional stack separator
2025-07-24 10:52:39 +09:00
Clemens Schwaighofer
edd35dccea Comment update for log class 2025-07-22 19:32:21 +09:00
Clemens Schwaighofer
ea527ea60c v0.15.0: Log class update with split up class with Logger sub class for pool/fork/thread worker setup 2025-07-18 14:18:19 +09:00
Clemens Schwaighofer
fd5e1db22b Change Log class and add simple Logger class without the init work
This Logger class can be used after the main Log class has been setup, eg in workers
inside pool/fork/thread runs

A new parent class holds all the public methods

init Logger class with "get_logger_settings" or with a dictionary Type LoggerInit that has
logger with type logging.Logger as mandatory and optional Queue entry
2025-07-18 14:15:00 +09:00
Clemens Schwaighofer
39e23faf7f dict mask helper test code update 2025-07-17 15:29:49 +09:00
Clemens Schwaighofer
de285b531a ToDo list update 2025-07-17 15:22:12 +09:00
Clemens Schwaighofer
0a29a592f9 v0.14.1: mask update to also work recusrive 2025-07-17 15:20:38 +09:00
Clemens Schwaighofer
e045b1d3b5 Add docstring for pytest file dict helpers 2025-07-17 15:19:05 +09:00
Clemens Schwaighofer
280e5fa861 Update the mask dict helper
It now goes recursive, is case insenstivie for keys and mask keys requests
Checks not for equal but for start/end or inside with edge character set

pytests added
2025-07-17 15:17:57 +09:00
Clemens Schwaighofer
472d3495b5 Add missing typedefs for regex constants 2025-07-17 13:32:35 +09:00
Clemens Schwaighofer
2778ac6870 in Log use the defined default levels for console and file 2025-07-16 11:06:38 +09:00
Clemens Schwaighofer
743a0a8ac9 v0.14.0: logging exception automatically writes to error for console output 2025-07-15 17:57:49 +09:00
Clemens Schwaighofer
694712ed2e logging update for exceptions with special error log to console
When calling log.exception it automatically writes a short message to the console as error
This error message is tagged <=EXCEPTION> and contains only the message without the stack trace
2025-07-15 17:56:56 +09:00
Clemens Schwaighofer
ea3b4f1790 v0.13.2: move domain regex to the regex constant file 2025-07-15 11:16:01 +09:00
Clemens Schwaighofer
da68818d4f Move the domain regex to the regex constant file 2025-07-15 11:13:23 +09:00
Clemens Schwaighofer
db6a3b53c5 v0.13.1: settings loader check additions 2025-07-15 10:33:38 +09:00
Clemens Schwaighofer
82b089498e Merge branch 'development' 2025-07-15 10:33:02 +09:00
Clemens Schwaighofer
948b0dd5e7 Settings loader add more checks
string.domain.with-localhost
string.domain.with-localhost.port
string.domain
string.date
2025-07-15 10:32:19 +09:00
Clemens Schwaighofer
4acc0b51b1 v0.13.0: move the dump data method from the iterator folder to the debug folder 2025-07-15 09:55:25 +09:00
Clemens Schwaighofer
a626b738a9 Move dump_data from iterator folder to debug folder 2025-07-15 09:54:23 +09:00
Clemens Schwaighofer
7119844313 v0.12.6: Settings: exception raised on error point, stacklevel increased for all sub functions in log/settings loader 2025-07-15 09:51:23 +09:00
Clemens Schwaighofer
5763f57830 In settings loader do the raise ValueRror on the error, fix stack level, loggin fix stack level
Settings loader: all errors are thrown where the error happens and not in the print function
The print function if to log will add +1 to the stack level so the error is shown

In the log class in the log wrapper calls add +1 to the stack level to have the error line in the correct place
-> this fixes the stack trace part for now but we still want to have an auto full stack trace simple added
2025-07-15 09:44:29 +09:00
Clemens Schwaighofer
70e8ceecce v0.12.5: settings loader allow empty block 2025-07-14 18:15:59 +09:00
Clemens Schwaighofer
acbe1ac692 Settings load add info for future settings/options argument 2025-07-14 18:15:07 +09:00
Clemens Schwaighofer
99bca2c467 Allow settings block to not exist via call setting 2025-07-14 18:14:33 +09:00
Clemens Schwaighofer
b74ed1f30e v0.12.4: settings loader add set default value for empty 2025-07-14 17:22:03 +09:00
Clemens Schwaighofer
8082ab78a1 Merge branch 'development' 2025-07-14 17:21:28 +09:00
Clemens Schwaighofer
c69076f517 Add set default if empty/not set in settings
With new empty: block if just like this set to None if not set (empty), can also be any value,
if list, skip setting default
2025-07-14 17:21:04 +09:00
Clemens Schwaighofer
648ab001b6 Settings loader fix for not set range check entries
If we have a range or length check and the value is not set, skip, and do not convert either
Not set is None
2025-07-14 17:00:25 +09:00
Clemens Schwaighofer
447034046e v0.12.3: settings loader error message improvement 2025-07-14 16:50:36 +09:00
Clemens Schwaighofer
0770ac0bb4 Better error handling in the settings loader for entry not found in block 2025-07-14 16:49:37 +09:00
Clemens Schwaighofer
aa2fbd4f70 v0.12.2: Fix mandatory for settings loader 2025-07-14 16:25:21 +09:00
Clemens Schwaighofer
58c8447531 Settings loader mandatory fixes
- mandatory empty check if empty list ([''])
- skip regex check if replace value is None -> allowed empty as empty if not mandatory
2025-07-14 16:23:55 +09:00
Clemens Schwaighofer
bcca43d774 v0.12.1: settings loader update, regex constants added 2025-07-14 16:01:54 +09:00
Clemens Schwaighofer
e9ccfe7ad2 Rebame the regex constants file name to not have compiled inside the name 2025-07-14 15:59:34 +09:00
Clemens Schwaighofer
6c2637ad34 Settings loader update with basic email check, and on check abort if not valid
In the settings checker, if a regex_clean is set as None then we will abort the script with error
if the regex is not matching

Add regex check for email basic

Also add a regex_constants list with regex entries (not compiled and compiled)
2025-07-14 15:57:19 +09:00
Clemens Schwaighofer
7183d05dd6 Update log method documentation 2025-07-14 14:29:42 +09:00
Clemens Schwaighofer
b45ca85cd3 v0.12.0: log updates, traceback helper 2025-07-11 19:10:10 +09:00
Clemens Schwaighofer
4ca45ebc73 Move var helpers into their own file, log update with additional levels
Add levels for ALERT, EMERGENCY to be syslog compatible
Add direct wrappers for all, but they are not yet fully usable because the stack fix is not yet implemented

Add a new debug helepr to get the stack as a string
2025-07-11 19:09:22 +09:00
Clemens Schwaighofer
6902768fed Make sure in log that adding handlers only works before logging is initialized
For future: if we add handlers later, the queue / logger must be re-intialized
2025-07-11 15:50:17 +09:00
Clemens Schwaighofer
3f9f2ceaac Log level setter now uses LoggingLevel for levels, set/get log level
Add flush for queue flushing

Add set/get level for handler

Allow adding handlers during launch, handlers cannot be added afterwards at the moment

Add testing for LoggingLevel enum
2025-07-11 15:35:34 +09:00
Clemens Schwaighofer
2a248bd249 v0.11.0 Release 2025-07-11 11:15:05 +09:00
Clemens Schwaighofer
c559a6bafb Move list_dict_handling to iterator_handling, add settings parser, add list helpers, add some string helpers
list_helpers:
convert to list, any input, output is always a list
compare to lists, check what elements from A are not in B, type safe

string helpers
add is_int, is_float checker
add string to bool converter for true/True/false/False strings

config reader with parsing and checking
The simple config reader is now in the corelibs with the basic content check, convert to list for entries, convert to value for entries, etc

log updates:
Add Log type Enum for better log level checks and convert
Add a get int for requested log level, and return default if not found
Make the validate log level a static function

Add tests for list helpers and new string helpers
2025-07-11 10:58:35 +09:00
Clemens Schwaighofer
19d7e9b5ed Make critical errors as red bold for logging to console 2025-07-10 14:34:06 +09:00
Clemens Schwaighofer
3e5a5accf7 Intigration test folder 2025-07-10 13:58:52 +09:00
Clemens Schwaighofer
424c91945a Update log and add Log Enum for Logging levels, add log level as in from Log
Add all logging levels as Enum class type (same as for the PHP CoreLibs Logging)

Add a new method to get the log level as int with fallback via the LoggingLevel Enum
2025-07-10 13:43:35 +09:00
Clemens Schwaighofer
c657dc564e Log: Make the validate log level a static function 2025-07-09 18:54:03 +09:00
Clemens Schwaighofer
208f002284 Set pyproject version 2025-07-09 17:17:26 +09:00
Clemens Schwaighofer
084ecc01e0 Bug fix for logging queue listener and ignoring of per handler set levels
in the QueueLister launcher add "respect_handler_level=True" so it respects the previous set log levels per handler

Also split all logging tests into their own file
2025-07-09 17:14:48 +09:00
Clemens Schwaighofer
08cb994d8d PyProject version update, testing logging 2025-07-09 16:16:02 +09:00
Clemens Schwaighofer
67f1a6688d Update Colors to have static colors that we can reset to 2025-07-09 15:22:31 +09:00
Clemens Schwaighofer
efb7968e93 time zone general names: upper case for constant 2025-07-09 15:09:12 +09:00
Clemens Schwaighofer
fe7c7db004 Change all __class__ to self for class global var 2025-07-09 15:06:27 +09:00
Clemens Schwaighofer
79d1ccae9a Readme file update 2025-07-09 14:48:54 +09:00
Clemens Schwaighofer
6e69af4aa8 Update pyproject.toml with new version 2025-07-09 14:45:53 +09:00
Clemens Schwaighofer
d500b7d473 Log update with listener Queues and color highlight for console
enables Queues if multiprocessing.Queue() is set in the "log_queue" setting

Now a logger "Log.init_worker_logging" can be attached to the ProcessPoolExecutor,
the init args must be the log_queue set

example:

```py
with concurrent.futures.ProcessPoolExecutor(
    max_workers=max_forks,
    initializer=Log.init_worker_logging,
    initargs=(log_queue,)
) as executor:
````

Move all settings into a settings argument, the structure is defined in LogSettings.
Default settings are in Log.DEFAULT_LOG_SETTINGS

Only log path and log name are parameters

Color output for console is on default enabled, disable via "console_color_output_enabled"
The complete console output can be stopped with "console_enabled"
2025-07-09 14:41:53 +09:00
Clemens Schwaighofer
ef599a1aad Version update in uv.lock 2025-07-09 09:45:35 +09:00
Clemens Schwaighofer
2d197134f1 log splitter output make length configurable 2025-07-09 09:44:55 +09:00
Clemens Schwaighofer
717080a009 Add Text ANSI colors class 2025-07-09 08:54:29 +09:00
Clemens Schwaighofer
19197c71ff README file name fix 2025-07-08 17:50:33 +09:00
Clemens Schwaighofer
051b93f2d8 TLS update in the Readme file 2025-07-08 17:46:00 +09:00
Clemens Schwaighofer
e04b3598b8 Ingore .coverage file from pytest 2025-07-08 15:59:53 +09:00
Clemens Schwaighofer
b88e0fe564 Add tests for string helpers too, update timestamp strings tests
Fix string helpers calls for some edge cases
2025-07-08 15:58:37 +09:00
Clemens Schwaighofer
060e3b4afe Move test runs into the test-run folder, add TimestampStrings, add basic tests via pytest
pytest added for dev.
Move all the test run python scripts into the "test-run" folder, so we can use the tests/ folder for just tests.

Setup one test for the TimestampStrings class with pytests in tests/unit/string_handling/test_timestamp_strings.py
2025-07-08 14:54:26 +09:00
Clemens Schwaighofer
cd07267475 v0.6.0: Fix src folder name 2025-07-08 10:04:01 +09:00
Clemens Schwaighofer
2fa031f6ee Comment out log handlers until we rebuild the logging class 2025-07-08 10:01:09 +09:00
Clemens Schwaighofer
f38cce1c1d Rename src CoreLibs to corelibs 2025-07-08 09:58:33 +09:00
Clemens Schwaighofer
52dd1e7b73 Fix base folder name, must be lower case 2025-07-08 09:56:43 +09:00
Clemens Schwaighofer
661a182655 Fix __init__.py 2025-07-08 09:47:55 +09:00
Clemens Schwaighofer
d803de312d Change log file formatter order 2025-07-08 09:46:00 +09:00
Clemens Schwaighofer
57a36d64f1 UV install information link 2025-07-04 10:55:10 +09:00
Clemens Schwaighofer
0cc4883fa1 Move the mask from string to dict helpers, add end comment 2025-07-03 14:01:40 +09:00
Clemens Schwaighofer
1eb464dd2c Add string handling helper maks to mask strings 2025-07-03 13:57:39 +09:00
Clemens Schwaighofer
f900a6eab9 Add double byte string format 2025-07-02 18:34:53 +09:00
84 changed files with 5255 additions and 196 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
**/*.egg-info
.mypy_cache/
**/.env
.coverage

View File

@@ -12,15 +12,20 @@ This is a pip package that can be installed into any project and covers the foll
## Current list
- config_handling: simple INI config file data loader with check/convert/etc
- csv_handling: csv dict writer helper
- debug_handling: various debug helpers like data dumper, timer, utilization, etc
- file_handling: crc handling for file content and file names, progress bar
- json_handling: jmespath support and json date support
- list_dict_handling: list and dictionary handling support (search, fingerprinting, etc)
- iterator_handling: list and dictionary handling support (search, fingerprinting, etc)
- logging_handling: extend log and also error message handling
- requests_handling: requests wrapper for better calls with auth headers
- script_handling: pid lock file handling, abort timer
- string_handling: byte format, datetime format, hashing, string formats for numbrers, etc
- string_handling: byte format, datetime format, hashing, string formats for numbrers, double byte string format, etc
## UV setup
uv must be [installed](https://docs.astral.sh/uv/getting-started/installation/)
## How to publish
@@ -35,26 +40,64 @@ explicit = true
```
```sh
uv build --native-tls
uv publish --index egra-gitea --token <gitea token> --native-tls
uv build
uv publish --index egra-gitea --token <gitea token>
```
## Test package
We must set the full index URL here because we run with "--no-project2
We must set the full index URL here because we run with "--no-project"
```sh
uv run --with corelibs --index egra-gitea=https://git.egplusww.jp/api/packages/PyPI/pypi/simple/ --no-project --native-tls -- python -c "import corelibs"
uv run --with corelibs --index egra-gitea=https://git.egplusww.jp/api/packages/PyPI/pypi/simple/ --no-project -- python -c "import corelibs"
```
### Python tests
All python tests are the tests/ folder. They are structured by the source folder layout
run them with
```sh
uv run pytest
```
Get a coverate report
```sh
uv run pytest --cov=corelibs
```
### Other tests
In the test folder other tests are located.
In the test-run folder usage and run tests are located
At the moment only a small test for the "progress" module is set
#### Progress
```sh
uv run --native-tls tests/progress/progress_test.py
uv run test-run/progress/progress_test.py
```
#### Double byte string format
```sh
uv run test-run/double_byte_string_format/double_byte_string_format.py
```
#### Strings helpers
```sh
uv run test-run/timestamp_strings/timestamp_strings.py
```
```sh
uv run test-run/string_handling/string_helpers.py
```
#### Log
```sh
uv run test-run/logging_handling/log.py
```
## How to install in another project
@@ -62,19 +105,23 @@ uv run --native-tls tests/progress/progress_test.py
This will also add the index entry
```sh
uv add corelibs --index egra-gitea=https://git.egplusww.jp/api/packages/PyPI/pypi/simple/ --native-tls
uv add corelibs --index egra-gitea=https://git.egplusww.jp/api/packages/PyPI/pypi/simple/
```
## Python venv setup
In the folder where the script will be located
```sh
uv venv --python 3.13
```
Install all neded dependencies
After clone, run the command below to install all dependenciss
```sh
uv sync
```
## NOTE on TLS problems
> [!warning] TLS problems with Netskope
If the Netskope service is running all uv runs will fail unless either --native-tls is set or the enviroment variable SSL_CERT_FILE is set, see blow
```sh
export SSL_CERT_FILE='/Library/Application Support/Netskope/STAgent/data/nscacert_combined.pem'
```

11
SECURITY.md Normal file
View File

@@ -0,0 +1,11 @@
# Security Policy
This software follows the [Semver 2.0 scheme](https://semver.org/).
## Supported Versions
Only the latest version is supported
## Reporting a Vulnerability
Open a ticket to report a secuirty problem

View File

@@ -1,4 +1,7 @@
# ToDo list
- stub files .pyi
- fix all remaning check errors
- [x] stub files .pyi
- [ ] Add tests for all, we need 100% test coverate
- [x] Log: add custom format for "stack_correct" if set, this will override the normal stack block
- [ ] Log: add rotate for size based
- [ ] All folders and file names need to be revisited for naming and content collection

View File

@@ -1,9 +1,9 @@
# MARK: Project info
[project]
name = "corelibs"
version = "0.3.1"
version = "0.23.0"
description = "Collection of utils for Python scripts"
readme = "ReadMe.md"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"jmespath>=1.0.1",
@@ -25,6 +25,12 @@ explicit = true
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=8.4.1",
"pytest-cov>=6.2.1",
]
# MARK: Python linting
[tool.pyright]
typeCheckingMode = "strict"
@@ -47,6 +53,10 @@ notes = ["FIXME", "TODO"]
notes-rgx = '(FIXME|TODO)(\((TTD-|#)\[0-9]+\))'
[tool.flake8]
max-line-length = 120
ignore = [
"E741", # ignore ambigious variable name
"W504" # Line break occurred after a binary operator [wrong triggered by "or" in if]
]
[tool.pylint.MASTER]
# this is for the tests/etc folders
init-hook='import sys; sys.path.append("src/")'

View File

@@ -1,120 +0,0 @@
"""
A log handler wrapper
"""
import logging.handlers
import logging
from pathlib import Path
from typing import Mapping
class Log:
"""
logger setup
"""
EXCEPTION: int = 60
def __init__(
self,
log_path: Path,
log_name: str,
log_level_console: str = 'WARNING',
log_level_file: str = 'DEBUG',
add_start_info: bool = True
):
logging.addLevelName(Log.EXCEPTION, 'EXCEPTION')
if not log_name.endswith('.log'):
log_path = log_path.with_suffix('.log')
# overall logger settings
self.logger = logging.getLogger(log_name)
# set maximum logging level for all logging output
self.logger.setLevel(logging.DEBUG)
# console logger
self.__console_handler(log_level_console)
# file logger
self.__file_handler(log_level_file, log_path)
# if requests set a start log
if add_start_info is True:
self.break_line('START')
def __filter_exceptions(self, record: logging.LogRecord) -> bool:
return record.levelname != "EXCEPTION"
def __console_handler(self, log_level_console: str = 'WARNING'):
# console logger
if not isinstance(getattr(logging, log_level_console.upper(), None), int):
log_level_console = 'WARNING'
console_handler = logging.StreamHandler()
formatter_console = logging.Formatter(
(
'[%(asctime)s.%(msecs)03d] '
'[%(filename)s:%(funcName)s:%(lineno)d] '
'<%(levelname)s> '
'%(message)s'
),
datefmt="%Y-%m-%d %H:%M:%S",
)
console_handler.setLevel(log_level_console)
# do not show exceptions logs on console
console_handler.addFilter(self.__filter_exceptions)
console_handler.setFormatter(formatter_console)
self.logger.addHandler(console_handler)
def __file_handler(self, log_level_file: str, log_path: Path) -> None:
# file logger
if not isinstance(getattr(logging, log_level_file.upper(), None), int):
log_level_file = 'DEBUG'
file_handler = logging.handlers.TimedRotatingFileHandler(
filename=log_path,
encoding="utf-8",
when="D",
interval=1
)
formatter_file_handler = logging.Formatter(
(
'[%(asctime)s.%(msecs)03d] '
'[%(pathname)s:%(funcName)s:%(lineno)d] '
'[%(name)s:%(process)d] '
'<%(levelname)s> '
'%(message)s'
),
datefmt="%Y-%m-%dT%H:%M:%S",
)
file_handler.setLevel(log_level_file)
file_handler.setFormatter(formatter_file_handler)
self.logger.addHandler(file_handler)
def break_line(self, info: str = "BREAK"):
"""
add a break line as info level
Keyword Arguments:
info {str} -- _description_ (default: {"BREAK"})
"""
self.logger.info("[%s] ================================>", info)
def exception(self, msg: object, *args: object, extra: Mapping[str, object] | None = None) -> None:
"""
log on exceotion level
Args:
msg (object): _description_
*args (object): arguments for msg
extra: Mapping[str, object] | None: extra arguments for the formatting if needed
"""
self.logger.log(Log.EXCEPTION, msg, *args, exc_info=True, extra=extra)
def validate_log_level(self, log_level: str) -> bool:
"""
if the log level is invalid, will erturn false
Args:
log_level (str): _description_
Returns:
bool: _description_
"""
return isinstance(getattr(logging, log_level.upper(), None), int)
# __END__

View File

@@ -0,0 +1,37 @@
"""
List of regex compiled strings that can be used
"""
import re
def compile_re(reg: str) -> re.Pattern[str]:
"""
compile a regex with verbose flag
Arguments:
reg {str} -- _description_
Returns:
re.Pattern[str] -- _description_
"""
return re.compile(reg, re.VERBOSE)
# email regex
EMAIL_BASIC_REGEX: str = r"""
^[A-Za-z0-9!#$%&'*+\-\/=?^_`{|}~][A-Za-z0-9!#$%:\(\)&'*+\-\/=?^_`{|}~\.]{0,63}
@(?!-)[A-Za-z0-9-]{1,63}(?<!-)(?:\.[A-Za-z0-9-]{1,63}(?<!-))*\.[a-zA-Z]{2,6}$
"""
# Domain regex with localhost
DOMAIN_WITH_LOCALHOST_REGEX: str = r"""
^(?:localhost|(?!-)[A-Za-z0-9-]{1,63}(?<!-)(?:\.[A-Za-z0-9-]{1,63}(?<!-))*\.[A-Za-z]{2,})$
"""
# domain regex with loclhost and optional port
DOMAIN_WITH_LOCALHOST_PORT_REGEX: str = r"""
^(?:localhost|(?!-)[A-Za-z0-9-]{1,63}(?<!-)(?:\.[A-Za-z0-9-]{1,63}(?<!-))*\.[A-Za-z]{2,})(?::\d+)?$
"""
# Domain, no localhost
DOMAIN_REGEX: str = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(?:\.[A-Za-z0-9-]{1,63}(?<!-))*\.[A-Za-z]{2,}$"
# __END__

View File

@@ -0,0 +1,565 @@
"""
Load settings file for a certain group
Check data for existing and valid
Additional check for override settings as arguments
"""
import re
import configparser
from typing import Any, Tuple, Sequence, cast
from pathlib import Path
from corelibs.logging_handling.log import Log
from corelibs.iterator_handling.list_helpers import convert_to_list, is_list_in_list
from corelibs.var_handling.var_helpers import is_int, is_float, str_to_bool
from corelibs.config_handling.settings_loader_handling.settings_loader_check import SettingsLoaderCheck
class SettingsLoader:
"""
Settings Loader with Argument parser
"""
# split char
DEFAULT_ELEMENT_SPLIT_CHAR: str = ','
CONVERT_TO_LIST: list[str] = ['str', 'int', 'float', 'bool', 'auto']
def __init__(
self,
args: dict[str, Any],
config_file: Path,
log: 'Log | None' = None,
always_print: bool = False
) -> None:
"""
init the Settings loader
Args:
args (dict): Script Arguments
config_file (Path): config file including path
log (Log | None): Lop class, if set errors are written to this
always_print (bool): Set to true to always print errors, even if Log is available
element_split_char (str): Split character, default is ','
Raises:
ValueError: _description_
"""
self.args = args
self.config_file = config_file
self.log = log
self.always_print = always_print
# config parser, load config file first
self.config_parser: configparser.ConfigParser | None = self.__load_config_file()
# for check settings, abort flag
self.__check_settings_abort: bool = False
# MARK: load settings
def load_settings(
self,
config_id: str,
config_validate: dict[str, list[str]] | None = None,
allow_not_exist: bool = False
) -> dict[str, str]:
"""
neutral settings loader
The settings values on the right side are seen as a list if they have "," inside (see ELEMENT SPLIT CHAR)
but only if the "check:list." is set
for the allowe entries set, each set is "key => checks", check set is "check type:settings"
key: the key name in the settings file
check: check set with the following allowed entries on the left side for type
- mandatory: must be set as "mandatory:yes", if the key entry is missing or empty throws error
- check: see __check_settings for the settings currently available
- matching: a | list of entries where the value has to match too
- in: the right side is another KEY value from the settings where this value must be inside
- split: character to split entries, if set check:list+ must be set if checks are needed
- convert: convert to int, float -> if element is number convert, else leave as is
- empty: convert empty to, if nothing set on the right side then convert to None type
TODO: there should be a config/options argument for general settings
Args:
config_id (str): what block to load
config_validate (dict[str, list[str]]): list of allowed entries sets
allow_not_exist (bool): If set to True, does not throw an error, but returns empty set
Returns:
dict[str, str]: key = value list
"""
# default set entries
entry_set_empty: dict[str, str | None] = {}
# entries that have to be split
entry_split_char: dict[str, str] = {}
# entries that should be converted
entry_convert: dict[str, str] = {}
# all the settings for the config id given
settings: dict[str, dict[str, Any]] = {
config_id: {},
}
if config_validate is None:
config_validate = {}
if self.config_parser is not None:
try:
# load all data as is, validation is done afterwards
settings[config_id] = dict(self.config_parser[config_id])
except KeyError as e:
if allow_not_exist is True:
return {}
raise ValueError(self.__print(
f"[!] Cannot read [{config_id}] block in the {self.config_file}: {e}",
'CRITICAL'
)) from e
try:
for key, checks in config_validate.items():
skip = True
split_char = self.DEFAULT_ELEMENT_SPLIT_CHAR
# if one is set as list in check -> do not skip, but add to list
for check in checks:
if check.startswith("convert:"):
try:
[_, convert_to] = check.split(":")
if convert_to not in self.CONVERT_TO_LIST:
raise ValueError(self.__print(
f"[!] In [{config_id}] the convert type is invalid {check}: {convert_to}",
'CRITICAL'
))
entry_convert[key] = convert_to
except ValueError as e:
raise ValueError(self.__print(
f"[!] In [{config_id}] the convert type setup for entry failed: {check}: {e}",
'CRITICAL'
)) from e
if check.startswith('empty:'):
try:
[_, empty_set] = check.split(":")
if not empty_set:
empty_set = None
entry_set_empty[key] = empty_set
except ValueError as e:
print(f"VALUE ERROR: {key}")
raise ValueError(self.__print(
f"[!] In [{config_id}] the empty set type for entry failed: {check}: {e}",
'CRITICAL'
)) from e
# split char, also check to not set it twice, first one only
if check.startswith("split:") and not entry_split_char.get(key):
try:
[_, split_char] = check.split(":")
if len(split_char) == 0:
self.__print(
(
f"[*] In [{config_id}] the [{key}] split char character is empty, "
f"fallback to: {self.DEFAULT_ELEMENT_SPLIT_CHAR}"
),
"WARNING"
)
split_char = self.DEFAULT_ELEMENT_SPLIT_CHAR
entry_split_char[key] = split_char
skip = False
except ValueError as e:
raise ValueError(self.__print(
f"[!] In [{config_id}] the split character setup for entry failed: {check}: {e}",
'CRITICAL'
)) from e
if skip:
continue
settings[config_id][key] = [
__value.replace(" ", "")
for __value in settings[config_id][key].split(split_char)
]
except KeyError as e:
raise ValueError(self.__print(
f"[!] Cannot read [{config_id}] block because the entry [{e}] could not be found",
'CRITICAL'
)) from e
else:
# ignore error if arguments are set
if not self.__check_arguments(config_validate, True):
raise ValueError(self.__print(f"[!] Cannot find file: {self.config_file}", 'CRITICAL'))
else:
# base set
settings[config_id] = {}
# make sure all are set
# if we have arguments set, this override config settings
error: bool = False
for entry, validate in config_validate.items():
# if we have command line option set, this one overrides config
if self.__get_arg(entry):
self.__print(f"[*] Command line option override for: {entry}", 'WARNING')
settings[config_id][entry] = self.args.get(entry)
# validate checks
for check in validate:
# CHECKS
# - mandatory
# - check: regex check (see SettingsLoaderCheck class for entries)
# - matching: entry in given list
# - in: entry in other setting entry list
# - length: for string length
# - range: for int/float range check
# mandatory check
if check == "mandatory:yes" and (
not settings[config_id].get(entry) or settings[config_id].get(entry) == ['']
):
error = True
self.__print(f"[!] Missing content entry for: {entry}", 'ERROR')
# skip if empty none
if settings[config_id].get(entry) is None:
continue
if check.startswith("check:"):
# replace the check and run normal checks
settings[config_id][entry] = self.__check_settings(
check, entry, settings[config_id][entry]
)
if self.__check_settings_abort is True:
error = True
elif check.startswith("matching:"):
checks = check.replace("matching:", "").split("|")
if __result := is_list_in_list(convert_to_list(settings[config_id][entry]), list(checks)):
error = True
self.__print(f"[!] [{entry}] '{__result}' not matching {checks}", 'ERROR')
elif check.startswith("in:"):
check = check.replace("in:", "")
# skip if check does not exist, and set error
if settings[config_id].get(check) is None:
error = True
self.__print(f"[!] [{entry}] '{check}' target does not exist", 'ERROR')
continue
# entry must be in check entry
# in for list, else equal with convert to string
if (
__result := is_list_in_list(
convert_to_list(settings[config_id][entry]),
__checks := convert_to_list(settings[config_id][check])
)
):
self.__print(f"[!] [{entry}] '{__result}' must be in the '{__checks}' values list", 'ERROR')
error = True
elif check.startswith('length:'):
check = check.replace("length:", "")
# length can be: n, n-, n-m, -m
# as: equal, >= >=< =<
self.__build_from_to_equal(entry, check)
if not self.__length_range_validate(
entry,
'length',
cast(list[str], convert_to_list(settings[config_id][entry])),
self.__build_from_to_equal(entry, check, convert_to_int=True)
):
error = True
elif check.startswith('range:'):
check = check.replace("range:", "")
if not self.__length_range_validate(
entry,
'range',
cast(list[str], convert_to_list(settings[config_id][entry])),
self.__build_from_to_equal(entry, check)
):
error = True
# after post clean up if we have empty entries and we are mandatory
if check == "mandatory:yes" and (
not settings[config_id].get(entry) or settings[config_id].get(entry) == ['']
):
error = True
self.__print(f"[!] Missing content entry for: {entry}", 'ERROR')
if error is True:
raise ValueError(self.__print("[!] Missing or incorrect settings data. Cannot proceed", 'CRITICAL'))
# set empty
for [entry, empty_set] in entry_set_empty.items():
# if set, skip, else set to empty value
if settings[config_id].get(entry) or isinstance(settings[config_id].get(entry), list):
continue
settings[config_id][entry] = empty_set
# Convert input
for [entry, convert_type] in entry_convert.items():
if convert_type in ["int", "any"] and is_int(settings[config_id][entry]):
settings[config_id][entry] = int(settings[config_id][entry])
elif convert_type in ["float", "any"] and is_float(settings[config_id][entry]):
settings[config_id][entry] = float(settings[config_id][entry])
elif convert_type in ["bool", "any"] and (
settings[config_id][entry] == "true" or
settings[config_id][entry] == "True" or
settings[config_id][entry] == "false" or
settings[config_id][entry] == "False"
):
try:
settings[config_id][entry] = str_to_bool(settings[config_id][entry])
except ValueError:
self.__print(
f"[!] Could not convert to boolean for '{entry}': {settings[config_id][entry]}",
'ERROR'
)
# string is always string
# TODO: empty and int/float/bool: set to none?
return settings[config_id]
# MARK: build from/to/requal logic
def __build_from_to_equal(
self, entry: str, check: str, convert_to_int: bool = False
) -> Tuple[float | None, float | None, float | None]:
"""
split out the "n-m" part to get the to/from/equal
Arguments:
entry {str} -- _description_
check {str} -- _description_
Returns:
Tuple[float | None, float | None, float | None] -- _description_
Throws:
ValueError if range/length entries are not float
"""
__from = None
__to = None
__equal = None
try:
[__from, __to] = check.split('-')
if (__from and not is_float(__from)) or (__to and not is_float(__to)):
raise ValueError(self.__print(
f"[{entry}] Check value for length is not in: {check}",
'CRITICAL'
))
if len(__from) == 0:
__from = None
if len(__to) == 0:
__to = None
except ValueError as e:
if not is_float(__equal := check):
raise ValueError(self.__print(
f"[{entry}] Check value for length is not a valid integer: {check}",
'CRITICAL'
)) from e
if len(__equal) == 0:
__equal = None
# makre sure this is all int or None
if __from is not None:
__from = int(__from) if convert_to_int else float(__from)
if __to is not None:
__to = int(__to) if convert_to_int else float(__to)
if __equal is not None:
__equal = int(__equal) if convert_to_int else float(__equal)
return (
__from,
__to,
__equal
)
# MARK: length/range validation
def __length_range_validate(
self,
entry: str,
check_type: str,
values: Sequence[str | int | float],
check: Tuple[float | None, float | None, float | None],
) -> bool:
(__from, __to, __equal) = check
valid = True
for value_raw in convert_to_list(values):
# skip no tset values for range check
if not value_raw:
continue
value = 0
error_mark = ''
if check_type == 'length':
error_mark = 'length'
value = len(str(value_raw))
elif check_type == 'range':
error_mark = 'range'
value = float(str(value_raw))
if __equal is not None and value != __equal:
self.__print(f"[!] [{entry}] '{value_raw}' {error_mark} does not match {__equal}", 'ERROR')
valid = False
continue
if __from is not None and __to is None and value < __from:
self.__print(f"[!] [{entry}] '{value_raw}' {error_mark} smaller than minimum {__from}", 'ERROR')
valid = False
continue
if __from is None and __to is not None and value > __to:
self.__print(f"[!] [{entry}] '{value_raw}' {error_mark} larger than maximum {__to}", 'ERROR')
valid = False
continue
if __from is not None and __to is not None and (
value < __from or value > __to
):
self.__print(
f"[!] [{entry}] '{value_raw}' {error_mark} outside valid range {__from} to {__to}",
'ERROR'
)
valid = False
continue
return valid
# MARK: load config file data from file
def __load_config_file(self) -> configparser.ConfigParser | None:
"""
load and parse the config file
if not loadable return None
"""
# remove file name and get base path and check
if not self.config_file.parent.is_dir():
raise ValueError(f"Cannot find the config folder: {self.config_file.parent}")
config = configparser.ConfigParser()
if self.config_file.is_file():
config.read(self.config_file)
return config
return None
# MARK: regex clean up one
def __clean_invalid_setting(
self,
entry: str,
validate: str,
value: str,
regex: str,
regex_clean: str | None,
replace: str = "",
print_error: bool = True,
) -> str:
"""
check is a string is invalid, print optional error message and clean up string
Args:
entry (str): what entry key
validate (str): validate type
value (str): the value to check against
regex (str): regex used for checking as r'...'
regex_clean (str): regex used for cleaning as r'...'
replace (str): replace with character. Defaults to ''
print_error (bool): print the error message. Defaults to True
"""
check = re.compile(regex, re.VERBOSE)
clean: re.Pattern[str] | None = None
if regex_clean is not None:
clean = re.compile(regex_clean, re.VERBOSE)
# value must be set if clean is None, else empty value is allowed and will fail
if (clean is None and value or clean) and not check.search(value):
self.__print(
f"[!] Invalid content for '{entry}' with check '{validate}' and data: {value}",
'ERROR', print_error
)
# clean up if clean up is not none, else return EMPTY string
if clean is not None:
return clean.sub(replace, value)
self.__check_settings_abort = True
return ''
# else return as is
return value
# MARK: check settings, regx
def __check_settings(
self,
check: str, entry: str, setting_value: list[str] | str
) -> list[str] | str:
"""
check each setting valid
The settings are defined in the SettingsLoaderCheck class
Args:
check (str): What check to run
entry (str): Variable name, just for information message
setting_value (list[str | int] | str | int): settings value data
Returns:
list[str | int] |111 str | int: cleaned up settings value data
"""
check = check.replace("check:", "")
# get the check settings
__check_settings = SettingsLoaderCheck.CHECK_SETTINGS.get(check)
if __check_settings is None:
raise ValueError(self.__print(
f"[{entry}] Cannot get SettingsLoaderCheck.CHECK_SETTINGS for {check}",
'CRITICAL'
))
# reset the abort check
self.__check_settings_abort = False
# either removes or replaces invalid characters in the list
if isinstance(setting_value, list):
# clean up invalid characters
# loop over result and keep only filled (strip empty)
setting_value = [e for e in [
self.__clean_invalid_setting(
entry, check, str(__entry),
__check_settings['regex'], __check_settings['regex_clean'], __check_settings['replace']
)
for __entry in setting_value
] if e]
else:
setting_value = self.__clean_invalid_setting(
entry, check, str(setting_value),
__check_settings['regex'], __check_settings['regex_clean'], __check_settings['replace']
)
# else:
# self.__print(f"[!] Unkown type to check", 'ERROR)
# return data
return setting_value
# MARK: check arguments, for config file load fail
def __check_arguments(self, arguments: dict[str, list[str]], all_set: bool = False) -> bool:
"""
check if ast least one argument is set
Args:
arguments (list[str]): _description_
Returns:
bool: _description_
"""
count_set = 0
count_arguments = 0
has_argument = False
for argument, validate in arguments.items():
# if argument is mandatory add to count, if not mandatory set has "has" to skip error
mandatory = any(entry == "mandatory:yes" for entry in validate)
if not mandatory:
has_argument = True
continue
count_arguments += 1
if self.__get_arg(argument):
has_argument = True
count_set += 1
# for all set, True only if all are set
if all_set is True:
has_argument = count_set == count_arguments
return has_argument
# MARK: get argument from args dict
def __get_arg(self, entry: str) -> Any:
"""
check if an argument entry xists, if None -> returns None else value of argument
Arguments:
entry {str} -- _description_
Returns:
Any -- _description_
"""
if self.args.get(entry) is None:
return None
return self.args.get(entry)
# MARK: error print
def __print(self, msg: str, level: str, print_error: bool = True) -> str:
"""
print out error, if Log class is set then print to log instead
Arguments:
msg {str} -- _description_
level {str} -- _description_
Keyword Arguments:
print_error {bool} -- _description_ (default: {True})
"""
if self.log is not None:
if not Log.validate_log_level(level):
level = 'ERROR'
self.log.logger.log(Log.get_log_level_int(level), msg, stacklevel=2)
if self.log is None or self.always_print:
if print_error:
print(msg)
return msg
# __END__

View File

@@ -0,0 +1,81 @@
"""
Class of checks that can be run on value entries
"""
from typing import TypedDict
from corelibs.check_handling.regex_constants import (
EMAIL_BASIC_REGEX, DOMAIN_WITH_LOCALHOST_REGEX, DOMAIN_WITH_LOCALHOST_PORT_REGEX, DOMAIN_REGEX
)
class SettingsLoaderCheckValue(TypedDict):
"""Settings check entries"""
regex: str
# if None, then on error we exit, eles we clean up data
regex_clean: str | None
replace: str
class SettingsLoaderCheck:
"""
check:<NAME> or check:list+<NAME>
"""
CHECK_SETTINGS: dict[str, SettingsLoaderCheckValue] = {
"int": {
"regex": r"^[0-9]+$",
"regex_clean": r"[^0-9]",
"replace": "",
},
"string.alphanumeric": {
"regex": r"^[a-zA-Z0-9]+$",
"regex_clean": r"[^a-zA-Z0-9]",
"replace": "",
},
"string.alphanumeric.lower.dash": {
"regex": r"^[a-z0-9-]+$",
"regex_clean": r"[^a-z0-9-]",
"replace": "",
},
# A-Z a-z 0-9 _ - . ONLY
# This one does not remove, but replaces with _
"string.alphanumeric.extended.replace": {
"regex": r"^[_.a-zA-Z0-9-]+$",
"regex_clean": r"[^_.a-zA-Z0-9-]",
"replace": "_",
},
# This does a baisc email check, only alphanumeric with special characters
"string.email.basic": {
"regex": EMAIL_BASIC_REGEX,
"regex_clean": None,
"replace": "",
},
# Domain check, including localhost no port
"string.domain.with-localhost": {
"regex": DOMAIN_WITH_LOCALHOST_REGEX,
"regex_clean": None,
"replace": "",
},
# Domain check, with localhost and port
"string.domain.with-localhost.port": {
"regex": DOMAIN_WITH_LOCALHOST_PORT_REGEX,
"regex_clean": None,
"replace": "",
},
# Domain check, no pure localhost allowed
"string.domain": {
"regex": DOMAIN_REGEX,
"regex_clean": None,
"replace": "",
},
# Basic date check, does not validate date itself
"string.date": {
"regex": r"^\d{4}[/-]\d{1,2}[/-]\d{1,2}$",
"regex_clean": None,
"replace": "",
}
}
# __END__

View File

@@ -89,3 +89,5 @@ class CsvWriter:
csv_row[value] = line[key]
self.csv_file_writer.writerow(csv_row)
return True
# __END__

View File

@@ -0,0 +1,75 @@
"""
Various debug helpers
"""
import traceback
import os
import sys
from typing import Tuple, Type
from types import TracebackType
# _typeshed.OptExcInfo
OptExcInfo = Tuple[None, None, None] | Tuple[Type[BaseException], BaseException, TracebackType]
def call_stack(
start: int = 0,
skip_last: int = -1,
separator: str = ' -> ',
reset_start_if_empty: bool = False
) -> str:
"""
get the trace for the last entry
Keyword Arguments:
start {int} -- start, if too might output will empty until reset_start_if_empty is set (default: {0})
skip_last {int} -- how many of the last are skipped, defaults to -1 for current method (default: {-1})
seperator {str} -- add stack separator, if empty defaults to ' -> ' (default: { -> })
reset_start_if_empty {bool} -- if no stack returned because of too high start,
reset to 0 for full read (default: {False})
Returns:
str -- _description_
"""
# stack = traceback.extract_stack()[start:depth]
# how many of the last entries we skip (so we do not get self), default is -1
# start cannot be negative
if skip_last > 0:
skip_last = skip_last * -1
stack = traceback.extract_stack()
__stack = stack[start:skip_last]
# start possible to high, reset start to 0
if not __stack and reset_start_if_empty:
start = 0
__stack = stack[start:skip_last]
if not separator:
separator = ' -> '
# print(f"* HERE: {dump_data(stack)}")
return f"{separator}".join(f"{os.path.basename(f.filename)}:{f.name}:{f.lineno}" for f in __stack)
def exception_stack(
exc_stack: OptExcInfo | None = None,
separator: str = ' -> '
) -> str:
"""
Exception traceback, if no sys.exc_info is set, run internal
Keyword Arguments:
exc_stack {OptExcInfo | None} -- _description_ (default: {None})
separator {str} -- _description_ (default: {' -> '})
Returns:
str -- _description_
"""
if exc_stack is not None:
_, _, exc_traceback = exc_stack
else:
exc_traceback = None
_, _, exc_traceback = sys.exc_info()
stack = traceback.extract_tb(exc_traceback)
if not separator:
separator = ' -> '
# print(f"* HERE: {dump_data(stack)}")
return f"{separator}".join(f"{os.path.basename(f.filename)}:{f.name}:{f.lineno}" for f in stack)
# __END__

View File

@@ -6,7 +6,7 @@ import json
from typing import Any
def dump_data(data: dict[Any, Any] | list[Any] | str | None) -> str:
def dump_data(data: Any, use_indent: bool = True) -> str:
"""
dump formated output from dict/list
@@ -16,6 +16,7 @@ def dump_data(data: dict[Any, Any] | list[Any] | str | None) -> str:
Returns:
str: _description_
"""
return json.dumps(data, indent=4, ensure_ascii=False, default=str)
indent = 4 if use_indent else None
return json.dumps(data, indent=indent, ensure_ascii=False, default=str)
# __END__

View File

@@ -110,5 +110,4 @@ class Timer:
"""
return self._run_time
# __END__

View File

@@ -0,0 +1,23 @@
"""
Exceptions for csv file reading and processing
"""
class NoCsvReader(Exception):
"""
CSV reader is none
"""
class CsvHeaderDataMissing(Exception):
"""
The csv reader returned None as headers, the header column in the csv file is missing
"""
class CompulsoryCsvHeaderCheckFailed(Exception):
"""
raise if the header is not matching to the excpeted values
"""
# __END__

View File

@@ -42,3 +42,5 @@ def file_name_crc(file_path: Path, add_parent_folder: bool = False) -> str:
return str(Path(file_path.parent.name).joinpath(file_path.name))
else:
return file_path.name
# __END__

View File

@@ -2,23 +2,40 @@
wrapper around search path
"""
from typing import Any
from typing import Any, TypedDict, NotRequired
from warnings import deprecated
class ArraySearchList(TypedDict):
"""find in array from list search dict"""
key: str
value: str | bool | int | float | list[str | None]
case_sensitive: NotRequired[bool]
@deprecated("Use find_in_array_from_list()")
def array_search(
search_params: list[dict[str, str | bool | list[str | None]]],
search_params: list[ArraySearchList],
data: list[dict[str, Any]],
return_index: bool = False
) -> list[dict[str, Any]]:
"""depreacted, old call order"""
return find_in_array_from_list(data, search_params, return_index)
def find_in_array_from_list(
data: list[dict[str, Any]],
search_params: list[ArraySearchList],
return_index: bool = False
) -> list[dict[str, Any]]:
"""
search in an array of dicts with an array of Key/Value set
search in an list of dicts with an list of Key/Value set
all Key/Value sets must match
Value set can be list for OR match
option: case_senstive: default True
Args:
search_params (list): List of search params in "Key"/"Value" lists with options
data (list): data to search in, must be a list
search_params (list): List of search params in "key"/"value" lists with options
return_index (bool): return index of list [default False]
Raises:
@@ -32,18 +49,20 @@ def array_search(
"""
if not isinstance(search_params, list): # type: ignore
raise ValueError("search_params must be a list")
keys = []
keys: list[str] = []
# check that key and value exist and are set
for search in search_params:
if not search.get('Key') or not search.get('Value'):
if not search.get('key') or not search.get('value'):
raise KeyError(
f"Either Key '{search.get('Key', '')}' or "
f"Value '{search.get('Value', '')}' is missing or empty"
f"Either Key '{search.get('key', '')}' or "
f"Value '{search.get('value', '')}' is missing or empty"
)
# if double key -> abort
if search.get("Key") in keys:
if search.get("key") in keys:
raise KeyError(
f"Key {search.get('Key', '')} already exists in search_params"
f"Key {search.get('key', '')} already exists in search_params"
)
keys.append(str(search['key']))
return_items: list[dict[str, Any]] = []
for si_idx, search_item in enumerate(data):
@@ -55,20 +74,20 @@ def array_search(
# lower case left side
# TODO: allow nested Keys. eg "Key: ["Key a", "key b"]" to be ["Key a"]["key b"]
if search.get("case_sensitive", True) is False:
search_value = search_item.get(str(search['Key']), "").lower()
search_value = search_item.get(str(search['key']), "").lower()
else:
search_value = search_item.get(str(search['Key']), "")
search_value = search_item.get(str(search['key']), "")
# lower case right side
if isinstance(search['Value'], list):
if isinstance(search['value'], list):
search_in = [
str(k).lower()
if search.get("case_sensitive", True) is False else k
for k in search['Value']
]
str(k).lower()
if search.get("case_sensitive", True) is False else k
for k in search['value']
]
elif search.get("case_sensitive", True) is False:
search_in = str(search['Value']).lower()
search_in = str(search['value']).lower()
else:
search_in = search['Value']
search_in = search['value']
# compare check
if (
(
@@ -126,3 +145,5 @@ def value_lookup(haystack: dict[str, str], value: str, raise_on_many: bool = Fal
if raise_on_many is True and len(keys) > 1:
raise ValueError("More than one element found with the same name")
return keys[0]
# __END__

View File

@@ -0,0 +1,85 @@
"""
Dict helpers
"""
from typing import TypeAlias, Union, Dict, List, Any, cast
# definitions for the mask run below
MaskableValue: TypeAlias = Union[str, int, float, bool, None]
NestedDict: TypeAlias = Dict[str, Union[MaskableValue, List[Any], 'NestedDict']]
ProcessableValue: TypeAlias = Union[MaskableValue, List[Any], NestedDict]
def mask(
data_set: dict[str, Any],
mask_keys: list[str] | None = None,
mask_str: str = "***",
mask_str_edges: str = '_',
skip: bool = False
) -> dict[str, Any]:
"""
mask data for output
Checks if mask_keys list exist in any key in the data set either from the start or at the end
Use the mask_str_edges to define how searches inside a string should work. Default it must start
and end with '_', remove to search string in string
Arguments:
data_set {dict[str, str]} -- _description_
Keyword Arguments:
mask_keys {list[str] | None} -- _description_ (default: {None})
mask_str {str} -- _description_ (default: {"***"})
mask_str_edges {str} -- _description_ (default: {"_"})
skip {bool} -- if set to true skip (default: {False})
Returns:
dict[str, str] -- _description_
"""
if skip is True:
return data_set
if mask_keys is None:
mask_keys = ["encryption", "password", "secret"]
else:
# make sure it is lower case
mask_keys = [mask_key.lower() for mask_key in mask_keys]
def should_mask_key(key: str) -> bool:
"""Check if a key should be masked"""
__key_lower = key.lower()
return any(
__key_lower.startswith(mask_key) or
__key_lower.endswith(mask_key) or
f"{mask_str_edges}{mask_key}{mask_str_edges}" in __key_lower
for mask_key in mask_keys
)
def mask_recursive(obj: ProcessableValue) -> ProcessableValue:
"""Recursively mask values in nested structures"""
if isinstance(obj, dict):
return {
key: mask_value(value) if should_mask_key(key) else mask_recursive(value)
for key, value in obj.items()
}
if isinstance(obj, list):
return [mask_recursive(item) for item in obj]
return obj
def mask_value(value: Any) -> Any:
"""Handle masking based on value type"""
if isinstance(value, list):
# Mask each individual value in the list
return [mask_str for _ in cast('list[Any]', value)]
if isinstance(value, dict):
# Recursively process the dictionary instead of masking the whole thing
return mask_recursive(cast('ProcessableValue', value))
# Mask primitive values
return mask_str
return {
key: mask_value(value) if should_mask_key(key) else mask_recursive(value)
for key, value in data_set.items()
}
# __END__

View File

@@ -35,3 +35,5 @@ def dict_hash_crc(data: dict[Any, Any] | list[Any]) -> str:
return hashlib.sha256(
json.dumps(data, sort_keys=True, ensure_ascii=True).encode('utf-8')
).hexdigest()
# __END__

View File

@@ -0,0 +1,47 @@
"""
List type helpers
"""
from typing import Any, Sequence
def convert_to_list(
entry: str | int | float | bool | Sequence[str | int | float | bool | Sequence[Any]]
) -> Sequence[str | int | float | bool | Sequence[Any]]:
"""
Convert any of the non list values (except dictionary) to a list
Arguments:
entry {str | int | float | bool | list[str | int | float | bool]} -- _description_
Returns:
list[str | int | float | bool] -- _description_
"""
if isinstance(entry, list):
return entry
return [entry]
def is_list_in_list(
list_a: Sequence[str | int | float | bool | Sequence[Any]],
list_b: Sequence[str | int | float | bool | Sequence[Any]]
) -> Sequence[str | int | float | bool | Sequence[Any]]:
"""
Return entries from list_a that are not in list_b
Type safe compare
Arguments:
list_a {list[Any]} -- _description_
list_b {list[Any]} -- _description_
Returns:
list[Any] -- _description_
"""
# Create sets of (value, type) tuples
set_a = set((item, type(item)) for item in list_a)
set_b = set((item, type(item)) for item in list_b)
# Get the difference and extract just the values
return [item for item, _ in set_a - set_b]
# __END__

View File

@@ -59,3 +59,5 @@ def build_dict(
dict[str, Any | list[Any] | dict[Any, Any]],
delete_keys_from_set(any_dict, ignore_entries)
)
# __END__

View File

@@ -32,4 +32,6 @@ def jmespath_search(search_data: dict[Any, Any] | list[Any], search_params: str)
raise ValueError(f"Type error for search_params: {excp}") from excp
return search_result
# TODO: compile jmespath setup
# __END__

View File

@@ -3,7 +3,7 @@ json encoder for datetime
"""
from typing import Any
from json import JSONEncoder
from json import JSONEncoder, dumps
from datetime import datetime, date
@@ -28,4 +28,16 @@ def default(obj: Any) -> str | None:
return obj.isoformat()
return None
def json_dumps(data: Any):
"""
wrapper for json.dumps with sure dump without throwing Exceptions
Arguments:
data {Any} -- _description_
Returns:
_type_ -- _description_
"""
return dumps(data, ensure_ascii=False, default=str)
# __END__

View File

@@ -0,0 +1,720 @@
"""
A log handler wrapper
if log_settings['log_queue'] is set to multiprocessing.Queue it will launch with listeners
attach "init_worker_logging" with the set log_queue
"""
import re
import logging.handlers
import logging
from datetime import datetime
import time
from pathlib import Path
import atexit
from typing import MutableMapping, TextIO, TypedDict, Any, TYPE_CHECKING, cast
from corelibs.logging_handling.logging_level_handling.logging_level import LoggingLevel
from corelibs.string_handling.text_colors import Colors
from corelibs.debug_handling.debug_helpers import call_stack, exception_stack
if TYPE_CHECKING:
from multiprocessing import Queue
# MARK: Log settings TypedDict
class LogSettings(TypedDict):
"""log settings, for Log setup"""
log_level_console: LoggingLevel
log_level_file: LoggingLevel
per_run_log: bool
console_enabled: bool
console_color_output_enabled: bool
add_start_info: bool
add_end_info: bool
log_queue: 'Queue[str] | None'
class LoggerInit(TypedDict):
"""for Logger init"""
logger: logging.Logger
log_queue: 'Queue[str] | None'
# MARK: Custom color filter
class CustomConsoleFormatter(logging.Formatter):
"""
Custom formatter with colors for console output
"""
COLORS = {
LoggingLevel.DEBUG.name: Colors.cyan,
LoggingLevel.INFO.name: Colors.green,
LoggingLevel.WARNING.name: Colors.yellow,
LoggingLevel.ERROR.name: Colors.red,
LoggingLevel.CRITICAL.name: Colors.red_bold,
LoggingLevel.ALERT.name: Colors.yellow_bold,
LoggingLevel.EMERGENCY.name: Colors.magenta_bold,
LoggingLevel.EXCEPTION.name: Colors.magenta_bright, # will never be written to console
}
def format(self, record: logging.LogRecord) -> str:
"""
set the color highlight
Arguments:
record {logging.LogRecord} -- _description_
Returns:
str -- _description_
"""
# Add color to levelname for console output
reset = Colors.reset
color = self.COLORS.get(record.levelname, reset)
# only highlight level for basic
if record.levelname in [LoggingLevel.DEBUG.name, LoggingLevel.INFO.name]:
record.levelname = f"{color}{record.levelname}{reset}"
return super().format(record)
# highlight whole line
message = super().format(record)
return f"{color}{message}{reset}"
# TODO: add custom handlers for stack_trace, if not set fill with %(filename)s:%(funcName)s:%(lineno)d
# hasattr(record, 'stack_trace')
# also for something like "context" where we add an array of anything to a message
class CustomHandlerFilter(logging.Filter):
"""
Add a custom handler for filtering
"""
HANDLER_NAME_FILTER_EXCEPTION: str = 'console'
def __init__(self, handler_name: str, filter_exceptions: bool = False):
super().__init__(name=handler_name)
self.handler_name = handler_name
self.filter_exceptions = filter_exceptions
def filter(self, record: logging.LogRecord) -> bool:
# if console and exception do not show
if self.handler_name == self.HANDLER_NAME_FILTER_EXCEPTION and self.filter_exceptions:
return record.levelname != "EXCEPTION"
# if cnosole entry is true and traget file filter
if hasattr(record, 'console') and getattr(record, 'console') is True and self.handler_name == 'file':
return False
return True
# def __filter_exceptions(self, record: logging.LogRecord) -> bool:
# return record.levelname != "EXCEPTION"
# MARK: Parent class
class LogParent:
"""
Parent class with general methods
used by Log and Logger
"""
# spacer lenght characters and the character
SPACER_CHAR: str = '='
SPACER_LENGTH: int = 32
def __init__(self):
self.logger: logging.Logger
self.log_queue: 'Queue[str] | None' = None
self.handlers: dict[str, Any] = {}
# FIXME: we need to add a custom formater to add stack level listing if we want to
# Important note, although they exist, it is recommended to use self.logger.NAME directly
# so that the correct filename, method and row number is set
# for > 50 use logger.log(LoggingLevel.<LEVEL>.value, ...)
# for exception logger.log(LoggingLevel.EXCEPTION.value, ..., execInfo=True)
# MARK: log message
def log(self, level: int, msg: object, *args: object, extra: MutableMapping[str, object] | None = None):
"""log general"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.log(level, msg, *args, extra=extra, stacklevel=2)
# MARK: DEBUG 10
def debug(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""debug"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.debug(msg, *args, extra=extra, stacklevel=2)
# MARK: INFO 20
def info(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""info"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.info(msg, *args, extra=extra, stacklevel=2)
# MARK: WARNING 30
def warning(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""warning"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.warning(msg, *args, extra=extra, stacklevel=2)
# MARK: ERROR 40
def error(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""error"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.error(msg, *args, extra=extra, stacklevel=2)
# MARK: CRITICAL 50
def critical(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""critcal"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.critical(msg, *args, extra=extra, stacklevel=2)
# MARK: ALERT 55
def alert(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""alert"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
# extra_dict = dict(extra)
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.log(LoggingLevel.ALERT.value, msg, *args, extra=extra, stacklevel=2)
# MARK: EMERGECNY: 60
def emergency(self, msg: object, *args: object, extra: MutableMapping[str, object] | None = None) -> None:
"""emergency"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
self.logger.log(LoggingLevel.EMERGENCY.value, msg, *args, extra=extra, stacklevel=2)
# MARK: EXCEPTION: 70
def exception(
self,
msg: object, *args: object, extra: MutableMapping[str, object] | None = None,
log_error: bool = True
) -> None:
"""
log on exceotion level, this is log.exception, but logs with a new level
Args:
msg (object): _description_
*args (object): arguments for msg
extra: Mapping[str, object] | None: extra arguments for the formatting if needed
log_error: (bool): If set to false will not write additional error message for console (Default True)
"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
if extra is None:
extra = {}
extra['stack_trace'] = call_stack(skip_last=2)
extra['exception_trace'] = exception_stack()
# write to console first with extra flag for filtering in file
if log_error:
self.logger.log(
LoggingLevel.ERROR.value,
f"<=EXCEPTION={extra['exception_trace']}> {msg} [{extra['stack_trace']}]",
*args, extra=dict(extra) | {'console': True}, stacklevel=2
)
self.logger.log(LoggingLevel.EXCEPTION.value, msg, *args, exc_info=True, extra=extra, stacklevel=2)
def break_line(self, info: str = "BREAK"):
"""
add a break line as info level
Keyword Arguments:
info {str} -- _description_ (default: {"BREAK"})
"""
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
self.logger.info("[%s] %s>", info, self.SPACER_CHAR * self.SPACER_LENGTH)
# MARK: queue handling
def flush(self, handler_name: str | None = None, timeout: float = 2.0) -> bool:
"""
Flush all pending messages
Keyword Arguments:
handler_name {str | None} -- _description_ (default: {None})
timeout {float} -- _description_ (default: {2.0})
Returns:
bool -- _description_
"""
if not self.log_queue:
return False
try:
# Wait for queue to be processed
start_time = time.time()
while not self.log_queue.empty() and (time.time() - start_time) < timeout:
time.sleep(0.01)
# Flush all handlers or handler given
if handler_name:
try:
self.handlers[handler_name].flush()
except IndexError:
pass
else:
for handler in self.handlers.values():
handler.flush()
except OSError:
return False
return True
def cleanup(self):
"""
cleanup for any open queues in case we have an abort
"""
if not self.log_queue:
return
self.flush()
# Close the queue properly
self.log_queue.close()
self.log_queue.join_thread()
# MARK: log level handling
def set_log_level(self, handler_name: str, log_level: LoggingLevel) -> bool:
"""
set the logging level for a handler
Arguments:
handler {str} -- _description_
log_level {LoggingLevel} -- _description_
Returns:
bool -- _description_
"""
try:
# flush queue befoe changing logging level
self.flush(handler_name)
self.handlers[handler_name].setLevel(log_level.name)
return True
except IndexError:
if self.logger:
self.logger.error('Handler %s not found, cannot change log level', handler_name)
return False
except AttributeError:
if self.logger:
self.logger.error(
'Cannot change to log level %s for handler %s, log level invalid',
LoggingLevel.name, handler_name
)
return False
def get_log_level(self, handler_name: str) -> LoggingLevel:
"""
gettthe logging level for a handler
Arguments:
handler_name {str} -- _description_
Returns:
LoggingLevel -- _description_
"""
try:
return LoggingLevel.from_any(self.handlers[handler_name].level)
except IndexError:
return LoggingLevel.NOTSET
@staticmethod
def validate_log_level(log_level: Any) -> bool:
"""
if the log level is invalid will return false, else return true
Args:
log_level (Any): _description_
Returns:
bool: _description_
"""
try:
_ = LoggingLevel.from_any(log_level).value
return True
except ValueError:
return False
@staticmethod
def get_log_level_int(log_level: Any) -> int:
"""
Return log level as INT
If invalid returns the default log level
Arguments:
log_level {Any} -- _description_
Returns:
int -- _description_
"""
try:
return LoggingLevel.from_any(log_level).value
except ValueError:
return LoggingLevel.from_string(Log.DEFAULT_LOG_LEVEL.name).value
# MARK: Logger
class Logger(LogParent):
"""
The class we can pass on to other clases without re-init the class itself
NOTE: if no queue object is handled over the logging level change might not take immediate effect
"""
def __init__(self, logger_settings: LoggerInit):
LogParent.__init__(self)
self.logger = logger_settings['logger']
self.lg = self.logger
self.l = self.logger
self.handlers = {str(_handler.name): _handler for _handler in self.logger.handlers}
self.log_queue = logger_settings['log_queue']
# MARK: LogSetup class
class Log(LogParent):
"""
logger setup
"""
# spacer lenght characters and the character
SPACER_CHAR: str = '='
SPACER_LENGTH: int = 32
# default logging level
DEFAULT_LOG_LEVEL: LoggingLevel = LoggingLevel.WARNING
DEFAULT_LOG_LEVEL_FILE: LoggingLevel = LoggingLevel.DEBUG
DEFAULT_LOG_LEVEL_CONSOLE: LoggingLevel = LoggingLevel.WARNING
# default settings
DEFAULT_LOG_SETTINGS: LogSettings = {
"log_level_console": DEFAULT_LOG_LEVEL_CONSOLE,
"log_level_file": DEFAULT_LOG_LEVEL_FILE,
"per_run_log": False,
"console_enabled": True,
"console_color_output_enabled": True,
"add_start_info": True,
"add_end_info": False,
"log_queue": None,
}
# MARK: constructor
def __init__(
self,
log_path: Path,
log_name: str,
log_settings: dict[str, 'LoggingLevel | str | bool | None | Queue[str]'] | LogSettings | None = None,
other_handlers: dict[str, Any] | None = None
):
LogParent.__init__(self)
# add new level for alert, emergecny and exception
logging.addLevelName(LoggingLevel.ALERT.value, LoggingLevel.ALERT.name)
logging.addLevelName(LoggingLevel.EMERGENCY.value, LoggingLevel.EMERGENCY.name)
logging.addLevelName(LoggingLevel.EXCEPTION.value, LoggingLevel.EXCEPTION.name)
# parse the logging settings
self.log_settings = self.__parse_log_settings(log_settings)
# if path, set log name with .log
# if log name with .log, strip .log for naming
if log_path.is_dir():
__log_file_name = re.sub(r'[^a-zA-Z0-9]', '', log_name)
if not log_name.endswith('.log'):
log_path = log_path.joinpath(Path(__log_file_name).with_suffix('.log'))
else:
log_path = log_path.joinpath(__log_file_name)
elif not log_path.suffix == '.log':
# add .log if the path is a file but without .log
log_path = log_path.with_suffix('.log')
# stip .log from the log name if set
if not log_name.endswith('.log'):
log_name = Path(log_name).stem
# general log name
self.log_name = log_name
self.log_queue: 'Queue[str] | None' = None
self.listener: logging.handlers.QueueListener | None = None
self.logger: logging.Logger
# setup handlers
# NOTE if console with color is set first, some of the color formatting is set
# in the file writer too, for the ones where color is set BEFORE the format
# Any is logging.StreamHandler, logging.FileHandler and all logging.handlers.*
self.handlers: dict[str, Any] = {}
self.add_handler('file_handler', self.__create_file_handler(
'file_handler', self.log_settings['log_level_file'], log_path)
)
if self.log_settings['console_enabled']:
# console
self.add_handler('stream_handler', self.__create_console_handler(
'stream_handler', self.log_settings['log_level_console'])
)
# add other handlers,
if other_handlers is not None:
for handler_key, handler in other_handlers.items():
self.add_handler(handler_key, handler)
# init listener if we have a log_queue set
self.__init_listener(self.log_settings['log_queue'])
# overall logger start
self.__init_log(log_name)
# if requests set a start log
if self.log_settings['add_start_info'] is True:
self.break_line('START')
# MARK: deconstructor
def __del__(self):
"""
Call when class is destroyed, make sure the listender is closed or else we throw a thread error
"""
if self.log_settings['add_end_info']:
self.break_line('END')
self.stop_listener()
# MARK: parse log settings
def __parse_log_settings(
self,
log_settings: dict[str, 'LoggingLevel | str | bool | None | Queue[str]'] | LogSettings | None
) -> LogSettings:
# skip with defaul it not set
if log_settings is None:
return self.DEFAULT_LOG_SETTINGS
# check entries
default_log_settings = self.DEFAULT_LOG_SETTINGS
# check log levels
for __log_entry in ['log_level_console', 'log_level_file']:
if log_settings.get(__log_entry) is None:
continue
# if not valid reset to default, if not in default set to WARNING
if not self.validate_log_level(__log_level := log_settings.get(__log_entry, '')):
__log_level = self.DEFAULT_LOG_SETTINGS.get(
__log_entry, self.DEFAULT_LOG_LEVEL
)
default_log_settings[__log_entry] = LoggingLevel.from_any(__log_level)
# check bool
for __log_entry in [
"per_run_log",
"console_enabled",
"console_color_output_enabled",
"add_start_info",
"add_end_info",
]:
if log_settings.get(__log_entry) is None:
continue
if not isinstance(__setting := log_settings.get(__log_entry, ''), bool):
__setting = self.DEFAULT_LOG_SETTINGS.get(__log_entry, True)
default_log_settings[__log_entry] = __setting
# check log queue
__setting = log_settings.get('log_queue', self.DEFAULT_LOG_SETTINGS['log_queue'])
if __setting is not None:
__setting = cast('Queue[str]', __setting)
default_log_settings['log_queue'] = __setting
return default_log_settings
# def __filter_exceptions(self, record: logging.LogRecord) -> bool:
# return record.levelname != "EXCEPTION"
# MARK: add a handler
def add_handler(
self,
handler_name: str,
handler: Any
) -> bool:
"""
Add a log handler to the handlers dict
Arguments:
handler_name {str} -- _description_
handler {Any} -- _description_
"""
if self.handlers.get(handler_name):
return False
if self.listener is not None or hasattr(self, 'logger'):
raise ValueError(
f"Cannot add handler {handler_name}: {handler.get_name()} because logger is already running"
)
# TODO: handler must be some handler type, how to check?
self.handlers[handler_name] = handler
return True
# MARK: console handler
def __create_console_handler(
self, handler_name: str,
log_level_console: LoggingLevel = LoggingLevel.WARNING, filter_exceptions: bool = True
) -> logging.StreamHandler[TextIO]:
# console logger
if not self.validate_log_level(log_level_console):
log_level_console = self.DEFAULT_LOG_LEVEL_CONSOLE
console_handler = logging.StreamHandler()
# format layouts
format_string = (
'[%(asctime)s.%(msecs)03d] '
'[%(name)s] '
'[%(filename)s:%(funcName)s:%(lineno)d] '
'<%(levelname)s> '
'%(message)s'
)
format_date = "%Y-%m-%d %H:%M:%S"
# color or not
if self.log_settings['console_color_output_enabled']:
formatter_console = CustomConsoleFormatter(format_string, datefmt=format_date)
else:
formatter_console = logging.Formatter(format_string, datefmt=format_date)
console_handler.set_name(handler_name)
console_handler.setLevel(log_level_console.name)
# do not show exceptions logs on console
console_handler.addFilter(CustomHandlerFilter('console', filter_exceptions))
console_handler.setFormatter(formatter_console)
return console_handler
# MARK: file handler
def __create_file_handler(
self, handler_name: str,
log_level_file: LoggingLevel, log_path: Path,
# for TimedRotating, if per_run_log is off
when: str = "D", interval: int = 1, backup_count: int = 0
) -> logging.handlers.TimedRotatingFileHandler | logging.FileHandler:
# file logger
# when: S/M/H/D/W0-W6/midnight
# interval: how many, 1D = every day
# backup_count: how many old to keep, 0 = all
if not self.validate_log_level(log_level_file):
log_level_file = self.DEFAULT_LOG_LEVEL_FILE
if self.log_settings['per_run_log']:
# log path, remove them stem (".log"), then add the datetime and add .log again
now = datetime.now()
# we add microseconds part to get milli seconds
new_stem=f"{log_path.stem}.{now.strftime('%Y-%m-%d_%H-%M-%S')}.{str(now.microsecond)[:3]}"
file_handler = logging.FileHandler(
filename=log_path.with_name(f"{new_stem}{log_path.suffix}"),
encoding="utf-8",
)
else:
file_handler = logging.handlers.TimedRotatingFileHandler(
filename=log_path,
encoding="utf-8",
when=when,
interval=interval,
backupCount=backup_count
)
formatter_file_handler = logging.Formatter(
(
# time stamp
'[%(asctime)s.%(msecs)03d] '
# log name
'[%(name)s] '
# filename + pid
'[%(filename)s:%(process)d] '
# path + func + line number
'[%(pathname)s:%(funcName)s:%(lineno)d] '
# error level
'<%(levelname)s> '
# message
'%(message)s'
),
datefmt="%Y-%m-%dT%H:%M:%S",
)
file_handler.set_name(handler_name)
file_handler.setLevel(log_level_file.name)
# do not show errors flagged with console (they are from exceptions)
file_handler.addFilter(CustomHandlerFilter('file'))
file_handler.setFormatter(formatter_file_handler)
return file_handler
# MARK: init listener
def __init_listener(self, log_queue: 'Queue[str] | None' = None):
"""
If we have a Queue option start the logging queue
Keyword Arguments:
log_queue {Queue[str] | None} -- _description_ (default: {None})
"""
if log_queue is None:
return
self.log_queue = log_queue
atexit.register(self.stop_listener)
self.listener = logging.handlers.QueueListener(
self.log_queue,
*self.handlers.values(),
respect_handler_level=True
)
self.listener.start()
def stop_listener(self):
"""
stop the listener
"""
if self.listener is not None:
self.flush()
self.listener.stop()
# MARK: init main log
def __init_log(self, log_name: str) -> None:
"""
Initialize the main loggger
"""
queue_handler: logging.handlers.QueueHandler | None = None
if self.log_queue is not None:
queue_handler = logging.handlers.QueueHandler(self.log_queue)
# overall logger settings
self.logger = logging.getLogger(log_name)
# add all the handlers
if queue_handler is None:
for handler in self.handlers.values():
self.logger.addHandler(handler)
else:
self.logger.addHandler(queue_handler)
# set maximum logging level for all logging output
# log level filtering is done per handler
self.logger.setLevel(logging.DEBUG)
# short name
self.lg = self.logger
self.l = self.logger
# MARK: init logger for Fork/Thread
@staticmethod
def init_worker_logging(log_queue: 'Queue[str]') -> logging.Logger:
"""
This initalizes a logger that can be used in pool/thread queue calls
call in worker initializer as "Log.init_worker_logging(Queue[str])
"""
queue_handler = logging.handlers.QueueHandler(log_queue)
# getLogger call MUST be WITHOUT and logger name
root_logger = logging.getLogger()
# base logging level, filtering is done in the handlers
root_logger.setLevel(logging.DEBUG)
root_logger.handlers.clear()
root_logger.addHandler(queue_handler)
# for debug only
root_logger.debug('[LOGGER] Init log: %s - %s', log_queue, root_logger.handlers)
return root_logger
def get_logger_settings(self) -> LoggerInit:
"""
get the logger settings we need to init the Logger class
Returns:
LoggerInit -- _description_
"""
return {
"logger": self.logger,
"log_queue": self.log_queue
}
# __END__

View File

@@ -0,0 +1,89 @@
"""
All logging levels
"""
import logging
from typing import Any
from enum import Enum
class LoggingLevel(Enum):
"""
Log class levels
"""
NOTSET = logging.NOTSET # 0
DEBUG = logging.DEBUG # 10
INFO = logging.INFO # 20
WARNING = logging.WARNING # 30
ERROR = logging.ERROR # 40
CRITICAL = logging.CRITICAL # 50
ALERT = 55 # 55 (for Sys log)
EMERGENCY = 60 # 60 (for Sys log)
EXCEPTION = 70 # 70 (manualy set, error but with higher level)
# Alternative names
WARN = logging.WARN # 30 (alias for WARNING)
FATAL = logging.FATAL # 50 (alias for CRITICAL)
@classmethod
def from_string(cls, level_str: str):
"""Convert string to LogLevel enum"""
try:
return cls[level_str.upper()]
except KeyError as e:
raise ValueError(f"Invalid log level: {level_str}") from e
except AttributeError as e:
raise ValueError(f"Invalid log level: {level_str}") from e
@classmethod
def from_int(cls, level_int: int):
"""Convert integer to LogLevel enum"""
try:
return cls(level_int)
except ValueError as e:
raise ValueError(f"Invalid log level: {level_int}") from e
@classmethod
def from_any(cls, level_any: Any):
"""
Convert any vale
if self LoggingLevel return as is, else try to convert from int or string
Arguments:
level_any {Any} -- _description_
Returns:
_type_ -- _description_
"""
if isinstance(level_any, LoggingLevel):
return level_any
if isinstance(level_any, int):
return cls.from_int(level_any)
return cls.from_string(level_any)
def to_logging_level(self):
"""Convert to logging module level"""
return self.value
def to_lower_case(self):
"""return loser case"""
return self.name.lower()
def __str__(self):
return self.name
def includes(self, level: 'LoggingLevel'):
"""
if given level is included in set level
eg: INFO set, ERROR is included in INFO because INFO level would print ERROR
"""
return self.value <= level.value
def is_higher_than(self, level: 'LoggingLevel'):
"""if given value is higher than set"""
return self.value > level.value
def is_lower_than(self, level: 'LoggingLevel'):
"""if given value is lower than set"""
return self.value < level.value
# __END__

0
src/corelibs/py.typed Normal file
View File

View File

@@ -0,0 +1,20 @@
"""
Various HTTP auth helpers
"""
from base64 import b64encode
def basic_auth(username: str, password: str) -> str:
"""
setup basic auth, for debug
Arguments:
username {str} -- _description_
password {str} -- _description_
Returns:
str -- _description_
"""
token = b64encode(f"{username}:{password}".encode('utf-8')).decode("ascii")
return f'Basic {token}'

View File

@@ -93,3 +93,5 @@ def unlock_run(lock_file: Path) -> None:
lock_file.unlink()
except IOError as e:
raise IOError(f"Cannot remove lock_file: {lock_file}: {e}") from e
# __END__

View File

View File

@@ -60,5 +60,4 @@ def create_time(timestamp: float, timestamp_format: str = "%Y-%m-%d %H:%M:%S") -
"""
return time.strftime(timestamp_format, time.localtime(timestamp))
# __END__

View File

@@ -0,0 +1,226 @@
"""
Format double byte strings to exact length
"""
import unicodedata
class DoubleByteFormatString:
"""
Format a string to exact length
"""
def __init__(
self,
string: str,
cut_length: int,
format_length: int | None = None,
placeholder: str = '..',
format_string: str = '{{:<{len}}}'
):
"""
shorts a string to exact cut length and sets it to format length
after "cut_length" cut the "placeholder" will be added, so that the new cut_length is never
larget than the cut_length given (".." is counted to cut_length)
if format_length if set and outside format_length will be set
the cut_length is adjusted to format_length if the format_length is shorter
Example
"Foo bar baz" 10 charcters -> 5 cut_length -> 10 format_length
"Foo.. "
use class.get_string_short() for cut length shortend string
use class.get_string_short_formated() to get the shorted string to format length padding
creates a class that shortens and sets the format length
to use with a print format run the format needs to be pre set in
the style of {{:<{len}}} style
self.get_string_short_formated() for the "len" parameter
Args:
string (str): string to work with
cut_length (int): width to shorten to
format_length (int | None): format length. Defaults to None
placeholder (str, optional): placeholder to put after shortened string. Defaults to '..'.
format_string (str, optional): format string. Defaults to '{{:<{len}}}'
"""
# output variables
self.string_short: str = ''
self.string_width_value: int = 0
self.string_short_width: int = 0
self.format_length_value: int = 0
# internal varaibles
self.placeholder: str = placeholder
# original string
self.string: str = ''
# width to cut string to
self.cut_length: int = 0
# format length to set to
self.format_length: int = 0
# main string
self.string = str(string)
self.format_string: str = format_string
# if width is > 0 set, else set width of string (fallback)
if cut_length > 0:
self.cut_length = cut_length
elif cut_length <= 0:
self.cut_length = self.__string_width_calc(self.string)
# format length set, if not set or smaller than 0, set to width of string
self.format_length = self.cut_length
if format_length is not None and format_length > 0:
self.format_length = format_length
# check that width is not larger then length if yes, set width to length
self.cut_length = min(self.cut_length, self.format_length)
# process the string shorten and format length calculation
self.process()
def process(self):
"""
runs all the class methods to set string length, the string shortened
and the format length
"""
# call the internal ones to set the data
if self.string:
self.__string_width()
self.__shorten_string()
if self.format_length:
self.__format_length()
def get_string_short(self) -> str:
"""
get the shortend string
Returns:
str -- _description_
"""
return self.string_short
def get_string_short_formated(self, format_string: str = '{{:<{len}}}') -> str:
"""
get the formatted string
Keyword Arguments:
format_string {_type_} -- _description_ (default: {'{{:<{len}}}'})
Returns:
str -- _description_
"""
if not format_string:
format_string = self.format_string
return format_string.format(
len=self.get_format_length()
).format(
self.get_string_short()
)
def get_format_length(self) -> int:
"""
get the format length for outside length set
Returns:
int -- _description_
"""
return self.format_length_value
def get_cut_length(self) -> int:
"""
get the actual cut length
Returns:
int -- _description_
"""
return self.cut_length
def get_requested_cut_length(self) -> int:
"""
get the requested cut length
Returns:
int -- _description_
"""
return self.cut_length
def get_requested_format_length(self) -> int:
"""
get the requested format length
Returns:
int -- _description_
"""
return self.format_length
def __string_width_calc(self, string: str) -> int:
"""
does the actual string width calculation
Args:
string (str): string to calculate from
Returns:
int: stringth width
"""
return sum(1 + (unicodedata.east_asian_width(c) in "WF") for c in string)
def __string_width(self):
"""
calculates the string width based on the characters
this is an internal method and should not be called on itself
"""
# only run if string is set and is valid string
if self.string:
# calculate width. add +1 for each double byte character
self.string_width_value = self.__string_width_calc(self.string)
def __format_length(self):
"""
set the format length based on the length for the format
and the shortend string
this is an internal method and should not be called on itself
"""
if not self.string_short:
self.__shorten_string()
# get correct format length based on string
if (
self.string_short and
self.format_length > 0 and
self.string_short_width > 0
):
# length: format length wanted
# substract the width of the shortend string - the length of the shortend string
self.format_length_value = self.format_length - (self.string_short_width - len(self.string_short))
else:
# if we have nothing to shorten the length, keep the old one
self.format_length_value = self.format_length
def __shorten_string(self):
"""
shorten string down to set width
this is an internal method and should not be called on itself
"""
# set string width if not set
if not self.string_width_value:
self.__string_width()
# if the double byte string width is larger than the wanted width
if self.string_width_value > self.cut_length:
cur_len = 0
self.string_short = ''
for char in str(self.string):
# set the current length if we add the character
cur_len += 2 if unicodedata.east_asian_width(char) in "WF" else 1
# if the new length is smaller than the output length to shorten too add the char
if cur_len <= (self.cut_length - len(self.placeholder)):
self.string_short += char
self.string_short_width = cur_len
# return string with new width and placeholder
self.string_short = f"{self.string_short}{self.placeholder}"
self.string_short_width += len(self.placeholder)
else:
# if string is same saze just copy
self.string_short = self.string
# __END__

View File

@@ -36,3 +36,5 @@ def sha1_short(string: str) -> str:
str -- _description_
"""
return hashlib.sha1(string.encode('utf-8')).hexdigest()[:9]
# __END__

View File

@@ -2,16 +2,19 @@
String helpers
"""
from decimal import Decimal, getcontext
from textwrap import shorten
def shorten_string(string: str, length: int, hard_shorten: bool = False, placeholder: str = " [~]") -> str:
def shorten_string(
string: str | int | float, length: int, hard_shorten: bool = False, placeholder: str = " [~]"
) -> str:
"""
check if entry is too long and cut it, but only for console output
Note that if there are no spaces in the string, it will automatically use the hard split mode
Args:
string (str): _description_
string (str | int | float): _description_
length (int): _description_
hard_shorten (bool): if shorte should be done on fixed string lenght. Default: False
placeholder (str): placeholder string. Default: " [~]"
@@ -19,13 +22,19 @@ def shorten_string(string: str, length: int, hard_shorten: bool = False, placeho
Returns:
str: _description_
"""
length = int(length)
string = str(string)
# if placeholder > lenght
if len(string) > length:
if hard_shorten is True or " " not in string:
# hard shorten error
if len(placeholder) > length:
raise ValueError(f"Cannot shorten string: placeholder {placeholder} is too large for max width")
short_string = f"{string[:(length - len(placeholder))]}{placeholder}"
else:
short_string = shorten(string, width=length, placeholder=placeholder)
try:
short_string = shorten(string, width=length, placeholder=placeholder)
except ValueError as e:
raise ValueError(f"Cannot shorten string: {e}") from e
else:
short_string = string
@@ -66,6 +75,9 @@ def format_number(number: float, precision: int = 0) -> str:
format numbers, current trailing zeros does not work
use {:,} or {:,.f} or {:,.<N>f} <N> = number instead of this
The upper limit of the precision depends on the value of the number itself
very large numbers will have no precision at all any more
Arguments:
number {float} -- _description_
@@ -75,12 +87,18 @@ def format_number(number: float, precision: int = 0) -> str:
Returns:
str -- _description_
"""
if precision < 0 and precision > 100:
if precision < 0 or precision > 100:
precision = 0
if precision > 0:
getcontext().prec = precision
# make it a string to avoid mangling
_number = Decimal(str(number))
else:
_number = number
return (
"{:,."
f"{str(precision)}"
"f}"
).format(number)
).format(_number)
# __END__

View File

@@ -0,0 +1,156 @@
"""
Basic ANSI colors
Set colors with print(f"something {Colors.yellow}colorful{Colors.end})
bold + underline + color combinations are possible.
"""
class Colors:
"""
ANSI colors defined
"""
# General sets, these should not be accessd
__BOLD = '\033[1m'
__UNDERLINE = '\033[4m'
__END = '\033[0m'
__RESET = '\033[0m'
# Define ANSI color codes as class attributes
__BLACK = "\033[30m"
__RED = "\033[31m"
__GREEN = "\033[32m"
__YELLOW = "\033[33m"
__BLUE = "\033[34m"
__MAGENTA = "\033[35m"
__CYAN = "\033[36m"
__WHITE = "\033[37m"
# Define bold/bright versions of the colors
__BLACK_BOLD = "\033[1;30m"
__RED_BOLD = "\033[1;31m"
__GREEN_BOLD = "\033[1;32m"
__YELLOW_BOLD = "\033[1;33m"
__BLUE_BOLD = "\033[1;34m"
__MAGENTA_BOLD = "\033[1;35m"
__CYAN_BOLD = "\033[1;36m"
__WHITE_BOLD = "\033[1;37m"
# BRIGHT, alternative
__BLACK_BRIGHT = '\033[90m'
__RED_BRIGHT = '\033[91m'
__GREEN_BRIGHT = '\033[92m'
__YELLOW_BRIGHT = '\033[93m'
__BLUE_BRIGHT = '\033[94m'
__MAGENTA_BRIGHT = '\033[95m'
__CYAN_BRIGHT = '\033[96m'
__WHITE_BRIGHT = '\033[97m'
# set access vars
bold = __BOLD
underline = __UNDERLINE
end = __END
reset = __RESET
# normal
black = __BLACK
red = __RED
green = __GREEN
yellow = __YELLOW
blue = __BLUE
magenta = __MAGENTA
cyan = __CYAN
white = __WHITE
# bold
black_bold = __BLACK_BOLD
red_bold = __RED_BOLD
green_bold = __GREEN_BOLD
yellow_bold = __YELLOW_BOLD
blue_bold = __BLUE_BOLD
magenta_bold = __MAGENTA_BOLD
cyan_bold = __CYAN_BOLD
white_bold = __WHITE_BOLD
# bright
black_bright = __BLACK_BRIGHT
red_bright = __RED_BRIGHT
green_bright = __GREEN_BRIGHT
yellow_bright = __YELLOW_BRIGHT
blue_bright = __BLUE_BRIGHT
magenta_bright = __MAGENTA_BRIGHT
cyan_bright = __CYAN_BRIGHT
white_bright = __WHITE_BRIGHT
@staticmethod
def disable():
"""
No colors
"""
Colors.bold = ''
Colors.underline = ''
Colors.end = ''
Colors.reset = ''
# normal
Colors.black = ''
Colors.red = ''
Colors.green = ''
Colors.yellow = ''
Colors.blue = ''
Colors.magenta = ''
Colors.cyan = ''
Colors.white = ''
# bold/bright
Colors.black_bold = ''
Colors.red_bold = ''
Colors.green_bold = ''
Colors.yellow_bold = ''
Colors.blue_bold = ''
Colors.magenta_bold = ''
Colors.cyan_bold = ''
Colors.white_bold = ''
# bold/bright alt
Colors.black_bright = ''
Colors.red_bright = ''
Colors.green_bright = ''
Colors.yellow_bright = ''
Colors.blue_bright = ''
Colors.magenta_bright = ''
Colors.cyan_bright = ''
Colors.white_bright = ''
@staticmethod
def reset_colors():
"""
reset colors to the original ones
"""
# set access vars
Colors.bold = Colors.__BOLD
Colors.underline = Colors.__UNDERLINE
Colors.end = Colors.__END
Colors.reset = Colors.__RESET
# normal
Colors.black = Colors.__BLACK
Colors.red = Colors.__RED
Colors.green = Colors.__GREEN
Colors.yellow = Colors.__YELLOW
Colors.blue = Colors.__BLUE
Colors.magenta = Colors.__MAGENTA
Colors.cyan = Colors.__CYAN
Colors.white = Colors.__WHITE
# bold
Colors.black_bold = Colors.__BLACK_BOLD
Colors.red_bold = Colors.__RED_BOLD
Colors.green_bold = Colors.__GREEN_BOLD
Colors.yellow_bold = Colors.__YELLOW_BOLD
Colors.blue_bold = Colors.__BLUE_BOLD
Colors.magenta_bold = Colors.__MAGENTA_BOLD
Colors.cyan_bold = Colors.__CYAN_BOLD
Colors.white_bold = Colors.__WHITE_BOLD
# bright
Colors.black_bright = Colors.__BLACK_BRIGHT
Colors.red_bright = Colors.__RED_BRIGHT
Colors.green_bright = Colors.__GREEN_BRIGHT
Colors.yellow_bright = Colors.__YELLOW_BRIGHT
Colors.blue_bright = Colors.__BLUE_BRIGHT
Colors.magenta_bright = Colors.__MAGENTA_BRIGHT
Colors.cyan_bright = Colors.__CYAN_BRIGHT
Colors.white_bright = Colors.__WHITE_BRIGHT
# __END__

View File

@@ -0,0 +1,112 @@
"""
Current timestamp strings and time zones
"""
import re
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from corelibs.var_handling.var_helpers import is_float
class TimeParseError(Exception):
"""Custom exception for time parsing errors."""
class TimeUnitError(Exception):
"""Custom exception for time parsing errors."""
class TimestampStrings:
"""
set default time stamps
"""
TIME_ZONE: str = 'Asia/Tokyo'
def __init__(self, time_zone: str | None = None):
self.timestamp_now = datetime.now()
self.time_zone = time_zone if time_zone is not None else self.TIME_ZONE
try:
self.timestamp_now_tz = datetime.now(ZoneInfo(self.time_zone))
except ZoneInfoNotFoundError as e:
raise ValueError(f'Zone could not be loaded [{self.time_zone}]: {e}') from e
self.today = self.timestamp_now.strftime('%Y-%m-%d')
self.timestamp = self.timestamp_now.strftime("%Y-%m-%d %H:%M:%S")
self.timestamp_tz = self.timestamp_now_tz.strftime("%Y-%m-%d %H:%M:%S %Z")
self.timestamp_file = self.timestamp_now.strftime("%Y-%m-%d_%H%M%S")
def convert_to_seconds(time_string: str | int | float) -> int:
"""
Conver a string with time units into a seconds string
The following units are allowed
Y: 365 days
M: 30 days
d, h, m, s
Arguments:
time_string {str} -- _description_
Raises:
ValueError: _description_
Returns:
int -- _description_
"""
# skip out if this is a number of any type
# numbers will br made float, rounded and then converted to int
if is_float(time_string):
return int(round(float(time_string)))
time_string = str(time_string)
# Define time unit conversion factors
unit_factors: dict[str, int] = {
'Y': 31536000, # 365 days * 86400 seconds/day
'M': 2592000 * 12, # 1 year in seconds (assuming 365 days per year)
'd': 86400, # 1 day in seconds
'h': 3600, # 1 hour in seconds
'm': 60, # minutes to seconds
's': 1 # 1 second in seconds
}
long_unit_names: dict[str, str] = {
'year': 'Y',
'years': 'Y',
'month': 'M',
'months': 'M',
'day': 'd',
'days': 'd',
'hour': 'h',
'hours': 'h',
'minute': 'm',
'minutes': 'm',
'min': 'm',
'second': 's',
'seconds': 's',
'sec': 's',
}
total_seconds = 0
seen_units: list[str] = [] # Track units that have been encountered
# Use regex to match number and time unit pairs
for match in re.finditer(r'(\d+)\s*([a-zA-Z]+)', time_string):
value, unit = int(match.group(1)), match.group(2)
# full name check, fallback to original name
unit = long_unit_names.get(unit.lower(), unit)
# Check for duplicate units
if unit in seen_units:
raise TimeParseError(f"Unit '{unit}' appears more than once.")
# Check invalid unit
if unit not in unit_factors:
raise TimeUnitError(f"Unit '{unit}' is not a valid unit name.")
# Add to total seconds based on the units
if unit in unit_factors:
total_seconds += value * unit_factors[unit]
seen_units.append(unit)
return total_seconds

View File

View File

@@ -0,0 +1,65 @@
"""
variable convert, check, etc helepr
"""
from typing import Any
def is_int(string: Any) -> bool:
"""
check if a value is int
Arguments:
string {Any} -- _description_
Returns:
bool -- _description_
"""
try:
int(string)
return True
except TypeError:
return False
except ValueError:
return False
def is_float(string: Any) -> bool:
"""
check if a value is float
Arguments:
string {Any} -- _description_
Returns:
bool -- _description_
"""
try:
float(string)
return True
except TypeError:
return False
except ValueError:
return False
def str_to_bool(string: str):
"""
convert string to bool
Arguments:
s {str} -- _description_
Raises:
ValueError: _description_
Returns:
_type_ -- _description_
"""
if string == "True" or string == "true":
return True
if string == "False" or string == "false":
return False
raise ValueError(f"Invalid boolean string: {string}")
# __END__

View File

@@ -0,0 +1,33 @@
[TestA]
foo=bar
foobar=1
bar=st
some_match=foo
some_match_list=foo,bar
test_list=a,b,c,d f, g h
other_list=a|b|c|d|
third_list=xy|ab|df|fg
str_length=foobar
int_range=20
int_range_not_set=
int_range_not_set_empty_set=5
#
match_target=foo
match_target_list=foo,bar,baz
#
match_source_a=foo
match_source_b=foo
; match_source_c=foo
match_source_list=foo,bar
[TestB]
element_a=Static energy
element_b=123.5
element_c=True
email=foo@bar.com,other+bar-fee@domain-com.cp,
email_not_mandatory=
email_bad=gii@bar.com
[LoadTest]
a.b.c=foo
d:e:f=bar

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,125 @@
"""
Settings loader test
"""
import re
from pathlib import Path
from corelibs.debug_handling.dump_data import dump_data
from corelibs.logging_handling.log import Log
from corelibs.config_handling.settings_loader import SettingsLoader
from corelibs.config_handling.settings_loader_handling.settings_loader_check import SettingsLoaderCheck
SCRIPT_PATH: Path = Path(__file__).resolve().parent
ROOT_PATH: Path = SCRIPT_PATH
CONFIG_DIR: Path = Path("config")
CONFIG_FILE: str = "settings.ini"
def main():
"""
Main run
"""
value = "2025/1/1"
regex_c = re.compile(SettingsLoaderCheck.CHECK_SETTINGS['string.date']['regex'], re.VERBOSE)
result = regex_c.search(value)
print(f"regex {regex_c} check against {value} -> {result}")
# for log testing
script_path: Path = Path(__file__).resolve().parent
log = Log(
log_path=script_path.joinpath('log', 'settings_loader.log'),
log_name="Settings Loader",
log_settings={
"log_level_console": 'DEBUG',
"log_level_file": 'DEBUG',
}
)
log.logger.info('Settings loader')
sl = SettingsLoader(
{
'foo': 'OVERLOAD'
},
ROOT_PATH.joinpath(CONFIG_DIR, CONFIG_FILE),
log=log
)
try:
config_load = 'TestA'
config_data = sl.load_settings(
config_load,
{
# "doesnt": ["split:,"],
"foo": ["mandatory:yes"],
"foobar": ["check:int"],
"bar": ["mandatory:yes"],
"some_match": ["matching:foo|bar"],
"some_match_list": ["split:,", "matching:foo|bar"],
"test_list": [
"check:string.alphanumeric",
"split:,"
],
"other_list": ["split:|"],
"third_list": [
"split:|",
"check:string.alphanumeric"
],
"str_length": [
"length:2-10"
],
"int_range": [
"range:2-50"
],
"int_range_not_set": [
"range:2-50"
],
"int_range_not_set_empty_set": [
"empty:"
],
"match_target": ["matching:foo"],
"match_target_list": ["split:,", "matching:foo|bar|baz",],
"match_source_a": ["in:match_target"],
"match_source_b": ["in:match_target_list"],
"match_source_list": ["split:,", "in:match_target_list"],
}
)
print(f"[{config_load}] Load: {config_load} -> {dump_data(config_data)}")
except ValueError as e:
print(f"Could not load settings: {e}")
try:
config_load = 'TestB'
config_data = sl.load_settings(
config_load,
{
"email": [
"split:,",
"mandatory:yes",
"check:string.email.basic"
],
"email_not_mandatory": [
"split:,",
# "mandatory:yes",
"check:string.email.basic"
],
"email_bad": [
"split:,",
"mandatory:yes",
"check:string.email.basic"
]
}
)
print(f"[{config_load}] Load: {config_load} -> {dump_data(config_data)}")
except ValueError as e:
print(f"Could not load settings: {e}")
try:
config_load = 'LoadTest'
config_data = sl.load_settings(config_load)
print(f"[{config_load}] Load: {config_load} -> {dump_data(config_data)}")
except ValueError as e:
print(f"Could not load settings: {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env -S uv run --script
"""
Test for double byte format
"""
from corelibs.string_handling.double_byte_string_format import DoubleByteFormatString
def main():
"""
Main call
"""
string = [
"Some string 123 other text",
"Some string 日本語 other text",
"日本語は string 123 other text",
"あいうえおかきくけこさしすせそなにぬねのまみむめも〜",
"あいうえおかきくけこさしす 1 other text",
"Some string すせそなにぬねのまみむめも〜",
"SOME OTHER STRING THAT IS LONGER THAN TWENTYSIX CHARACTERS",
"日本語は string 123 other text Some string 日本語 other text"
]
format_str = "{{:<{len}}}"
length_set = [
(26, 25),
(26, 26),
(26, 60),
(26, 20),
(26, -5),
(-6, -5),
]
for _length_set in length_set:
cut_length = _length_set[0]
format_length = _length_set[1]
print(f"========= Cut: {cut_length} | Format: {format_length} ==> ")
for _string in string:
string_test = DoubleByteFormatString(_string, cut_length, format_length)
formated = format_str.format(
len=string_test.get_format_length()
).format(
string_test.get_string_short()
)
print(
"* Shorten string: shorten length: "
f"Req: {string_test.get_requested_cut_length()} ({cut_length}) / "
f"Set: {string_test.get_cut_length()}, "
"format length: "
f"Req: {string_test.get_requested_format_length()} ({format_length}) / "
f"Set: {string_test.get_format_length()}"
f"\nOrig: |{_string}|"
f"\nGSS : |{string_test.get_string_short()}|"
f"\nF : |{formated}|"
f"\nGSSF: |{string_test.get_string_short_formated()}|"
)
print("-------")
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Search data tests
iterator_handling.data_search
"""
from corelibs.debug_handling.dump_data import dump_data
from corelibs.iterator_handling.data_search import find_in_array_from_list, ArraySearchList
def main() -> None:
"""
Comment
"""
data = [
{
"lookup_value_p": "A01",
"lookup_value_c": "B01",
"replace_value": "R01",
},
{
"lookup_value_p": "A02",
"lookup_value_c": "B02",
"replace_value": "R02",
},
]
test_foo = ArraySearchList(
key = "lookup_value_p",
value = "A01"
)
print(test_foo)
search: list[ArraySearchList] = [
{
"key": "lookup_value_p",
"value": "A01"
},
{
"key": "lookup_value_c",
"value": "B01"
}
]
result = find_in_array_from_list(data, search)
print(f"Search {dump_data(search)} -> {dump_data(result)}")
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,106 @@
"""
Iterator helper testing
"""
from corelibs.debug_handling.dump_data import dump_data
from corelibs.iterator_handling.dict_helpers import mask
def __mask():
data = {
# "user": "john",
# "encryption_key": "Secret key",
# "ENCRYPTION.TEST": "Secret key test",
# "inside_password_test": "Hide this",
"password": ["secret1", "secret2"], # List value gets masked
# "config": {
# "db_password": {"primary": "secret", "backup": "secret2"}, # Dict value gets masked
# "api_keys": ["key1", "key2", "key3"] # List value gets masked
# },
# "items": [ # List value that doesn't get masked, but gets processed recursively
# {"name": "item1", "secret_key": "itemsecret"},
# {"name": "item2", "passwords": ["pass1", "pass2"]}
# ],
# "normal_list": ["item1", "item2", "item3"] # Normal list, not masked
}
data = {
"config": {
# "password": ["secret1", "secret2"],
# "password_other": {"password": ["secret1", "secret2"]},
# "database": {
# "host": "localhost",
# "password": "db_secret",
# "users": [
# {"name": "admin", "password": "admin123"},
# {"name": "user", "secret_key": "user456"}
# ]
# },
# "api": {
# # "endpoints": ["api1", "api2"],
# "encryption_settings": {
# "enabled": True,
# "secret": "api_secret"
# }
# }
"secret_key": "normal_value",
"api_key": "normal_value",
"my_key_value": "normal_value",
}
}
data = {
"basic": {
"log_level_console": "DEBUG",
"log_level_file": "DEBUG",
"storage_interface": "sqlite",
"content_start_date": "2023-1-1",
"encryption_key": "ENCRYPTION_KEY"
},
"email": {
"alert_email": [
"test+z-sd@tequila.jp"
]
},
"poller": {
"max_forks": "1",
"interface": "Zac"
},
"pusher": {
"max_forks": "3",
"interface": "Screendragon"
},
"api:Zac": {
"type": "zac",
"client_id": "oro_zac_demo",
"client_secret": "CLIENT_SECRET",
"username": "zacuser",
"password": "ZACuser3",
"hostname": "e-gra2.zac.ai",
"appname": "e-gra2_api_trial",
"api_path": "b/api/v2"
},
"api:Screendragon": {
"type": "screendragon",
"client_id": "omniprostaging",
"encryption_client": "SOME_SECRET",
"client_encryption": "SOME_SECRET",
"secret_client": "SOME_SECRET",
"client_secret": "SOME_SECRET",
"hostname": "omniprostaging.screendragon.com",
"appname": "sdapi",
"api_path": "api"
}
}
result = mask(data)
print(f"** In: {dump_data(data)}")
print(f"===> Masked: {dump_data(result)}")
def main():
"""
Test: corelibs.string_handling.string_helpers
"""
__mask()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
"""
test list helpers
"""
from corelibs.iterator_handling.list_helpers import is_list_in_list, convert_to_list
def __test_is_list_in_list_a():
list_a = [1, "hello", 3.14, True, "world"]
list_b = ["hello", True, 42]
result = is_list_in_list(list_a, list_b)
print(f"RESULT: {result}")
def __convert_list():
source = "hello"
result = convert_to_list(source)
print(f"IN: {source} -> {result}")
def main():
__test_is_list_in_list_a()
__convert_list()
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
jmes path testing
"""
from corelibs.debug_handling.dump_data import dump_data
from corelibs.json_handling.jmespath_helper import jmespath_search
def main() -> None:
"""
Comment
"""
__set = {
'a': 'b',
'foobar': [1, 2, 'a'],
'bar': {
'a': 1,
'b': 'c'
},
'baz': [
{
'aa': 1,
'ab': 'cc'
},
{
'ba': 2,
'bb': 'dd'
},
],
'foo': {
'a': [1, 2, 3],
'b': ['a', 'b', 'c']
}
}
__get = [
'a',
'bar.a',
'foo.a',
'baz[].aa'
]
for __jmespath in __get:
result = jmespath_search(__set, __jmespath)
print(f"GET {__jmespath}: {dump_data(result)}")
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,106 @@
"""
Log logging_handling.log testing
"""
# import atexit
import sys
from pathlib import Path
# this is for testing only
from corelibs.logging_handling.log import Log, Logger
from corelibs.debug_handling.debug_helpers import exception_stack, call_stack
from corelibs.logging_handling.logging_level_handling.logging_level import LoggingLevel
def main():
"""
Log testing
"""
script_path: Path = Path(__file__).resolve().parent
log = Log(
log_path=script_path.joinpath('log', 'test.log'),
log_name="Test Log",
log_settings={
"log_level_console": 'DEBUG',
# "log_level_console": None,
"log_level_file": 'DEBUG',
# "console_color_output_enabled": False,
"per_run_log": True
}
)
logn = Logger(log.get_logger_settings())
log.logger.debug('[NORMAL] Debug test: %s', log.logger.name)
log.lg.debug('[NORMAL] Debug test: %s', log.logger.name)
log.debug('[NORMAL-] Debug test: %s', log.logger.name)
logn.lg.debug('[NORMAL N] Debug test: %s', log.logger.name)
logn.debug('[NORMAL N-] Debug test: %s', log.logger.name)
log.logger.info('[NORMAL] Info test: %s', log.logger.name)
log.info('[NORMAL-] Info test: %s', log.logger.name)
log.logger.warning('[NORMAL] Warning test: %s', log.logger.name)
log.warning('[NORMAL-] Warning test: %s', log.logger.name)
log.logger.error('[NORMAL] Error test: %s', log.logger.name)
log.error('[NORMAL-] Error test: %s', log.logger.name)
log.logger.critical('[NORMAL] Critical test: %s', log.logger.name)
log.critical('[NORMAL-] Critical test: %s', log.logger.name)
log.logger.log(LoggingLevel.ALERT.value, '[NORMAL] alert test: %s', log.logger.name)
log.alert('[NORMAL-] alert test: %s', log.logger.name)
log.emergency('[NORMAL-] emergency test: %s', log.logger.name)
log.logger.log(LoggingLevel.EMERGENCY.value, '[NORMAL] emergency test: %s', log.logger.name)
log.exception('[NORMAL] Exception test: %s', log.logger.name)
log.logger.log(LoggingLevel.EXCEPTION.value, '[NORMAL] exception test: %s', log.logger.name, exc_info=True)
bad_level = 'WRONG'
if not Log.validate_log_level(bad_level):
print(f"Invalid level: {bad_level}")
good_level = 'WARNING'
if Log.validate_log_level(good_level):
print(f"Valid level: {good_level}")
print(f"ERROR is to_logging_level(): {LoggingLevel.ERROR.to_logging_level()}")
print(f"ERROR is to_lower_case(): {LoggingLevel.ERROR.to_lower_case()}")
print(f"ERROR is: {LoggingLevel.ERROR}")
print(f"ERROR is value: {LoggingLevel.ERROR.value}")
print(f"ERROR is name: {LoggingLevel.ERROR.name}")
print(f"ERROR is from_string(lower): {LoggingLevel.from_string('ERROR')}")
print(f"ERROR is from_string(upper): {LoggingLevel.from_string('ERROR')}")
print(f"ERROR is from_int: {LoggingLevel.from_int(40)}")
print(f"ERROR is from_any(text lower): {LoggingLevel.from_any('ERROR')}")
print(f"ERROR is from_any(text upper): {LoggingLevel.from_any('ERROR')}")
print(f"ERROR is from_any(int): {LoggingLevel.from_any(40)}")
print(f"INFO <= ERROR: {LoggingLevel.INFO.includes(LoggingLevel.ERROR)}")
print(f"INFO > ERROR: {LoggingLevel.INFO.is_higher_than(LoggingLevel.ERROR)}")
print(f"INFO < ERROR: {LoggingLevel.INFO.is_lower_than(LoggingLevel.ERROR)}")
print(f"INFO < ERROR: {LoggingLevel.INFO.is_lower_than(LoggingLevel.ERROR)}")
try:
print(f"INVALID is A: {LoggingLevel.from_string('INVALID')}")
except ValueError as e:
print(f"* ERROR: {e}")
try:
__test = 5 / 0
print(f"Divied: {__test}")
except ZeroDivisionError as e:
print(f"** sys.exec_info(): {sys.exc_info()}")
print(f"** sys.exec_info(): [{exception_stack()}] | [{exception_stack(sys.exc_info())}] | [{call_stack()}]")
log.logger.critical("Divison through zero: %s", e)
log.exception("Divison through zero: %s", e)
for handler in log.logger.handlers:
print(
f"** Handler (logger) {handler} [{handler.name}] -> "
f"{handler.level} -> {LoggingLevel.from_any(handler.level)}"
)
for key, handler in log.handlers.items():
print(f"Handler (handlers) [{key}] {handler} -> {handler.level} -> {LoggingLevel.from_any(handler.level)}")
log.set_log_level('stream_handler', LoggingLevel.ERROR)
log.logger.warning('[NORMAL] Invisible Warning test: %s', log.logger.name)
log.logger.error('[NORMAL] Visible Error test: %s', log.logger.name)
# log.handlers['stream_handler'].se
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,91 @@
"""
Pool Queue log handling
Thread Queue log handling
"""
import random
import time
from multiprocessing import Queue
import concurrent.futures
import logging
from pathlib import Path
from corelibs.logging_handling.log import Log
from corelibs.logging_handling.logging_level_handling.logging_level import LoggingLevel
def work_function(log_name: str, worker_id: int, data: list[int]) -> int:
"""
simulate worker
Arguments:
worker_id {int} -- _description_
data {list[int]} -- _description_
Returns:
int -- _description_
"""
log = logging.getLogger(f'{log_name}-WorkerFn-{worker_id}')
log.info('Starting worker: %s', worker_id)
time.sleep(random.uniform(1, 3))
result = sum(data) * worker_id
return result
def main():
"""
Queue log tester
"""
print("[START] Queue logger test")
log_queue: 'Queue[str]' = Queue()
script_path: Path = Path(__file__).resolve().parent
log = Log(
log_path=script_path.joinpath('log', 'test.log'),
log_name="Test Log",
log_settings={
"log_level_console": 'INFO',
"log_level_file": 'INFO',
"log_queue": log_queue,
}
)
log.logger.debug('Pool Fork logging test')
max_forks = 2
data_sets = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
with concurrent.futures.ProcessPoolExecutor(
max_workers=max_forks,
initializer=Log.init_worker_logging,
initargs=(log_queue,)
) as executor:
log.logger.info('Start workers')
futures = [
executor.submit(work_function, log.log_name, worker_id, data)
for worker_id, data in enumerate(data_sets, 1)
]
log.logger.info('Workders started')
for future in concurrent.futures.as_completed(futures):
log.logger.warning('Processing result: %s', future.result())
print(f"Processing result: {future.result()}")
log.set_log_level('stream_handler', LoggingLevel.ERROR)
log.logger.error('SECOND Start workers')
futures = [
executor.submit(work_function, log.log_name, worker_id, data)
for worker_id, data in enumerate(data_sets, 1)
]
log.logger.info('[INVISIBLE] Workders started')
log.logger.error('[VISIBLE] Second Workders started')
for future in concurrent.futures.as_completed(futures):
log.logger.error('Processing result: %s', future.result())
print(f"Processing result: {future.result()}")
log.set_log_level('stream_handler', LoggingLevel.DEBUG)
log.logger.info('[END] Queue logger test')
log.stop_listener()
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,66 @@
"""
Log logging_handling.log testing
"""
# import atexit
from pathlib import Path
from multiprocessing import Queue
import time
# this is for testing only
from corelibs.logging_handling.log import Log
from corelibs.logging_handling.logging_level_handling.logging_level import LoggingLevel
def main():
"""
Log testing
"""
script_path: Path = Path(__file__).resolve().parent
log_queue: 'Queue[str]' = Queue()
log_q = Log(
log_path=script_path.joinpath('log', 'test_queue.log'),
log_name="Test Log",
log_settings={
"log_level_console": 'WARNING',
"log_level_file": 'ERROR',
"log_queue": log_queue
# "console_color_output_enabled": False,
}
)
log_q.logger.debug('[QUEUE] Debug test: %s', log_q.logger.name)
log_q.logger.info('[QUEUE] Info test: %s', log_q.logger.name)
log_q.logger.warning('[QUEUE] Warning test: %s', log_q.logger.name)
log_q.logger.error('[QUEUE] Error test: %s', log_q.logger.name)
log_q.logger.critical('[QUEUE] Critical test: %s', log_q.logger.name)
log_q.logger.log(LoggingLevel.EXCEPTION.value, '[QUEUE] Exception test: %s', log_q.logger.name, exc_info=True)
time.sleep(0.1)
for handler in log_q.logger.handlers:
print(f"[1] Handler (logger) {handler}")
if log_q.listener is not None:
for handler in log_q.listener.handlers:
print(f"[1] Handler (queue) {handler}")
for handler in log_q.handlers.items():
print(f"[1] Handler (handlers) {handler}")
log_q.set_log_level('stream_handler', LoggingLevel.ERROR)
log_q.logger.warning('[QUEUE-B] [INVISIBLE] Warning test: %s', log_q.logger.name)
log_q.logger.error('[QUEUE-B] [VISIBLE] Error test: %s', log_q.logger.name)
for handler in log_q.logger.handlers:
print(f"[2] Handler (logger) {handler}")
if log_q.listener is not None:
for handler in log_q.listener.handlers:
print(f"[2] Handler (queue) {handler}")
for handler in log_q.handlers.items():
print(f"[2] Handler (handlers) {handler}")
log_q.stop_listener()
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,31 @@
"""
Log logging_handling.log testing
"""
# import atexit
from pathlib import Path
from multiprocessing import Queue
# this is for testing only
from queue_logger.log_queue import QueueLogger
def main():
"""
Log testing
"""
script_path: Path = Path(__file__).resolve().parent
log_queue: 'Queue[str]' = Queue()
log_q_legacy = QueueLogger(
log_file=script_path.joinpath('log', 'test_queue_legacy.log'),
log_name="Test Log Queue",
log_queue=log_queue
)
log_q_legacy.mlog.info('Log test: %s', 'Queue Legacy')
# log_q.stop_listener()
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,96 @@
"""
test queue logger interface
NOTE: this has all moved to the default log interface
"""
import logging
import logging.handlers
from pathlib import Path
from multiprocessing import Queue
class QueueLogger:
"""
Queue logger
"""
def __init__(self, log_file: Path, log_name: str, log_queue: 'Queue[str] | None' = None):
self.log_file = log_file
self.log_name = log_name
self.handlers = self.setup_logging()
self.log_queue: 'Queue[str]' = log_queue if log_queue is not None else Queue()
self.listener = logging.handlers.QueueListener(self.log_queue, *self.handlers)
self.listener.start()
self.mlog: logging.Logger = self.main_log(log_name)
def __del__(self):
self.mlog.info("[%s] ================================>", "END")
self.listener.stop()
def setup_logging(self):
"""
setup basic logging
"""
# Create formatters
file_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - [PID:%(process)d] [%(filename)s:%(lineno)d] - %(message)s'
)
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Create handlers
file_handler = logging.FileHandler(self.log_file)
file_handler.setFormatter(file_formatter)
file_handler.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setFormatter(console_formatter)
console_handler.setLevel(logging.DEBUG)
return [file_handler, console_handler]
def main_log(self, log_name: str) -> logging.Logger:
"""
main logger
Arguments:
log_name {str} -- _description_
Returns:
logging.Logger -- _description_
"""
mlog_handler = logging.handlers.QueueHandler(self.log_queue)
mlog = logging.getLogger(f'{log_name}-MainProcess')
mlog.addHandler(mlog_handler)
mlog.setLevel(logging.DEBUG)
return mlog
@staticmethod
def init_worker_logging(log_queue: 'Queue[str]', log_name: str, ):
"""
Initialize logging for worker processes
"""
# Create QueueHandler
queue_handler = logging.handlers.QueueHandler(log_queue)
# Setup root logger for this process
# NOTE: This must be EMPTY or new SINGLE NEW logger is created, we need one for EACH fork
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.handlers.clear()
root_logger.addHandler(queue_handler)
root_logger.info('[LOGGER] Init log: %s - %s', log_queue, log_name)
return root_logger
def stop_listener(self):
"""
stop the listener
"""
self.listener.stop()

View File

Can't render this file because it contains an unexpected character in line 3 and column 165.

View File

Can't render this file because it is too large.

View File

Can't render this file because it contains an unexpected character in line 3 and column 165.

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/env -S uv run --script
"""
Test for progress class
@@ -89,3 +89,5 @@ def main():
if __name__ == '__main__':
main()
# __END__

View File

@@ -0,0 +1,88 @@
"""
Test string_handling/string_helpers
"""
import sys
from decimal import Decimal, getcontext
from textwrap import shorten
from corelibs.string_handling.string_helpers import shorten_string, format_number
from corelibs.string_handling.text_colors import Colors
def __sh_shorten_string():
string = "hello"
length = 3
placeholder = " [very long placeholder]"
try:
result = shorten_string(string, length, placeholder=placeholder)
print(f"IN: {string} -> {result}")
except ValueError as e:
print(f"{Colors.red}Failed: {Colors.bold}{e}{Colors.end}")
try:
result = shorten(string, width=length, placeholder=placeholder)
print(f"IN: {string} -> {result}")
except ValueError as e:
print(f"Failed: {e}")
def __sh_format_number():
print(f"Max int: {sys.maxsize}")
print(f"Max float: {sys.float_info.max}")
number = 1234.56
precision = 0
result = format_number(number, precision)
print(f"Format {number} ({precision}) -> {result}")
number = 1234.56
precision = 100
result = format_number(number, precision)
print(f"Format {number} ({precision}) -> {result}")
number = 123400000000000001.56
if number >= sys.maxsize:
print("INT Number too big")
if number >= sys.float_info.max:
print("FLOAT Number too big")
precision = 5
result = format_number(number, precision)
print(f"Format {number} ({precision}) -> {result}")
precision = 100
getcontext().prec = precision
number = Decimal(str(1234.56))
result = f"{number:,.100f}"
print(f"Format {number} ({precision}) -> {result}")
def __sh_colors():
for color in [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
]:
for change in ['', '_bold', '_bright']:
_color = f"{color}{change}"
print(f"Color: {getattr(Colors, _color)}{_color}{Colors.end}")
print(f"Underline: {Colors.underline}UNDERLINE{Colors.reset}")
print(f"Bold: {Colors.bold}BOLD{Colors.reset}")
print(f"Underline/Yellow: {Colors.underline}{Colors.yellow}UNDERLINE YELLOW{Colors.reset}")
print(f"Underline/Yellow/Bold: {Colors.underline}{Colors.bold}{Colors.yellow}UNDERLINE YELLOW BOLD{Colors.reset}")
def main():
"""
Test: corelibs.string_handling.string_helpers
"""
__sh_shorten_string()
__sh_format_number()
__sh_colors()
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
timestamp string checks
"""
from corelibs.string_handling.timestamp_strings import convert_to_seconds, TimeParseError, TimeUnitError
def main() -> None:
"""
Comment
"""
test_cases = [
"5M 6d", # 5 months, 6 days
"2h 30m 45s", # 2 hours, 30 minutes, 45 seconds
"1Y 2M 3d", # 1 year, 2 months, 3 days
"1h", # 1 hour
"30m", # 30 minutes
"2 hours 15 minutes", # 2 hours, 15 minutes
"1d 12h", # 1 day, 12 hours
"3M 2d 4h", # 3 months, 2 days, 4 hours
"45s", # 45 seconds
"1 year 2 months", # 1 year, 2 months
"2Y 6M 15d 8h 30m 45s", # Complex example
# ]
# invalid_test_cases = [
"5M 6d 2M", # months appears twice
"2h 30m 45s 1h", # hours appears twice
"1d 2 days", # days appears twice (short and long form)
"30m 45 minutes", # minutes appears twice
"1Y 2 years", # years appears twice
"1x 2 yrs", # invalid names
123, # int
789.12, # float
456.56, # float, high
"4566", # int as string
"5551.12", # float as string
"5551.56", # float, high as string
]
for time_string in test_cases:
try:
result = convert_to_seconds(time_string)
print(f"{time_string} => {result}")
except (TimeParseError, TimeUnitError) as e:
print(f"Error encountered for {time_string}: {type(e).__name__}: {e}")
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env -S uv run --script
"""
Test for double byte format
"""
from corelibs.string_handling.timestamp_strings import TimestampStrings
def main():
ts = TimestampStrings()
print(f"TS: {ts.timestamp_now}")
try:
ts = TimestampStrings("invalid")
except ValueError as e:
print(f"Value error: {e}")
if __name__ == "__main__":
main()
# __END__

View File

0
tests/unit/__init__.py Normal file
View File

View File

@@ -0,0 +1,291 @@
"""
tests for corelibs.iterator_handling.dict_helpers
"""
import pytest
from typing import Any
from corelibs.iterator_handling.dict_helpers import mask
def test_mask_default_behavior():
"""Test masking with default mask_keys"""
data = {
"username": "john_doe",
"password": "secret123",
"email": "john@example.com",
"api_secret": "abc123",
"encryption_key": "xyz789"
}
result = mask(data)
assert result["username"] == "john_doe"
assert result["password"] == "***"
assert result["email"] == "john@example.com"
assert result["api_secret"] == "***"
assert result["encryption_key"] == "***"
def test_mask_custom_keys():
"""Test masking with custom mask_keys"""
data = {
"username": "john_doe",
"token": "abc123",
"api_key": "xyz789",
"password": "secret123"
}
result = mask(data, mask_keys=["token", "api"])
assert result["username"] == "john_doe"
assert result["token"] == "***"
assert result["api_key"] == "***"
assert result["password"] == "secret123" # Not masked with custom keys
def test_mask_custom_mask_string():
"""Test masking with custom mask string"""
data = {"password": "secret123"}
result = mask(data, mask_str="[HIDDEN]")
assert result["password"] == "[HIDDEN]"
def test_mask_case_insensitive():
"""Test that masking is case insensitive"""
data = {
"PASSWORD": "secret123",
"Secret_Key": "abc123",
"ENCRYPTION_data": "xyz789"
}
result = mask(data)
assert result["PASSWORD"] == "***"
assert result["Secret_Key"] == "***"
assert result["ENCRYPTION_data"] == "***"
def test_mask_key_patterns():
"""Test different key matching patterns (start, end, contains)"""
data = {
"password_hash": "hash123", # starts with
"user_password": "secret123", # ends with
"my_secret_key": "abc123", # contains with edges
"secretvalue": "xyz789", # contains without edges
"startsecretvalue": "xyz123", # contains without edges
"normal_key": "normal_value"
}
result = mask(data)
assert result["password_hash"] == "***"
assert result["user_password"] == "***"
assert result["my_secret_key"] == "***"
assert result["secretvalue"] == "***" # will mask beacuse starts with
assert result["startsecretvalue"] == "xyz123" # will not mask
assert result["normal_key"] == "normal_value"
def test_mask_custom_edges():
"""Test masking with custom edge characters"""
data = {
"my-secret-key": "abc123",
"my_secret_key": "xyz789"
}
result = mask(data, mask_str_edges="-")
assert result["my-secret-key"] == "***"
assert result["my_secret_key"] == "xyz789" # Underscore edges don't match
def test_mask_empty_edges():
"""Test masking with empty edge characters (substring matching)"""
data = {
"secretvalue": "abc123",
"mysecretkey": "xyz789",
"normal_key": "normal_value"
}
result = mask(data, mask_str_edges="")
assert result["secretvalue"] == "***"
assert result["mysecretkey"] == "***"
assert result["normal_key"] == "normal_value"
def test_mask_nested_dict():
"""Test masking nested dictionaries"""
data = {
"user": {
"name": "john",
"password": "secret123",
"profile": {
"email": "john@example.com",
"encryption_key": "abc123"
}
},
"api_secret": "xyz789"
}
result = mask(data)
assert result["user"]["name"] == "john"
assert result["user"]["password"] == "***"
assert result["user"]["profile"]["email"] == "john@example.com"
assert result["user"]["profile"]["encryption_key"] == "***"
assert result["api_secret"] == "***"
def test_mask_lists():
"""Test masking lists and nested structures with lists"""
data = {
"users": [
{"name": "john", "password": "secret1"},
{"name": "jane", "password": "secret2"}
],
"secrets": ["secret1", "secret2", "secret3"]
}
result = mask(data)
print(f"R {result['secrets']}")
assert result["users"][0]["name"] == "john"
assert result["users"][0]["password"] == "***"
assert result["users"][1]["name"] == "jane"
assert result["users"][1]["password"] == "***"
assert result["secrets"] == ["***", "***", "***"]
def test_mask_mixed_types():
"""Test masking with different value types"""
data = {
"password": "string_value",
"secret_number": 12345,
"encryption_flag": True,
"secret_float": 3.14,
"password_none": None,
"normal_key": "normal_value"
}
result = mask(data)
assert result["password"] == "***"
assert result["secret_number"] == "***"
assert result["encryption_flag"] == "***"
assert result["secret_float"] == "***"
assert result["password_none"] == "***"
assert result["normal_key"] == "normal_value"
def test_mask_skip_true():
"""Test that skip=True returns original data unchanged"""
data = {
"password": "secret123",
"encryption_key": "abc123",
"normal_key": "normal_value"
}
result = mask(data, skip=True)
assert result == data
assert result is data # Should return the same object
def test_mask_empty_dict():
"""Test masking empty dictionary"""
data: dict[str, Any] = {}
result = mask(data)
assert result == {}
def test_mask_none_mask_keys():
"""Test explicit None mask_keys uses defaults"""
data = {"password": "secret123", "token": "abc123"}
result = mask(data, mask_keys=None)
assert result["password"] == "***"
assert result["token"] == "abc123" # Not in default keys
def test_mask_empty_mask_keys():
"""Test empty mask_keys list"""
data = {"password": "secret123", "secret": "abc123"}
result = mask(data, mask_keys=[])
assert result["password"] == "secret123"
assert result["secret"] == "abc123"
def test_mask_complex_nested_structure():
"""Test masking complex nested structure"""
data = {
"config": {
"database": {
"host": "localhost",
"password": "db_secret",
"users": [
{"name": "admin", "password": "admin123"},
{"name": "user", "secret_key": "user456"}
]
},
"api": {
"endpoints": ["api1", "api2"],
"encryption_settings": {
"enabled": True,
"secret": "api_secret"
}
}
}
}
result = mask(data)
assert result["config"]["database"]["host"] == "localhost"
assert result["config"]["database"]["password"] == "***"
assert result["config"]["database"]["users"][0]["name"] == "admin"
assert result["config"]["database"]["users"][0]["password"] == "***"
assert result["config"]["database"]["users"][1]["name"] == "user"
assert result["config"]["database"]["users"][1]["secret_key"] == "***"
assert result["config"]["api"]["endpoints"] == ["api1", "api2"]
assert result["config"]["api"]["encryption_settings"]["enabled"] is True
assert result["config"]["api"]["encryption_settings"]["secret"] == "***"
def test_mask_preserves_original_data():
"""Test that original data is not modified"""
original_data = {
"password": "secret123",
"username": "john_doe"
}
data_copy = original_data.copy()
result = mask(original_data)
assert original_data == data_copy # Original unchanged
assert result != original_data # Result is different
assert result["password"] == "***"
assert original_data["password"] == "secret123"
@pytest.mark.parametrize("mask_key,expected_keys", [
(["pass"], ["password", "user_pass", "my_pass_key"]),
(["key"], ["api_key", "secret_key", "my_key_value"]),
(["token"], ["token", "auth_token", "my_token_here"]),
])
def test_mask_parametrized_keys(mask_key: list[str], expected_keys: list[str]):
"""Parametrized test for different mask key patterns"""
data = {key: "value" for key in expected_keys}
data["normal_entry"] = "normal_value"
result = mask(data, mask_keys=mask_key)
for key in expected_keys:
assert result[key] == "***"
assert result["normal_entry"] == "normal_value"

View File

@@ -0,0 +1,300 @@
"""
iterator_handling.list_helepr tests
"""
from typing import Any
import pytest
from corelibs.iterator_handling.list_helpers import convert_to_list, is_list_in_list
class TestConvertToList:
"""Test cases for convert_to_list function"""
def test_string_input(self):
"""Test with string inputs"""
assert convert_to_list("hello") == ["hello"]
assert convert_to_list("") == [""]
assert convert_to_list("123") == ["123"]
assert convert_to_list("true") == ["true"]
def test_integer_input(self):
"""Test with integer inputs"""
assert convert_to_list(42) == [42]
assert convert_to_list(0) == [0]
assert convert_to_list(-10) == [-10]
assert convert_to_list(999999) == [999999]
def test_float_input(self):
"""Test with float inputs"""
assert convert_to_list(3.14) == [3.14]
assert convert_to_list(0.0) == [0.0]
assert convert_to_list(-2.5) == [-2.5]
assert convert_to_list(1.0) == [1.0]
def test_boolean_input(self):
"""Test with boolean inputs"""
assert convert_to_list(True) == [True]
assert convert_to_list(False) == [False]
def test_list_input_unchanged(self):
"""Test that list inputs are returned unchanged"""
# String lists
str_list = ["a", "b", "c"]
assert convert_to_list(str_list) == str_list
assert convert_to_list(str_list) is str_list # Same object reference
# Integer lists
int_list = [1, 2, 3]
assert convert_to_list(int_list) == int_list
assert convert_to_list(int_list) is int_list
# Float lists
float_list = [1.1, 2.2, 3.3]
assert convert_to_list(float_list) == float_list
assert convert_to_list(float_list) is float_list
# Boolean lists
bool_list = [True, False, True]
assert convert_to_list(bool_list) == bool_list
assert convert_to_list(bool_list) is bool_list
# Mixed lists
mixed_list = [1, "hello", 3.14, True]
assert convert_to_list(mixed_list) == mixed_list
assert convert_to_list(mixed_list) is mixed_list
# Empty list
empty_list: list[int] = []
assert convert_to_list(empty_list) == empty_list
assert convert_to_list(empty_list) is empty_list
def test_nested_lists(self):
"""Test with nested lists (should still return the same list)"""
nested_list: list[list[int]] = [[1, 2], [3, 4]]
assert convert_to_list(nested_list) == nested_list
assert convert_to_list(nested_list) is nested_list
def test_single_element_lists(self):
"""Test with single element lists"""
single_str = ["hello"]
assert convert_to_list(single_str) == single_str
assert convert_to_list(single_str) is single_str
single_int = [42]
assert convert_to_list(single_int) == single_int
assert convert_to_list(single_int) is single_int
class TestIsListInList:
"""Test cases for is_list_in_list function"""
def test_string_lists(self):
"""Test with string lists"""
list_a = ["a", "b", "c", "d"]
list_b = ["b", "d", "e"]
result = is_list_in_list(list_a, list_b)
assert set(result) == {"a", "c"}
assert isinstance(result, list)
def test_integer_lists(self):
"""Test with integer lists"""
list_a = [1, 2, 3, 4, 5]
list_b = [2, 4, 6]
result = is_list_in_list(list_a, list_b)
assert set(result) == {1, 3, 5}
assert isinstance(result, list)
def test_float_lists(self):
"""Test with float lists"""
list_a = [1.1, 2.2, 3.3, 4.4]
list_b = [2.2, 4.4, 5.5]
result = is_list_in_list(list_a, list_b)
assert set(result) == {1.1, 3.3}
assert isinstance(result, list)
def test_boolean_lists(self):
"""Test with boolean lists"""
list_a = [True, False, True]
list_b = [True]
result = is_list_in_list(list_a, list_b)
assert set(result) == {False}
assert isinstance(result, list)
def test_mixed_type_lists(self):
"""Test with mixed type lists"""
list_a = [1, "hello", 3.14, True, "world"]
list_b = ["hello", True, 42]
result = is_list_in_list(list_a, list_b)
assert set(result) == {1, 3.14, "world"}
assert isinstance(result, list)
def test_empty_lists(self):
"""Test with empty lists"""
# Empty list_a
assert is_list_in_list([], [1, 2, 3]) == []
# Empty list_b
list_a = [1, 2, 3]
result = is_list_in_list(list_a, [])
assert set(result) == {1, 2, 3}
# Both empty
assert is_list_in_list([], []) == []
def test_no_common_elements(self):
"""Test when lists have no common elements"""
list_a = [1, 2, 3]
list_b = [4, 5, 6]
result = is_list_in_list(list_a, list_b)
assert set(result) == {1, 2, 3}
def test_all_elements_common(self):
"""Test when all elements in list_a are in list_b"""
list_a = [1, 2, 3]
list_b = [1, 2, 3, 4, 5]
result = is_list_in_list(list_a, list_b)
assert result == []
def test_identical_lists(self):
"""Test with identical lists"""
list_a = [1, 2, 3]
list_b = [1, 2, 3]
result = is_list_in_list(list_a, list_b)
assert result == []
def test_duplicate_elements(self):
"""Test with duplicate elements in lists"""
list_a = [1, 2, 2, 3, 3, 3]
list_b = [2, 4]
result = is_list_in_list(list_a, list_b)
# Should return unique elements only (set behavior)
assert set(result) == {1, 3}
assert isinstance(result, list)
def test_list_b_larger_than_list_a(self):
"""Test when list_b is larger than list_a"""
list_a = [1, 2]
list_b = [2, 3, 4, 5, 6, 7, 8]
result = is_list_in_list(list_a, list_b)
assert set(result) == {1}
def test_order_independence(self):
"""Test that order doesn't matter due to set operations"""
list_a = [3, 1, 4, 1, 5]
list_b = [1, 2, 6]
result = is_list_in_list(list_a, list_b)
assert set(result) == {3, 4, 5}
# Parametrized tests for more comprehensive coverage
class TestParametrized:
"""Parametrized tests for better coverage"""
@pytest.mark.parametrize("input_value,expected", [
("hello", ["hello"]),
(42, [42]),
(3.14, [3.14]),
(True, [True]),
(False, [False]),
("", [""]),
(0, [0]),
(0.0, [0.0]),
(-1, [-1]),
(-2.5, [-2.5]),
])
def test_convert_to_list_parametrized(self, input_value: Any, expected: Any):
"""Test convert_to_list with various single values"""
assert convert_to_list(input_value) == expected
@pytest.mark.parametrize("input_list", [
[1, 2, 3],
["a", "b", "c"],
[1.1, 2.2, 3.3],
[True, False],
[1, "hello", 3.14, True],
[],
[42],
[[1, 2], [3, 4]],
])
def test_convert_to_list_with_lists_parametrized(self, input_list: Any):
"""Test convert_to_list with various list inputs"""
result = convert_to_list(input_list)
assert result == input_list
assert result is input_list # Same object reference
@pytest.mark.parametrize("list_a,list_b,expected_set", [
([1, 2, 3], [2], {1, 3}),
(["a", "b", "c"], ["b", "d"], {"a", "c"}),
([1, 2, 3], [4, 5, 6], {1, 2, 3}),
([1, 2, 3], [1, 2, 3], set()),
([], [1, 2, 3], set()),
([1, 2, 3], [], {1, 2, 3}),
([True, False], [True], {False}),
([1.1, 2.2, 3.3], [2.2], {1.1, 3.3}),
])
def test_is_list_in_list_parametrized(self, list_a: list[Any], list_b: list[Any], expected_set: Any):
"""Test is_list_in_list with various input combinations"""
result = is_list_in_list(list_a, list_b)
assert set(result) == expected_set
assert isinstance(result, list)
# Edge cases and special scenarios
class TestEdgeCases:
"""Test edge cases and special scenarios"""
def test_convert_to_list_with_none_like_values(self):
"""Test convert_to_list with None-like values (if function supports them)"""
# Note: Based on type hints, None is not supported, but testing behavior
# This test might need to be adjusted based on actual function behavior
pass
def test_is_list_in_list_preserves_type_distinctions(self):
"""Test that different types are treated as different"""
list_a = [1, "1", 1.0, True]
list_b = [1] # Only integer 1
result = is_list_in_list(list_a, list_b)
# Note: This test depends on how Python's set handles type equality
# 1, 1.0, and True are considered equal in sets
# "1" is different from 1
# expected_items = {"1"} # String "1" should remain
assert "1" in result
assert isinstance(result, list)
def test_large_lists(self):
"""Test with large lists"""
large_list_a = list(range(1000))
large_list_b = list(range(500, 1500))
result = is_list_in_list(large_list_a, large_list_b)
expected = list(range(500)) # 0 to 499
assert set(result) == set(expected)
def test_memory_efficiency(self):
"""Test that convert_to_list doesn't create unnecessary copies"""
original_list = [1, 2, 3, 4, 5]
result = convert_to_list(original_list)
# Should be the same object, not a copy
assert result is original_list
# Modifying the original should affect the result
original_list.append(6)
assert 6 in result
# Performance tests (optional)
class TestPerformance:
"""Performance-related tests"""
def test_is_list_in_list_with_duplicates_performance(self):
"""Test that function handles duplicates efficiently"""
# List with many duplicates
list_a = [1, 2, 3] * 100 # 300 elements, many duplicates
list_b = [2] * 50 # 50 elements, all the same
result = is_list_in_list(list_a, list_b)
# Should still work correctly despite duplicates
assert set(result) == {1, 3}
assert isinstance(result, list)

View File

@@ -0,0 +1,341 @@
"""
logging_handling.logging_level_handling.logging_level
"""
import logging
import pytest
# from enum import Enum
from corelibs.logging_handling.logging_level_handling.logging_level import LoggingLevel
class TestLoggingLevelEnum:
"""Test the LoggingLevel enum values and basic functionality."""
def test_enum_values(self):
"""Test that all enum values match expected logging levels."""
assert LoggingLevel.NOTSET.value == 0
assert LoggingLevel.DEBUG.value == 10
assert LoggingLevel.INFO.value == 20
assert LoggingLevel.WARNING.value == 30
assert LoggingLevel.ERROR.value == 40
assert LoggingLevel.CRITICAL.value == 50
assert LoggingLevel.ALERT.value == 55
assert LoggingLevel.EMERGENCY.value == 60
assert LoggingLevel.EXCEPTION.value == 70
assert LoggingLevel.WARN.value == 30
assert LoggingLevel.FATAL.value == 50
def test_enum_corresponds_to_logging_module(self):
"""Test that enum values correspond to logging module constants."""
assert LoggingLevel.NOTSET.value == logging.NOTSET
assert LoggingLevel.DEBUG.value == logging.DEBUG
assert LoggingLevel.INFO.value == logging.INFO
assert LoggingLevel.WARNING.value == logging.WARNING
assert LoggingLevel.ERROR.value == logging.ERROR
assert LoggingLevel.CRITICAL.value == logging.CRITICAL
assert LoggingLevel.WARN.value == logging.WARN
assert LoggingLevel.FATAL.value == logging.FATAL
def test_enum_aliases(self):
"""Test that aliases point to correct values."""
assert LoggingLevel.WARN.value == LoggingLevel.WARNING.value
assert LoggingLevel.FATAL.value == LoggingLevel.CRITICAL.value
class TestFromString:
"""Test the from_string classmethod."""
def test_from_string_valid_cases(self):
"""Test from_string with valid string inputs."""
assert LoggingLevel.from_string("DEBUG") == LoggingLevel.DEBUG
assert LoggingLevel.from_string("info") == LoggingLevel.INFO
assert LoggingLevel.from_string("Warning") == LoggingLevel.WARNING
assert LoggingLevel.from_string("ERROR") == LoggingLevel.ERROR
assert LoggingLevel.from_string("critical") == LoggingLevel.CRITICAL
assert LoggingLevel.from_string("EXCEPTION") == LoggingLevel.EXCEPTION
assert LoggingLevel.from_string("warn") == LoggingLevel.WARN
assert LoggingLevel.from_string("FATAL") == LoggingLevel.FATAL
def test_from_string_invalid_cases(self):
"""Test from_string with invalid string inputs."""
with pytest.raises(ValueError, match="Invalid log level: invalid"):
LoggingLevel.from_string("invalid")
with pytest.raises(ValueError, match="Invalid log level: "):
LoggingLevel.from_string("")
with pytest.raises(ValueError, match="Invalid log level: 123"):
LoggingLevel.from_string("123")
class TestFromInt:
"""Test the from_int classmethod."""
def test_from_int_valid_cases(self):
"""Test from_int with valid integer inputs."""
assert LoggingLevel.from_int(0) == LoggingLevel.NOTSET
assert LoggingLevel.from_int(10) == LoggingLevel.DEBUG
assert LoggingLevel.from_int(20) == LoggingLevel.INFO
assert LoggingLevel.from_int(30) == LoggingLevel.WARNING
assert LoggingLevel.from_int(40) == LoggingLevel.ERROR
assert LoggingLevel.from_int(50) == LoggingLevel.CRITICAL
assert LoggingLevel.from_int(55) == LoggingLevel.ALERT
assert LoggingLevel.from_int(60) == LoggingLevel.EMERGENCY
assert LoggingLevel.from_int(70) == LoggingLevel.EXCEPTION
def test_from_int_invalid_cases(self):
"""Test from_int with invalid integer inputs."""
with pytest.raises(ValueError, match="Invalid log level: 999"):
LoggingLevel.from_int(999)
with pytest.raises(ValueError, match="Invalid log level: -1"):
LoggingLevel.from_int(-1)
with pytest.raises(ValueError, match="Invalid log level: 15"):
LoggingLevel.from_int(15)
class TestFromAny:
"""Test the from_any classmethod."""
def test_from_any_with_logging_level(self):
"""Test from_any when input is already a LoggingLevel."""
level = LoggingLevel.INFO
assert LoggingLevel.from_any(level) == LoggingLevel.INFO
assert LoggingLevel.from_any(level) is level
def test_from_any_with_int(self):
"""Test from_any with integer input."""
assert LoggingLevel.from_any(10) == LoggingLevel.DEBUG
assert LoggingLevel.from_any(20) == LoggingLevel.INFO
assert LoggingLevel.from_any(30) == LoggingLevel.WARNING
def test_from_any_with_string(self):
"""Test from_any with string input."""
assert LoggingLevel.from_any("DEBUG") == LoggingLevel.DEBUG
assert LoggingLevel.from_any("info") == LoggingLevel.INFO
assert LoggingLevel.from_any("Warning") == LoggingLevel.WARNING
def test_from_any_with_invalid_types(self):
"""Test from_any with invalid input types."""
with pytest.raises(ValueError):
LoggingLevel.from_any(None)
with pytest.raises(ValueError):
LoggingLevel.from_any([])
with pytest.raises(ValueError):
LoggingLevel.from_any({})
def test_from_any_with_invalid_values(self):
"""Test from_any with invalid values."""
with pytest.raises(ValueError):
LoggingLevel.from_any("invalid_level")
with pytest.raises(ValueError):
LoggingLevel.from_any(999)
class TestToLoggingLevel:
"""Test the to_logging_level method."""
def test_to_logging_level(self):
"""Test conversion to logging module level."""
assert LoggingLevel.DEBUG.to_logging_level() == 10
assert LoggingLevel.INFO.to_logging_level() == 20
assert LoggingLevel.WARNING.to_logging_level() == 30
assert LoggingLevel.ERROR.to_logging_level() == 40
assert LoggingLevel.CRITICAL.to_logging_level() == 50
assert LoggingLevel.ALERT.to_logging_level() == 55
assert LoggingLevel.EMERGENCY.to_logging_level() == 60
assert LoggingLevel.EXCEPTION.to_logging_level() == 70
class TestToLowerCase:
"""Test the to_lower_case method."""
def test_to_lower_case(self):
"""Test conversion to lowercase."""
assert LoggingLevel.DEBUG.to_lower_case() == "debug"
assert LoggingLevel.INFO.to_lower_case() == "info"
assert LoggingLevel.WARNING.to_lower_case() == "warning"
assert LoggingLevel.ERROR.to_lower_case() == "error"
assert LoggingLevel.CRITICAL.to_lower_case() == "critical"
assert LoggingLevel.ALERT.to_lower_case() == "alert"
assert LoggingLevel.EMERGENCY.to_lower_case() == "emergency"
assert LoggingLevel.EXCEPTION.to_lower_case() == "exception"
class TestStrMethod:
"""Test the __str__ method."""
def test_str_method(self):
"""Test string representation."""
assert str(LoggingLevel.DEBUG) == "DEBUG"
assert str(LoggingLevel.INFO) == "INFO"
assert str(LoggingLevel.WARNING) == "WARNING"
assert str(LoggingLevel.ERROR) == "ERROR"
assert str(LoggingLevel.CRITICAL) == "CRITICAL"
assert str(LoggingLevel.ALERT) == "ALERT"
assert str(LoggingLevel.EMERGENCY) == "EMERGENCY"
assert str(LoggingLevel.EXCEPTION) == "EXCEPTION"
class TestIncludes:
"""Test the includes method."""
def test_includes_valid_cases(self):
"""Test includes method with valid cases."""
# DEBUG includes all levels
assert LoggingLevel.DEBUG.includes(LoggingLevel.DEBUG)
assert LoggingLevel.DEBUG.includes(LoggingLevel.INFO)
assert LoggingLevel.DEBUG.includes(LoggingLevel.WARNING)
assert LoggingLevel.DEBUG.includes(LoggingLevel.ERROR)
assert LoggingLevel.DEBUG.includes(LoggingLevel.CRITICAL)
assert LoggingLevel.DEBUG.includes(LoggingLevel.ALERT)
assert LoggingLevel.DEBUG.includes(LoggingLevel.EMERGENCY)
assert LoggingLevel.DEBUG.includes(LoggingLevel.EXCEPTION)
# INFO includes INFO and higher
assert LoggingLevel.INFO.includes(LoggingLevel.INFO)
assert LoggingLevel.INFO.includes(LoggingLevel.WARNING)
assert LoggingLevel.INFO.includes(LoggingLevel.ERROR)
assert LoggingLevel.INFO.includes(LoggingLevel.CRITICAL)
assert LoggingLevel.INFO.includes(LoggingLevel.ALERT)
assert LoggingLevel.INFO.includes(LoggingLevel.EMERGENCY)
assert LoggingLevel.INFO.includes(LoggingLevel.EXCEPTION)
# INFO does not include DEBUG
assert not LoggingLevel.INFO.includes(LoggingLevel.DEBUG)
# ERROR includes ERROR and higher
assert LoggingLevel.ERROR.includes(LoggingLevel.ERROR)
assert LoggingLevel.ERROR.includes(LoggingLevel.CRITICAL)
assert LoggingLevel.ERROR.includes(LoggingLevel.ALERT)
assert LoggingLevel.ERROR.includes(LoggingLevel.EMERGENCY)
assert LoggingLevel.ERROR.includes(LoggingLevel.EXCEPTION)
# ERROR does not include lower levels
assert not LoggingLevel.ERROR.includes(LoggingLevel.DEBUG)
assert not LoggingLevel.ERROR.includes(LoggingLevel.INFO)
assert not LoggingLevel.ERROR.includes(LoggingLevel.WARNING)
class TestIsHigherThan:
"""Test the is_higher_than method."""
def test_is_higher_than(self):
"""Test is_higher_than method."""
assert LoggingLevel.ERROR.is_higher_than(LoggingLevel.WARNING)
assert LoggingLevel.CRITICAL.is_higher_than(LoggingLevel.ERROR)
assert LoggingLevel.ALERT.is_higher_than(LoggingLevel.CRITICAL)
assert LoggingLevel.EMERGENCY.is_higher_than(LoggingLevel.ALERT)
assert LoggingLevel.EXCEPTION.is_higher_than(LoggingLevel.EMERGENCY)
assert LoggingLevel.INFO.is_higher_than(LoggingLevel.DEBUG)
# Same level should return False
assert not LoggingLevel.INFO.is_higher_than(LoggingLevel.INFO)
# Lower level should return False
assert not LoggingLevel.DEBUG.is_higher_than(LoggingLevel.INFO)
class TestIsLowerThan:
"""Test the is_lower_than method - Note: there seems to be a bug in the original implementation."""
def test_is_lower_than_expected_behavior(self):
"""Test what the is_lower_than method should do (based on method name)."""
# Note: The original implementation has a bug - it uses > instead of <
# This test shows what the expected behavior should be
# Based on the method name, these should be true:
# assert LoggingLevel.DEBUG.is_lower_than(LoggingLevel.INFO)
# assert LoggingLevel.INFO.is_lower_than(LoggingLevel.WARNING)
# assert LoggingLevel.WARNING.is_lower_than(LoggingLevel.ERROR)
# However, due to the bug in implementation (using > instead of <),
# the method actually behaves the same as is_higher_than
pass
def test_is_lower_than_actual_behavior(self):
"""Test the actual behavior of is_lower_than method."""
# Due to the bug, this method behaves like is_higher_than
assert LoggingLevel.DEBUG.is_lower_than(LoggingLevel.INFO)
assert LoggingLevel.INFO.is_lower_than(LoggingLevel.WARNING)
assert LoggingLevel.WARNING.is_lower_than(LoggingLevel.ERROR)
assert LoggingLevel.ERROR.is_lower_than(LoggingLevel.CRITICAL)
assert LoggingLevel.CRITICAL.is_lower_than(LoggingLevel.ALERT)
assert LoggingLevel.ALERT.is_lower_than(LoggingLevel.EMERGENCY)
assert LoggingLevel.EMERGENCY.is_lower_than(LoggingLevel.EXCEPTION)
# Same level should return False
assert not LoggingLevel.INFO.is_lower_than(LoggingLevel.INFO)
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_none_inputs(self):
"""Test handling of None inputs."""
with pytest.raises((ValueError, AttributeError)):
LoggingLevel.from_string(None)
with pytest.raises((ValueError, TypeError)):
LoggingLevel.from_int(None)
def test_type_errors(self):
"""Test type error conditions."""
with pytest.raises((ValueError, AttributeError)):
LoggingLevel.from_string(123)
with pytest.raises((ValueError, TypeError)):
LoggingLevel.from_int("string")
# Integration tests
class TestIntegration:
"""Integration tests combining multiple methods."""
def test_round_trip_conversions(self):
"""Test round-trip conversions work correctly."""
original_levels = [
LoggingLevel.DEBUG, LoggingLevel.INFO, LoggingLevel.WARNING,
LoggingLevel.ERROR, LoggingLevel.CRITICAL, LoggingLevel.ALERT,
LoggingLevel.EXCEPTION, LoggingLevel.EXCEPTION
]
for level in original_levels:
# Test int round-trip
assert LoggingLevel.from_int(level.value) == level
# Test string round-trip
assert LoggingLevel.from_string(level.name) == level
# Test from_any round-trip
assert LoggingLevel.from_any(level) == level
assert LoggingLevel.from_any(level.value) == level
assert LoggingLevel.from_any(level.name) == level
def test_level_hierarchy(self):
"""Test that level hierarchy works correctly."""
levels = [
LoggingLevel.NOTSET, LoggingLevel.DEBUG, LoggingLevel.INFO,
LoggingLevel.WARNING, LoggingLevel.ERROR, LoggingLevel.CRITICAL,
LoggingLevel.ALERT, LoggingLevel.EMERGENCY, LoggingLevel.EXCEPTION
]
for i, level in enumerate(levels):
for j, other_level in enumerate(levels):
if i < j:
assert level.includes(other_level)
assert other_level.is_higher_than(level)
elif i > j:
assert not level.includes(other_level)
assert level.is_higher_than(other_level)
else:
assert level.includes(other_level) # Same level includes itself
assert not level.is_higher_than(other_level) # Same level is not higher
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -0,0 +1,239 @@
"""
PyTest: string_handling/string_helpers
"""
from textwrap import shorten
import pytest
from corelibs.string_handling.string_helpers import (
shorten_string, left_fill, format_number
)
class TestShortenString:
"""Tests for shorten_string function"""
def test_string_shorter_than_length(self):
"""Test that strings shorter than length are returned unchanged"""
result = shorten_string("hello", 10)
assert result == "hello"
def test_string_equal_to_length(self):
"""Test that strings equal to length are returned unchanged"""
result = shorten_string("hello", 5)
assert result == "hello"
def test_hard_shorten_true(self):
"""Test hard shortening with default placeholder"""
result = shorten_string("hello world", 8, hard_shorten=True)
assert result == "hell [~]"
def test_hard_shorten_custom_placeholder(self):
"""Test hard shortening with custom placeholder"""
result = shorten_string("hello world", 8, hard_shorten=True, placeholder="...")
assert result == "hello..."
def test_no_spaces_auto_hard_shorten(self):
"""Test that strings without spaces automatically use hard shorten"""
result = shorten_string("helloworld", 8)
assert result == "hell [~]"
def test_soft_shorten_with_spaces(self):
"""Test soft shortening using textwrap.shorten"""
result = shorten_string("hello world test", 12)
# Should use textwrap.shorten behavior
expected = shorten("hello world test", width=12, placeholder=" [~]")
assert result == expected
def test_placeholder_too_large_hard_shorten(self):
"""Test error when placeholder is larger than allowed length"""
with pytest.raises(ValueError, match="Cannot shorten string: placeholder .* is too large for max width"):
shorten_string("hello", 3, hard_shorten=True, placeholder=" [~]")
def test_placeholder_too_large_no_spaces(self):
"""Test error when placeholder is larger than allowed length for string without spaces"""
with pytest.raises(ValueError, match="Cannot shorten string: placeholder .* is too large for max width"):
shorten_string("hello", 3, placeholder=" [~]")
def test_textwrap_shorten_error(self):
"""Test handling of textwrap.shorten ValueError"""
# This might be tricky to trigger, but we can mock it
with pytest.raises(ValueError, match="Cannot shorten string:"):
# Very short length that might cause textwrap.shorten to fail
shorten_string("hello world", 1, hard_shorten=False)
def test_type_conversion(self):
"""Test that inputs are converted to proper types"""
result = shorten_string(12345, 8, hard_shorten=True)
assert result == "12345"
def test_empty_string(self):
"""Test with empty string"""
result = shorten_string("", 5)
assert result == ""
def test_zero_length(self):
"""Test with zero length"""
with pytest.raises(ValueError):
shorten_string("hello", 0, hard_shorten=True)
class TestLeftFill:
"""Tests for left_fill function"""
def test_basic_left_fill(self):
"""Test basic left filling with spaces"""
result = left_fill("hello", 10)
assert result == " hello"
assert len(result) == 10
def test_custom_fill_character(self):
"""Test left filling with custom character"""
result = left_fill("hello", 10, "0")
assert result == "00000hello"
def test_string_longer_than_width(self):
"""Test when string is longer than width"""
result = left_fill("hello world", 5)
assert result == "hello world" # Should return original string
def test_string_equal_to_width(self):
"""Test when string equals width"""
result = left_fill("hello", 5)
assert result == "hello"
def test_negative_width(self):
"""Test with negative width"""
result = left_fill("hello", -5)
assert result == "hello" # Should use string length
def test_zero_width(self):
"""Test with zero width"""
result = left_fill("hello", 0)
assert result == "hello" # Should return original string
def test_invalid_fill_character(self):
"""Test with invalid fill character (not single char)"""
result = left_fill("hello", 10, "abc")
assert result == " hello" # Should default to space
def test_empty_fill_character(self):
"""Test with empty fill character"""
result = left_fill("hello", 10, "")
assert result == " hello" # Should default to space
def test_empty_string(self):
"""Test with empty string"""
result = left_fill("", 5)
assert result == " "
class TestFormatNumber:
"""Tests for format_number function"""
def test_integer_default_precision(self):
"""Test formatting integer with default precision"""
result = format_number(1234)
assert result == "1,234"
def test_float_default_precision(self):
"""Test formatting float with default precision"""
result = format_number(1234.56)
assert result == "1,235" # Should round to nearest integer
def test_with_precision(self):
"""Test formatting with specified precision"""
result = format_number(1234.5678, 2)
assert result == "1,234.57"
def test_large_number(self):
"""Test formatting large number"""
result = format_number(1234567.89, 2)
assert result == "1,234,567.89"
def test_zero(self):
"""Test formatting zero"""
result = format_number(0)
assert result == "0"
def test_negative_number(self):
"""Test formatting negative number"""
result = format_number(-1234.56, 2)
assert result == "-1,234.56"
def test_negative_precision(self):
"""Test with negative precision (should default to 0)"""
result = format_number(1234.56, -1)
assert result == "1,235"
def test_excessive_precision(self):
"""Test with precision > 100 (should default to 0)"""
result = format_number(1234.56, 101)
assert result == "1,235"
def test_precision_boundary_values(self):
"""Test precision boundary values"""
# Test precision = 0 (should work)
result = format_number(1234.56, 0)
assert result == "1,235"
# Test precision = 100 (should work)
result = format_number(1234.56, 100)
assert "1,234.56" in result # Will have many trailing zeros
def test_small_decimal(self):
"""Test formatting small decimal number"""
result = format_number(0.123456, 4)
assert result == "0.1235"
def test_very_small_number(self):
"""Test formatting very small number"""
result = format_number(0.001, 3)
assert result == "0.001"
# Additional integration tests
class TestIntegration:
"""Integration tests combining functions"""
def test_format_and_fill(self):
"""Test formatting a number then left filling"""
formatted = format_number(1234.56, 2)
result = left_fill(formatted, 15)
assert result.endswith("1,234.56")
assert len(result) == 15
def test_format_and_shorten(self):
"""Test formatting a large number then shortening"""
formatted = format_number(123456789.123, 3)
result = shorten_string(formatted, 10)
assert len(result) <= 10
# Fixtures for parameterized tests
@pytest.mark.parametrize("input_str,length,expected", [
("hello", 10, "hello"),
("hello world", 5, "h [~]"),
("test", 4, "test"),
("", 5, ""),
])
def test_shorten_string_parametrized(input_str: str, length: int, expected: str):
"""Parametrized test for shorten_string"""
result = shorten_string(input_str, length, hard_shorten=True)
if expected.endswith(" [~]"):
assert result.endswith(" [~]")
assert len(result) == length
else:
assert result == expected
@pytest.mark.parametrize("number,precision,expected", [
(1000, 0, "1,000"),
(1234.56, 2, "1,234.56"),
(0, 1, "0.0"),
(-500, 0, "-500"),
])
def test_format_number_parametrized(number: float | int, precision: int, expected: str):
"""Parametrized test for format_number"""
assert format_number(number, precision) == expected
# __END__

View File

@@ -0,0 +1,157 @@
"""
PyTest: string_handling/timestamp_strings
"""
from datetime import datetime
from unittest.mock import patch, MagicMock
from zoneinfo import ZoneInfo
import pytest
# Assuming the class is in a file called timestamp_strings.py
from corelibs.string_handling.timestamp_strings import TimestampStrings
class TestTimestampStrings:
"""Test suite for TimestampStrings class"""
def test_default_initialization(self):
"""Test initialization with default timezone"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 15, 30, 45)
mock_datetime.now.return_value = mock_now
ts = TimestampStrings()
assert ts.time_zone == 'Asia/Tokyo'
assert ts.timestamp_now == mock_now
assert ts.today == '2023-12-25'
assert ts.timestamp == '2023-12-25 15:30:45'
assert ts.timestamp_file == '2023-12-25_153045'
def test_custom_timezone_initialization(self):
"""Test initialization with custom timezone"""
custom_tz = 'America/New_York'
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 15, 30, 45)
mock_datetime.now.return_value = mock_now
ts = TimestampStrings(time_zone=custom_tz)
assert ts.time_zone == custom_tz
assert ts.timestamp_now == mock_now
def test_invalid_timezone_raises_error(self):
"""Test that invalid timezone raises ValueError"""
invalid_tz = 'Invalid/Timezone'
with pytest.raises(ValueError) as exc_info:
TimestampStrings(time_zone=invalid_tz)
assert 'Zone could not be loaded [Invalid/Timezone]' in str(exc_info.value)
def test_timestamp_formats(self):
"""Test various timestamp format outputs"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
# Mock both datetime.now() calls
mock_now = datetime(2023, 12, 25, 9, 5, 3)
mock_now_tz = datetime(2023, 12, 25, 23, 5, 3, tzinfo=ZoneInfo('Asia/Tokyo'))
mock_datetime.now.side_effect = [mock_now, mock_now_tz]
ts = TimestampStrings()
assert ts.today == '2023-12-25'
assert ts.timestamp == '2023-12-25 09:05:03'
assert ts.timestamp_file == '2023-12-25_090503'
assert 'JST' in ts.timestamp_tz or 'Asia/Tokyo' in ts.timestamp_tz
def test_different_timezones_produce_different_results(self):
"""Test that different timezones produce different timestamp_tz values"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 12, 0, 0)
mock_datetime.now.return_value = mock_now
# Create instances with different timezones
ts_tokyo = TimestampStrings(time_zone='Asia/Tokyo')
ts_ny = TimestampStrings(time_zone='America/New_York')
# The timezone-aware timestamps should be different
assert ts_tokyo.time_zone != ts_ny.time_zone
# Note: The actual timestamp_tz values will depend on the mocked datetime
def test_class_default_timezone(self):
"""Test that class default timezone is correctly set"""
assert TimestampStrings.TIME_ZONE == 'Asia/Tokyo'
def test_none_timezone_uses_default(self):
"""Test that passing None for timezone uses class default"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 15, 30, 45)
mock_datetime.now.return_value = mock_now
ts = TimestampStrings(time_zone=None)
assert ts.time_zone == 'Asia/Tokyo'
def test_timestamp_file_format_no_colons(self):
"""Test that timestamp_file format doesn't contain colons (safe for filenames)"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 15, 30, 45)
mock_datetime.now.return_value = mock_now
ts = TimestampStrings()
assert ':' not in ts.timestamp_file
assert ' ' not in ts.timestamp_file
assert ts.timestamp_file == '2023-12-25_153045'
def test_multiple_instances_independent(self):
"""Test that multiple instances don't interfere with each other"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 15, 30, 45)
mock_datetime.now.return_value = mock_now
ts1 = TimestampStrings(time_zone='Asia/Tokyo')
ts2 = TimestampStrings(time_zone='Europe/London')
assert ts1.time_zone == 'Asia/Tokyo'
assert ts2.time_zone == 'Europe/London'
assert ts1.time_zone != ts2.time_zone
@patch('corelibs.string_handling.timestamp_strings.ZoneInfo')
def test_zoneinfo_called_correctly(self, mock_zoneinfo: MagicMock):
"""Test that ZoneInfo is called with correct timezone"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 15, 30, 45)
mock_datetime.now.return_value = mock_now
custom_tz = 'Europe/Paris'
ts = TimestampStrings(time_zone=custom_tz)
assert ts.time_zone == custom_tz
mock_zoneinfo.assert_called_with(custom_tz)
def test_edge_case_midnight(self):
"""Test timestamp formatting at midnight"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2023, 12, 25, 0, 0, 0)
mock_datetime.now.return_value = mock_now
ts = TimestampStrings()
assert ts.timestamp == '2023-12-25 00:00:00'
assert ts.timestamp_file == '2023-12-25_000000'
def test_edge_case_new_year(self):
"""Test timestamp formatting at new year"""
with patch('corelibs.string_handling.timestamp_strings.datetime') as mock_datetime:
mock_now = datetime(2024, 1, 1, 0, 0, 0)
mock_datetime.now.return_value = mock_now
ts = TimestampStrings()
assert ts.today == '2024-01-01'
assert ts.timestamp == '2024-01-01 00:00:00'
# __END__

View File

@@ -0,0 +1,241 @@
"""
var helpers
"""
# ADDED 2025/7/11 Replace 'your_module' with actual module name
from typing import Any
import pytest
from corelibs.var_handling.var_helpers import is_int, is_float, str_to_bool
class TestIsInt:
"""Test cases for is_int function"""
def test_valid_integers(self):
"""Test with valid integer strings"""
assert is_int("123") is True
assert is_int("0") is True
assert is_int("-456") is True
assert is_int("+789") is True
assert is_int("000") is True
def test_invalid_integers(self):
"""Test with invalid integer strings"""
assert is_int("12.34") is False
assert is_int("abc") is False
assert is_int("12a") is False
assert is_int("") is False
assert is_int(" ") is False
assert is_int("12.0") is False
assert is_int("1e5") is False
def test_numeric_types(self):
"""Test with actual numeric types"""
assert is_int(123) is True
assert is_int(0) is True
assert is_int(-456) is True
assert is_int(12.34) is True # float can be converted to int
assert is_int(12.0) is True
def test_other_types(self):
"""Test with other data types"""
assert is_int(None) is False
assert is_int([]) is False
assert is_int({}) is False
assert is_int(True) is True # bool is subclass of int
assert is_int(False) is True
class TestIsFloat:
"""Test cases for is_float function"""
def test_valid_floats(self):
"""Test with valid float strings"""
assert is_float("12.34") is True
assert is_float("0.0") is True
assert is_float("-45.67") is True
assert is_float("+78.9") is True
assert is_float("123") is True # integers are valid floats
assert is_float("0") is True
assert is_float("1e5") is True
assert is_float("1.5e-10") is True
assert is_float("inf") is True
assert is_float("-inf") is True
assert is_float("nan") is True
def test_invalid_floats(self):
"""Test with invalid float strings"""
assert is_float("abc") is False
assert is_float("12.34.56") is False
assert is_float("12a") is False
assert is_float("") is False
assert is_float(" ") is False
assert is_float("12..34") is False
def test_numeric_types(self):
"""Test with actual numeric types"""
assert is_float(123) is True
assert is_float(12.34) is True
assert is_float(0) is True
assert is_float(-45.67) is True
def test_other_types(self):
"""Test with other data types"""
assert is_float(None) is False
assert is_float([]) is False
assert is_float({}) is False
assert is_float(True) is True # bool can be converted to float
assert is_float(False) is True
class TestStrToBool:
"""Test cases for str_to_bool function"""
def test_valid_true_strings(self):
"""Test with valid true strings"""
assert str_to_bool("True") is True
assert str_to_bool("true") is True
def test_valid_false_strings(self):
"""Test with valid false strings"""
assert str_to_bool("False") is False
assert str_to_bool("false") is False
def test_invalid_strings(self):
"""Test with invalid boolean strings"""
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("TRUE")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("FALSE")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("yes")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("no")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("1")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("0")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool(" True")
with pytest.raises(ValueError, match="Invalid boolean string"):
str_to_bool("True ")
def test_error_message_content(self):
"""Test that error messages contain the invalid input"""
with pytest.raises(ValueError) as exc_info:
str_to_bool("invalid")
assert "Invalid boolean string: invalid" in str(exc_info.value)
def test_case_sensitivity(self):
"""Test that function is case sensitive"""
with pytest.raises(ValueError):
str_to_bool("TRUE")
with pytest.raises(ValueError):
str_to_bool("True ") # with space
with pytest.raises(ValueError):
str_to_bool(" True") # with space
# Additional edge case tests
class TestEdgeCases:
"""Test edge cases and special scenarios"""
def test_is_int_with_whitespace(self):
"""Test is_int with whitespace (should work due to int() behavior)"""
assert is_int(" 123 ") is True
assert is_int("\t456\n") is True
def test_is_float_with_whitespace(self):
"""Test is_float with whitespace (should work due to float() behavior)"""
assert is_float(" 12.34 ") is True
assert is_float("\t45.67\n") is True
def test_large_numbers(self):
"""Test with very large numbers"""
large_int = "123456789012345678901234567890"
assert is_int(large_int) is True
assert is_float(large_int) is True
def test_scientific_notation(self):
"""Test scientific notation"""
assert is_int("1e5") is False # int() doesn't handle scientific notation
assert is_float("1e5") is True
assert is_float("1.5e-10") is True
assert is_float("2E+3") is True
# Parametrized tests for more comprehensive coverage
class TestParametrized:
"""Parametrized tests for better coverage"""
@pytest.mark.parametrize("value,expected", [
("123", True),
("0", True),
("-456", True),
("12.34", False),
("abc", False),
("", False),
(123, True),
(12.5, True),
(None, False),
])
def test_is_int_parametrized(self, value: Any, expected: bool):
"""Test"""
assert is_int(value) == expected
@pytest.mark.parametrize("value,expected", [
("12.34", True),
("123", True),
("0", True),
("-45.67", True),
("inf", True),
("nan", True),
("abc", False),
("", False),
(12.34, True),
(123, True),
(None, False),
])
def test_is_float_parametrized(self, value: Any, expected: bool):
"""test"""
assert is_float(value) == expected
@pytest.mark.parametrize("value,expected", [
("True", True),
("true", True),
("False", False),
("false", False),
])
def test_str_to_bool_valid_parametrized(self, value: Any, expected: bool):
"""test"""
assert str_to_bool(value) == expected
@pytest.mark.parametrize("invalid_value", [
"TRUE",
"FALSE",
"yes",
"no",
"1",
"0",
"",
" True",
"True ",
"invalid",
])
def test_str_to_bool_invalid_parametrized(self, invalid_value: Any):
"""test"""
with pytest.raises(ValueError):
str_to_bool(invalid_value)

197
uv.lock generated
View File

@@ -1,41 +1,59 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.13"
[[package]]
name = "certifi"
version = "2025.6.15"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "corelibs"
version = "0.3.0"
version = "0.22.6"
source = { editable = "." }
dependencies = [
{ name = "jmespath" },
@@ -43,6 +61,12 @@ dependencies = [
{ name = "requests" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "jmespath", specifier = ">=1.0.1" },
@@ -50,6 +74,65 @@ requires-dist = [
{ name = "requests", specifier = ">=2.32.4" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
]
[[package]]
name = "coverage"
version = "7.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662, upload-time = "2025-08-23T14:42:44.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106, upload-time = "2025-08-23T14:41:15.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353, upload-time = "2025-08-23T14:41:16.656Z" },
{ url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350, upload-time = "2025-08-23T14:41:18.128Z" },
{ url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955, upload-time = "2025-08-23T14:41:19.577Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230, upload-time = "2025-08-23T14:41:20.959Z" },
{ url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387, upload-time = "2025-08-23T14:41:22.644Z" },
{ url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280, upload-time = "2025-08-23T14:41:24.061Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894, upload-time = "2025-08-23T14:41:26.165Z" },
{ url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536, upload-time = "2025-08-23T14:41:27.694Z" },
{ url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330, upload-time = "2025-08-23T14:41:29.081Z" },
{ url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961, upload-time = "2025-08-23T14:41:30.511Z" },
{ url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819, upload-time = "2025-08-23T14:41:31.962Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040, upload-time = "2025-08-23T14:41:33.472Z" },
{ url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374, upload-time = "2025-08-23T14:41:34.914Z" },
{ url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551, upload-time = "2025-08-23T14:41:36.333Z" },
{ url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776, upload-time = "2025-08-23T14:41:38.25Z" },
{ url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326, upload-time = "2025-08-23T14:41:40.343Z" },
{ url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090, upload-time = "2025-08-23T14:41:42.106Z" },
{ url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217, upload-time = "2025-08-23T14:41:43.591Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194, upload-time = "2025-08-23T14:41:45.051Z" },
{ url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258, upload-time = "2025-08-23T14:41:46.44Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521, upload-time = "2025-08-23T14:41:47.882Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090, upload-time = "2025-08-23T14:41:49.327Z" },
{ url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365, upload-time = "2025-08-23T14:41:50.796Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413, upload-time = "2025-08-23T14:41:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943, upload-time = "2025-08-23T14:41:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301, upload-time = "2025-08-23T14:41:56.528Z" },
{ url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302, upload-time = "2025-08-23T14:41:58.171Z" },
{ url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237, upload-time = "2025-08-23T14:41:59.703Z" },
{ url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726, upload-time = "2025-08-23T14:42:01.343Z" },
{ url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825, upload-time = "2025-08-23T14:42:03.263Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618, upload-time = "2025-08-23T14:42:05.037Z" },
{ url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199, upload-time = "2025-08-23T14:42:06.662Z" },
{ url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833, upload-time = "2025-08-23T14:42:08.262Z" },
{ url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048, upload-time = "2025-08-23T14:42:10.247Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549, upload-time = "2025-08-23T14:42:11.811Z" },
{ url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715, upload-time = "2025-08-23T14:42:13.505Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969, upload-time = "2025-08-23T14:42:15.422Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408, upload-time = "2025-08-23T14:42:16.971Z" },
{ url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168, upload-time = "2025-08-23T14:42:18.512Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317, upload-time = "2025-08-23T14:42:20.005Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600, upload-time = "2025-08-23T14:42:22.027Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714, upload-time = "2025-08-23T14:42:23.616Z" },
{ url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735, upload-time = "2025-08-23T14:42:25.156Z" },
{ url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736, upload-time = "2025-08-23T14:42:43.145Z" },
]
[[package]]
name = "idna"
version = "3.10"
@@ -59,6 +142,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jmespath"
version = "1.0.1"
@@ -68,6 +160,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psutil"
version = "7.0.0"
@@ -83,9 +193,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "pytest-cov"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
]
[[package]]
name = "requests"
version = "2.32.4"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -93,9 +242,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]