Compare commits

...

167 Commits

Author SHA1 Message Date
Clemens Schwaighofer
282fe1f7c0 v0.37.0: Log add from lookup for strings in Console config, move var helpers, datetime, enum to stand alone libs 2025-11-19 13:48:29 +09:00
Clemens Schwaighofer
afce5043e4 Cleanup other functions to use extern corelibs
Remove tests for parts that have moved to stand alone libraries
2025-11-19 13:46:34 +09:00
Clemens Schwaighofer
5996bb1fc0 Add Log ConsoleFormatSettings.from_string static method to get settings by name with default option
To help set from config or command line with fallback
2025-11-19 13:45:26 +09:00
Clemens Schwaighofer
06a17d7c30 Switch datetime handling, var handling to corelibs libraries
Use external corelib libraries for datetime handling and var handling enum base.
2025-11-19 13:13:32 +09:00
Clemens Schwaighofer
af7633183c v0.36.0: Log console format settings with bitwise mask 2025-11-19 11:31:50 +09:00
Clemens Schwaighofer
1280b2f855 Log switch to bitwise flag settings for console format type
Has the following settings
TIME, TIME_SECONDS, TIME_MILLISECONDS, TIME_MICROSECONDS: enable time output in different formats
TIME and TIME_MILLISECONDS are equivalent, if multiple are set the smallest precision wins
TIMEZONE: add time zone to time output
NAME: log group name
FILE: short file name
FUNCTION: function name
LINENO: line number

There is a class with quick grouped settings
ConsoleFormatSettings
ALL: all options enabled, time is in milliseconds
CONDENSED: time without time zone, file and line number
MINIMAL: only time without time zone
BARE: only the message, no other info
2025-11-19 11:25:49 +09:00
Clemens Schwaighofer
2e0b1f5951 v0.35.2: Sync miss for Log file format change 2025-11-18 15:55:49 +09:00
Clemens Schwaighofer
548d7491b8 Merge branch 'development' 2025-11-18 15:55:05 +09:00
Clemens Schwaighofer
ad99115544 v0.35.1: Log move pid into path name block, remove double filename 2025-11-18 15:51:29 +09:00
Clemens Schwaighofer
52919cbc49 Log: move process id to front of pathname in log format
The previous filename:pid has been removed, the filename is part of the pathname.
No need for double filename info and wasting space in the log line.
2025-11-18 15:49:46 +09:00
Clemens Schwaighofer
7f2dc13c31 v0.35.0: Logging update with output format settings for console logging 2025-11-18 15:37:13 +09:00
Clemens Schwaighofer
592652cff1 Update logging with console output format changes
"console_format_type" with "normal", "condensed", "minimal" options
This sets the format of the console output, controlling the amount of detail shown.
normal show log title, file, function and line number
condensed show file and line number only
minimal shows only timestamp, log level and message
Default is normal

"console_iso_precision" with "seconds", "milliseconds", "microseconds" options
This sets the precision of the ISO timestamp in console logs.
Default is milliseconds

The timestamp output is now ISO8601 formatatted with time zone.
2025-11-18 15:31:16 +09:00
Clemens Schwaighofer
6a1724695e Fix pyproject settings by removing explicit=true 2025-11-11 18:05:07 +09:00
Clemens Schwaighofer
037210756e v0.34.0: add BOM check for files 2025-11-06 18:22:45 +09:00
Clemens Schwaighofer
4e78d83092 Add checks for BOM encoding in files 2025-11-06 18:21:32 +09:00
Clemens Schwaighofer
0e6331fa6a v0.33.0: datetime parsing update 2025-11-06 13:26:07 +09:00
Clemens Schwaighofer
c98c5df63c Update datetime parse helper
Allow non T in isotime format, add non T normal datetime parsing
2025-11-06 13:24:27 +09:00
Clemens Schwaighofer
0981c74da9 v0.32.0: add email sending 2025-10-27 11:22:11 +09:00
Clemens Schwaighofer
31518799f6 README update 2025-10-27 11:20:46 +09:00
Clemens Schwaighofer
e8b4b9b48e Add send email class 2025-10-27 11:19:38 +09:00
Clemens Schwaighofer
cd06272b38 v0.31.1: fix dict_helper file name to dict_helpers 2025-10-27 10:42:45 +09:00
Clemens Schwaighofer
c5ab4352e3 Fix name dict_helper to dict_helpers
So we have the same name for everyhing
2025-10-27 10:40:12 +09:00
Clemens Schwaighofer
0da4a6b70a v0.31.0: Add tests, move files to final location 2025-10-27 10:29:47 +09:00
Clemens Schwaighofer
11c5f3387c README info update 2025-10-27 10:17:32 +09:00
Clemens Schwaighofer
3ed0171e17 Readme update 2025-10-27 10:09:27 +09:00
Clemens Schwaighofer
c7b38b0d70 Add ignore list for coverage (pytest), rename json default function to default_isoformat 2025-10-27 10:05:31 +09:00
Clemens Schwaighofer
caf0039de4 script handling and string handling 2025-10-24 21:19:41 +09:00
Clemens Schwaighofer
2637e1e42c Tests for requests handling 2025-10-24 19:00:07 +09:00
Clemens Schwaighofer
d0a1673965 Add pytest for logging 2025-10-24 18:33:25 +09:00
Clemens Schwaighofer
07e5d23f72 Add jmespath tests 2025-10-24 16:47:46 +09:00
Clemens Schwaighofer
fb4fdb6857 iterator tests added 2025-10-24 16:36:42 +09:00
Clemens Schwaighofer
d642a13b6e file handling tests, move progress to script handling
Progress is not only file, but process progress in a script
2025-10-24 16:07:47 +09:00
Clemens Schwaighofer
8967031f91 csv interface minor update to use the csv exceptions for errors 2025-10-24 15:45:09 +09:00
Clemens Schwaighofer
89caada4cc debug handling pytests added 2025-10-24 15:44:51 +09:00
Clemens Schwaighofer
b3616269bc csv writer to csv interface with reader class
But this is more for reference and should not be considered final
Missing things are like
- all values to private
- reader interface to parts
- value check for delimiter, quotechar, etc
2025-10-24 14:43:29 +09:00
Clemens Schwaighofer
4fa22813ce Add tests for settings loader 2025-10-24 14:19:05 +09:00
Clemens Schwaighofer
3ee3a0dce0 Tests for check_handling/regex_constants 2025-10-24 13:45:46 +09:00
Clemens Schwaighofer
1226721bc0 v0.30.0: add datetime and timestamp handling 2025-10-24 10:07:28 +09:00
Clemens Schwaighofer
a76eae0cc7 Add datetime helpers and move all time/date time datetime_handling folder
previous string_handling located datetime and timestamp files have been moved
to the datetime handling folder

Update readme file with more information about currently covered areas
2025-10-24 10:03:04 +09:00
Clemens Schwaighofer
53cf2a6f48 Add prepare_url_slash to string_helpers.py and tests
Function cleans up url paths (without domain) by ensuring they start with a single slash and removing double slashes.
2025-10-23 15:47:19 +09:00
Clemens Schwaighofer
fe69530b38 Add a simple add key entry to dictionary 2025-10-23 15:31:52 +09:00
Clemens Schwaighofer
bf83c1c394 v0.29.0: Add SQLite IO class 2025-10-23 15:24:17 +09:00
Clemens Schwaighofer
84ce43ab93 Add SQLite IO class
This is a very basic class without many helper functions added yet
Add to the CoreLibs so when we develop it further it can be used by all projects
2025-10-23 15:22:12 +09:00
Clemens Schwaighofer
5e0765ee24 Rename the enum_test to enum_base for the test run file 2025-10-23 14:32:52 +09:00
Clemens Schwaighofer
6edf9398b7 v0.28.0: Enum base class added 2025-10-23 13:48:57 +09:00
Clemens Schwaighofer
30bf9c1bcb Add Enum base class
A helper class for handling enum classes with various lookup helpers
2025-10-23 13:47:13 +09:00
Clemens Schwaighofer
0b59f3cc7a v0.27.0: add json replace content method 2025-10-23 13:22:19 +09:00
Clemens Schwaighofer
2544fad9ce Add json helper function json_replace
Function can replace content for a json path string in a dictionary
2025-10-23 13:20:40 +09:00
Clemens Schwaighofer
e579ef5834 v0.26.0: Add Symmetric Encryption 2025-10-23 11:48:52 +09:00
Clemens Schwaighofer
543e9766a1 Add symmetric encryption and tests 2025-10-23 11:47:41 +09:00
Clemens Schwaighofer
4c3611aba7 v0.25.1: add missing jmespath exception check 2025-10-09 16:43:53 +09:00
Clemens Schwaighofer
dadc14563a jmespath search check update 2025-10-09 16:42:41 +09:00
Clemens Schwaighofer
c1eda7305b jmespath search, catch JMESPathTypeError error
This error can happend if we search for a key and try to make a value compare and the key does not exist.
Perhaps also when they key should return a list
2025-10-09 16:39:54 +09:00
Clemens Schwaighofer
2f4e236350 v0.25.0: add create datetime iso format 2025-10-08 16:09:29 +09:00
Clemens Schwaighofer
b858936c68 Add test file for datetime helpers 2025-10-08 16:08:23 +09:00
Clemens Schwaighofer
78ce30283e Version update in uv.lock (merge from master) 2025-10-08 15:58:58 +09:00
Clemens Schwaighofer
f85fbb86af Add iso datetime create with time zone support
The time zone check is for short mappings is limited, it is recommended
to use full TZ names like "Europe/Berlin", "Asia/Tokyo", "America/New_York"
2025-10-08 15:57:57 +09:00
Clemens Schwaighofer
ed22105ec8 v0.24.4: Fix Zone info data in TimestampStrings class 2025-09-25 15:54:54 +09:00
Clemens Schwaighofer
7c5af588c7 Update the TimestampStrings zone info handling
time_zone is the string version of the time zone data
time_zone_zi is the ZoneInfo object of above
2025-09-25 15:53:26 +09:00
Clemens Schwaighofer
2690a285d9 v0.24.3: Pytest fixes 2025-09-25 15:38:29 +09:00
Clemens Schwaighofer
bb60a570d0 Change the TimestampStrings check to check for str instead of not ZoneInfo.
This fixes the pytest problem which threw:
TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union

during Mocking
2025-09-25 15:36:47 +09:00
Clemens Schwaighofer
ca0ab2d7d1 v0.24.2: TimestampString allows ZoneInfo object as zone name 2025-09-25 15:16:19 +09:00
Clemens Schwaighofer
38bae7fb46 TimestampStrings allows ZoneInfo object as time_zone parameter
So we can use pre-parsed data

Some tests for parsing settings, timestamp output
2025-09-25 15:14:40 +09:00
Clemens Schwaighofer
14466c3ff8 v0.24.1: allow negative timestamp convert to seconds, add pytests for this function 2025-09-24 15:27:15 +09:00
Clemens Schwaighofer
fe824f9fb4 Merge branch 'development' 2025-09-24 15:26:22 +09:00
Clemens Schwaighofer
ef5981b473 convert_to_seconds allow negative time strings and add pytests 2025-09-24 15:25:53 +09:00
Clemens Schwaighofer
7d1ee70cf6 v0.24.0: Add timestamp seconds to human readable 2025-09-19 10:25:44 +09:00
Clemens Schwaighofer
7c72d99619 add pytests for seconds to human readable convert 2025-09-19 10:17:36 +09:00
Clemens Schwaighofer
b32887a6d8 Add time in seconds convert to human readable format 2025-09-19 09:57:51 +09:00
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
125 changed files with 24217 additions and 649 deletions

View File

@@ -1,26 +1,56 @@
# CoreLibs for Python
This is a pip package that can be installed into any project and covers the following pars
> [!warning]
> This is pre-production, location of methods and names of paths can change
>
> This will be split up into modules per file and this will be just a collection holder
> See [Deprecated](#deprecated) below
This is a pip package that can be installed into any project and covers the following parts
- logging update with exception logs
- requests wrapper for easier auth pass on access
- dict fingerprinting
- sending email
- jmespath search
- dump outputs for data
- json helpers for conten replace and output
- dump outputs for data for debugging
- progress printing
- string formatting, time creation, byte formatting
- Enum base class
- SQLite simple IO class
- Symmetric encryption
## Current list
- csv_handling: csv dict writer helper
- config_handling: simple INI config file data loader with check/convert/etc
- csv_interface: csv dict writer/reader helper
- debug_handling: various debug helpers like data dumper, timer, utilization, etc
- db_handling: SQLite interface class
- encyption_handling: symmetric encryption
- email_handling: simple email sending
- 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)
- json_handling: jmespath support and json date support, replace content in dict with json paths
- 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, double byte string format, etc
- string_handling: byte format, datetime format, datetime compare, hashing, string formats for numbers, double byte string format, etc
- var_handling: var type checkers, enum base class
## Unfinished
- csv_handling/csv_interface: The CSV DictWriter interface is just in a very basic way implemented
- script_handling/script_helpers: No idea if there is need for this, tests are written but not finished
## Deprecated
All content in this module will move to stand alone libraries, as of now the following entries have moved and will throw deprecated warnings if used
- var_handling.enum_base: corelibs-enum-base
- var_handling.var_helpers: corelibs-var
- datetime_handling: corelibs-datetime
- string_handling.text_colors: corelibs-text-colors
## UV setup
@@ -32,7 +62,7 @@ Have the following setup in `project.toml`
```toml
[[tool.uv.index]]
name = "egra-gitea"
name = "opj-pypi"
url = "https://git.egplusww.jp/api/packages/PyPI/pypi/simple/"
publish-url = "https://git.egplusww.jp/api/packages/PyPI/pypi"
explicit = true
@@ -40,15 +70,15 @@ explicit = true
```sh
uv build
uv publish --index egra-gitea --token <gitea token>
uv publish --index opj-pypi --token <gitea token>
```
## Test package
## Use package
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 -- python -c "import corelibs"
uv run --with corelibs --index opj-pypi=https://git.egplusww.jp/api/packages/PyPI/pypi/simple/ --no-project -- python -c "import corelibs"
```
### Python tests
@@ -65,22 +95,15 @@ Get a coverate report
```sh
uv run pytest --cov=corelibs
uv run pytest --cov=corelibs --cov-report=term-missing
```
### Other tests
In the test-run folder usage and run tests are located
In the test-run folder usage and run tests are located, runt them below
```sh
uv run test-run/progress/progress_test.py
```
```sh
uv run test-run/double_byte_string_format/double_byte_string_format.py
```
```sh
uv run test-run/timestamp_strings/timestamp_strings.py
uv run test-run/<script>
```
## How to install in another project
@@ -88,18 +111,12 @@ uv run test-run/timestamp_strings/timestamp_strings.py
This will also add the index entry
```sh
uv add corelibs --index egra-gitea=https://git.egplusww.jp/api/packages/PyPI/pypi/simple/
uv add corelibs --index opj-pypi=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

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,32 +1,42 @@
# MARK: Project info
[project]
name = "corelibs"
version = "0.8.0"
version = "0.37.0"
description = "Collection of utils for Python scripts"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"corelibs-datetime>=1.0.1",
"corelibs-enum-base>=1.0.0",
"corelibs-var>=1.0.0",
"cryptography>=46.0.3",
"jmespath>=1.0.1",
"jsonpath-ng>=1.7.0",
"psutil>=7.0.0",
"requests>=2.32.4",
]
# set this to disable publish to pypi (pip)
# classifiers = ["Private :: Do Not Upload"]
# MARK: build target
[[tool.uv.index]]
name = "egra-gitea"
url = "https://git.egplusww.jp/api/packages/PyPI/pypi/simple/"
publish-url = "https://git.egplusww.jp/api/packages/PyPI/pypi"
explicit = true
# MARK: build system
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# set this to disable publish to pypi (pip)
# classifiers = ["Private :: Do Not Upload"]
# MARK: build target
[[tool.uv.index]]
name = "opj-pypi"
url = "https://git.egplusww.jp/api/packages/PyPI/pypi/simple/"
publish-url = "https://git.egplusww.jp/api/packages/PyPI/pypi"
[tool.uv.sources]
corelibs-enum-base = { index = "opj-pypi" }
corelibs-datetime = { index = "opj-pypi" }
corelibs-var = { index = "opj-pypi" }
[dependency-groups]
dev = [
"deepdiff>=8.6.1",
"pytest>=8.4.1",
"pytest-cov>=6.2.1",
]
@@ -53,6 +63,38 @@ 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/")'
# MARK: Testing
[tool.pytest.ini_options]
testpaths = [
"tests",
]
[tool.coverage.run]
omit = [
"*/tests/*",
"*/test_*.py",
"*/__init__.py"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"def __str__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:"
]
exclude_also = [
"def __.*__\\(",
"def __.*\\(",
"def _.*\\(",
]

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_var.var_helpers import is_int, is_float, str_to_bool
from corelibs.logging_handling.log import Log
from corelibs.iterator_handling.list_helpers import convert_to_list, is_list_in_list
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

@@ -0,0 +1,155 @@
"""
Write to CSV file
- each class set is one file write with one header set
"""
from typing import Any, Sequence
from pathlib import Path
from collections import Counter
import csv
from corelibs.exceptions.csv_exceptions import (
NoCsvReader, CompulsoryCsvHeaderCheckFailed, CsvHeaderDataMissing
)
DELIMITER = ","
QUOTECHAR = '"'
# type: _QuotingType
QUOTING = csv.QUOTE_MINIMAL
class CsvWriter:
"""
write to a CSV file
"""
def __init__(
self,
file_name: Path,
header_mapping: dict[str, str],
header_order: list[str] | None = None,
delimiter: str = DELIMITER,
quotechar: str = QUOTECHAR,
quoting: Any = QUOTING,
):
self.__file_name = file_name
# Key: index for write for the line dict, Values: header entries
self.header_mapping = header_mapping
self.header: Sequence[str] = list(header_mapping.values())
self.__delimiter = delimiter
self.__quotechar = quotechar
self.__quoting = quoting
self.csv_file_writer = self.__open_csv(header_order)
def __open_csv(self, header_order: list[str] | None) -> csv.DictWriter[str]:
"""
open csv file for writing, write headers
Note that if there is no header_order set we use the order in header dictionary
Arguments:
line {list[str] | None} -- optional dedicated header order
Returns:
csv.DictWriter[str] | None: _description_
"""
# if header order is set, make sure all header value fields exist
if not self.header:
raise CsvHeaderDataMissing("No header data available to write CSV file")
header_values = self.header
if header_order is not None:
if Counter(header_values) != Counter(header_order):
raise CompulsoryCsvHeaderCheckFailed(
"header order does not match header values: "
f"{', '.join(header_values)} != {', '.join(header_order)}"
)
header_values = header_order
# no duplicates
if len(header_values) != len(set(header_values)):
raise CompulsoryCsvHeaderCheckFailed(f"Header must have unique values only: {', '.join(header_values)}")
try:
fp = open(
self.__file_name,
"w", encoding="utf-8"
)
csv_file_writer = csv.DictWriter(
fp,
fieldnames=header_values,
delimiter=self.__delimiter,
quotechar=self.__quotechar,
quoting=self.__quoting,
)
csv_file_writer.writeheader()
return csv_file_writer
except OSError as err:
raise NoCsvReader(f"Could not open CSV file for writing: {err}") from err
def write_csv(self, line: dict[str, str]) -> None:
"""
write member csv line
Arguments:
line {dict[str, str]} -- _description_
Returns:
bool -- _description_
"""
csv_row: dict[str, Any] = {}
# only write entries that are in the header list
for key, value in self.header_mapping.items():
csv_row[value] = line[key]
self.csv_file_writer.writerow(csv_row)
class CsvReader:
"""
read from a CSV file
"""
def __init__(
self,
file_name: Path,
header_check: Sequence[str] | None = None,
delimiter: str = DELIMITER,
quotechar: str = QUOTECHAR,
quoting: Any = QUOTING,
):
self.__file_name = file_name
self.__header_check = header_check
self.__delimiter = delimiter
self.__quotechar = quotechar
self.__quoting = quoting
self.header: Sequence[str] | None = None
self.csv_file_reader = self.__open_csv()
def __open_csv(self) -> csv.DictReader[str]:
"""
open csv file for reading
Returns:
csv.DictReader | None: _description_
"""
try:
fp = open(
self.__file_name,
"r", encoding="utf-8"
)
csv_file_reader = csv.DictReader(
fp,
delimiter=self.__delimiter,
quotechar=self.__quotechar,
quoting=self.__quoting,
)
self.header = csv_file_reader.fieldnames
if not self.header:
raise CsvHeaderDataMissing("No header data available in CSV file")
if self.__header_check is not None:
header_diff = set(self.__header_check).difference(set(self.header or []))
if header_diff:
raise CompulsoryCsvHeaderCheckFailed(
f"CSV header does not match expected header: {', '.join(header_diff)} missing"
)
return csv_file_reader
except OSError as err:
raise NoCsvReader(f"Could not open CSV file for reading: {err}") from err
# __END__

View File

@@ -1,93 +0,0 @@
"""
Write to CSV file
- each class set is one file write with one header set
"""
from typing import Any
from pathlib import Path
from collections import Counter
import csv
class CsvWriter:
"""
write to a CSV file
"""
def __init__(
self,
path: Path,
file_name: str,
header: dict[str, str],
header_order: list[str] | None = None
):
self.path = path
self.file_name = file_name
# Key: index for write for the line dict, Values: header entries
self.header = header
self.csv_file_writer = self.__open_csv(header_order)
def __open_csv(self, header_order: list[str] | None) -> 'csv.DictWriter[str] | None':
"""
open csv file for writing, write headers
Note that if there is no header_order set we use the order in header dictionary
Arguments:
line {list[str] | None} -- optional dedicated header order
Returns:
csv.DictWriter[str] | None: _description_
"""
# if header order is set, make sure all header value fields exist
header_values = self.header.values()
if header_order is not None:
if Counter(header_values) != Counter(header_order):
print(
"header order does not match header values: "
f"{', '.join(header_values)} != {', '.join(header_order)}"
)
return None
header_values = header_order
# no duplicates
if len(header_values) != len(set(header_values)):
print(f"Header must have unique values only: {', '.join(header_values)}")
return None
try:
fp = open(
self.path.joinpath(self.file_name),
"w", encoding="utf-8"
)
csv_file_writer = csv.DictWriter(
fp,
fieldnames=header_values,
delimiter=",",
quotechar='"',
quoting=csv.QUOTE_MINIMAL,
)
csv_file_writer.writeheader()
return csv_file_writer
except OSError as err:
print("OS error:", err)
return None
def write_csv(self, line: dict[str, str]) -> bool:
"""
write member csv line
Arguments:
line {dict[str, str]} -- _description_
Returns:
bool -- _description_
"""
if self.csv_file_writer is None:
return False
csv_row: dict[str, Any] = {}
# only write entries that are in the header list
for key, value in self.header.items():
csv_row[value] = line[key]
self.csv_file_writer.writerow(csv_row)
return True
# __END__

View File

@@ -0,0 +1,235 @@
"""
Various string based date/time helpers
"""
from datetime import datetime, time
from warnings import deprecated
from zoneinfo import ZoneInfo
from corelibs_datetime import datetime_helpers
@deprecated("Use corelibs_datetime.datetime_helpers.create_time instead")
def create_time(timestamp: float, timestamp_format: str = "%Y-%m-%d %H:%M:%S") -> str:
"""
just takes a timestamp and prints out humand readable format
Arguments:
timestamp {float} -- _description_
Keyword Arguments:
timestamp_format {_type_} -- _description_ (default: {"%Y-%m-%d %H:%M:%S"})
Returns:
str -- _description_
"""
return datetime_helpers.create_time(timestamp, timestamp_format)
@deprecated("Use corelibs_datetime.datetime_helpers.get_system_timezone instead")
def get_system_timezone():
"""Get system timezone using datetime's automatic detection"""
# Get current time with system timezone
return datetime_helpers.get_system_timezone()
@deprecated("Use corelibs_datetime.datetime_helpers.parse_timezone_data instead")
def parse_timezone_data(timezone_tz: str = '') -> ZoneInfo:
"""
parses a string to get the ZoneInfo
If not set or not valid gets local time,
if that is not possible get UTC
Keyword Arguments:
timezone_tz {str} -- _description_ (default: {''})
Returns:
ZoneInfo -- _description_
"""
return datetime_helpers.parse_timezone_data(timezone_tz)
@deprecated("Use corelibs_datetime.datetime_helpers.get_datetime_iso8601 instead")
def get_datetime_iso8601(timezone_tz: str | ZoneInfo = '', sep: str = 'T', timespec: str = 'microseconds') -> str:
"""
set a datetime in the iso8601 format with microseconds
Returns:
str -- _description_
"""
try:
return datetime_helpers.get_datetime_iso8601(timezone_tz, sep, timespec)
except KeyError as e:
raise ValueError(f"Deprecated ValueError, change to KeyError: {e}") from e
@deprecated("Use corelibs_datetime.datetime_helpers.validate_date instead")
def validate_date(date: str, not_before: datetime | None = None, not_after: datetime | None = None) -> bool:
"""
check if Y-m-d or Y/m/d are parsable and valid
Arguments:
date {str} -- _description_
Returns:
bool -- _description_
"""
return datetime_helpers.validate_date(date, not_before, not_after)
@deprecated("Use corelibs_datetime.datetime_helpers.parse_flexible_date instead")
def parse_flexible_date(
date_str: str,
timezone_tz: str | ZoneInfo | None = None,
shift_time_zone: bool = True
) -> datetime | None:
"""
Parse date string in multiple formats
will add time zone info if not None
on default it will change the TZ and time to the new time zone
if no TZ info is set in date_str, then localtime is assumed
Arguments:
date_str {str} -- _description_
Keyword Arguments:
timezone_tz {str | ZoneInfo | None} -- _description_ (default: {None})
shift_time_zone {bool} -- _description_ (default: {True})
Returns:
datetime | None -- _description_
"""
return datetime_helpers.parse_flexible_date(
date_str,
timezone_tz,
shift_time_zone
)
@deprecated("Use corelibs_datetime.datetime_helpers.compare_dates instead")
def compare_dates(date1_str: str, date2_str: str) -> None | bool:
"""
compare two dates, if the first one is newer than the second one return True
If the dates are equal then false will be returned
on error return None
Arguments:
date1_str {str} -- _description_
date2_str {str} -- _description_
Returns:
None | bool -- _description_
"""
return datetime_helpers.compare_dates(date1_str, date2_str)
@deprecated("Use corelibs_datetime.datetime_helpers.find_newest_datetime_in_list instead")
def find_newest_datetime_in_list(date_list: list[str]) -> None | str:
"""
Find the newest date from a list of ISO 8601 formatted date strings.
Handles potential parsing errors gracefully.
Args:
date_list (list): List of date strings in format '2025-08-06T16:17:39.747+09:00'
Returns:
str: The date string with the newest/latest date, or None if list is empty or all dates are invalid
"""
return datetime_helpers.find_newest_datetime_in_list(date_list)
@deprecated("Use corelibs_datetime.datetime_helpers.parse_day_of_week_range instead")
def parse_day_of_week_range(dow_days: str) -> list[tuple[int, str]]:
"""
Parse a day of week list/range string and return a list of tuples with day index and name.
Allowed are short (eg Mon) or long names (eg Monday).
Arguments:
dow_days {str} -- A comma-separated list of days or ranges (e.g., "Mon,Wed-Fri")
Raises:
ValueError: If the input format is invalid or if duplicate days are found.
Returns:
list[tuple[int, str]] -- A list of tuples containing the day index and name.
"""
# we have Sun twice because it can be 0 or 7
# Mon is 1 and Sun is 7, which is ISO standard
try:
return datetime_helpers.parse_day_of_week_range(dow_days)
except KeyError as e:
raise ValueError(f"Deprecated ValueError, change to KeyError: {e}") from e
@deprecated("Use corelibs_datetime.datetime_helpers.parse_time_range instead")
def parse_time_range(time_str: str, time_format: str = "%H:%M") -> tuple[time, time]:
"""
Parse a time range string in the format "HH:MM-HH:MM" and return a tuple of two time objects.
Arguments:
time_str {str} -- The time range string to parse.
Raises:
ValueError: Invalid time block set
ValueError: Invalid time format
ValueError: Start time must be before end time
Returns:
tuple[time, time] -- start time, end time: leading zeros formattd
"""
try:
return datetime_helpers.parse_time_range(time_str, time_format)
except KeyError as e:
raise ValueError(f"Deprecated ValueError, change to KeyError: {e}") from e
@deprecated("Use corelibs_datetime.datetime_helpers.times_overlap_or_connect instead")
def times_overlap_or_connect(time1: tuple[time, time], time2: tuple[time, time], allow_touching: bool = False) -> bool:
"""
Check if two time ranges overlap or connect
Args:
time1 (tuple): (start_time, end_time) for first range
time2 (tuple): (start_time, end_time) for second range
allow_touching (bool): If True, touching ranges (e.g., 8:00-10:00 and 10:00-12:00) are allowed
Returns:
bool: True if ranges overlap or connect (based on allow_touching)
"""
return datetime_helpers.times_overlap_or_connect(time1, time2, allow_touching)
@deprecated("Use corelibs_datetime.datetime_helpers.is_time_in_range instead")
def is_time_in_range(current_time: str, start_time: str, end_time: str) -> bool:
"""
Check if current_time is within start_time and end_time (inclusive)
Time format: "HH:MM" (24-hour format)
Arguments:
current_time {str} -- _description_
start_time {str} -- _description_
end_time {str} -- _description_
Returns:
bool -- _description_
"""
# Convert string times to time objects
return datetime_helpers.is_time_in_range(current_time, start_time, end_time)
@deprecated("Use corelibs_datetime.datetime_helpers.reorder_weekdays_from_today instead")
def reorder_weekdays_from_today(base_day: str) -> dict[int, str]:
"""
Reorder the days of the week starting from the specified base_day.
Arguments:
base_day {str} -- The day to start the week from (e.g., "Mon").
Returns:
dict[int, str] -- A dictionary mapping day numbers to day names.
"""
try:
return datetime_helpers.reorder_weekdays_from_today(base_day)
except KeyError as e:
raise ValueError(f"Deprecated ValueError, change to KeyError: {e}") from e
# __END__

View File

@@ -0,0 +1,88 @@
"""
Convert timestamp strings with time units into seconds and vice versa.
"""
from warnings import deprecated
from corelibs_datetime import timestamp_convert
from corelibs_datetime.timestamp_convert import TimeParseError as NewTimeParseError, TimeUnitError as NewTimeUnitError
@deprecated("Use corelibs_datetime.timestamp_convert.TimeParseError instead")
class TimeParseError(Exception):
"""Custom exception for time parsing errors."""
@deprecated("Use corelibs_datetime.timestamp_convert.TimeUnitError instead")
class TimeUnitError(Exception):
"""Custom exception for time parsing errors."""
@deprecated("Use corelibs_datetime.timestamp_convert.convert_to_seconds instead")
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
try:
return timestamp_convert.convert_to_seconds(time_string)
except NewTimeParseError as e:
raise TimeParseError(f"Deprecated, use corelibs_datetime.timestamp_convert.TimeParseError: {e}") from e
except NewTimeUnitError as e:
raise TimeUnitError(f"Deprecated, use corelibs_datetime.timestamp_convert.TimeUnitError: {e}") from e
@deprecated("Use corelibs_datetime.timestamp_convert.seconds_to_string instead")
def seconds_to_string(seconds: str | int | float, show_microseconds: bool = False) -> str:
"""
Convert seconds to compact human readable format (e.g., "1d 2h 3m 4.567s")
Zero values are omitted.
milliseconds if requested are added as fractional part of seconds.
Supports negative values with "-" prefix
if not int or float, will return as is
Args:
seconds (float): Time in seconds (can be negative)
show_microseconds (bool): Whether to show microseconds precision
Returns:
str: Compact human readable time format
"""
return timestamp_convert.seconds_to_string(seconds, show_microseconds)
@deprecated("Use corelibs_datetime.timestamp_convert.convert_timestamp instead")
def convert_timestamp(timestamp: float | int | str, show_microseconds: bool = True) -> str:
"""
format timestamp into human readable format. This function will add 0 values between set values
for example if we have 1d 1s it would output 1d 0h 0m 1s
Milliseconds will be shown if set, and added with ms at the end
Negative values will be prefixed with "-"
if not int or float, will return as is
Arguments:
timestamp {float} -- _description_
Keyword Arguments:
show_micro {bool} -- _description_ (default: {True})
Returns:
str -- _description_
"""
return timestamp_convert.convert_timestamp(timestamp, show_microseconds)
# __END__

View File

@@ -0,0 +1,21 @@
"""
Current timestamp strings and time zones
"""
from warnings import deprecated
from zoneinfo import ZoneInfo
from corelibs_datetime import timestamp_strings
class TimestampStrings(timestamp_strings.TimestampStrings):
"""
set default time stamps
"""
TIME_ZONE: str = 'Asia/Tokyo'
@deprecated("Use corelibs_datetime.timestamp_strings.TimestampStrings instead")
def __init__(self, time_zone: str | ZoneInfo | None = None):
super().__init__(time_zone)
# __END__

View File

View File

@@ -0,0 +1,214 @@
"""
SQLite DB::IO
Will be moved to the CoreLibs
also method names are subject to change
"""
# import gc
from pathlib import Path
from typing import Any, Literal, TYPE_CHECKING
import sqlite3
from corelibs.debug_handling.debug_helpers import call_stack
if TYPE_CHECKING:
from corelibs.logging_handling.log import Logger
class SQLiteIO():
"""Mini SQLite interface"""
def __init__(
self,
log: 'Logger',
db_name: str | Path,
autocommit: bool = False,
enable_fkey: bool = True,
row_factory: str | None = None
):
self.log = log
self.db_name = db_name
self.autocommit = autocommit
self.enable_fkey = enable_fkey
self.row_factory = row_factory
self.conn: sqlite3.Connection | None = self.db_connect()
# def __del__(self):
# self.db_close()
def db_connect(self) -> sqlite3.Connection | None:
"""
Connect to SQLite database, create if it doesn't exist
"""
try:
# Connect to database (creates if doesn't exist)
self.conn = sqlite3.connect(self.db_name, autocommit=self.autocommit)
self.conn.setconfig(sqlite3.SQLITE_DBCONFIG_ENABLE_FKEY, True)
# self.conn.execute("PRAGMA journal_mode=WAL")
# self.log.debug(f"Connected to database: {self.db_name}")
def dict_factory(cursor: sqlite3.Cursor, row: list[Any]):
fields = [column[0] for column in cursor.description]
return dict(zip(fields, row))
match self.row_factory:
case 'Row':
self.conn.row_factory = sqlite3.Row
case 'Dict':
self.conn.row_factory = dict_factory
case _:
self.conn.row_factory = None
return self.conn
except (sqlite3.Error, sqlite3.OperationalError) as e:
self.log.error(f"Error connecting to database [{type(e).__name__}] [{self.db_name}]: {e} [{call_stack()}]")
self.log.error(f"Error code: {e.sqlite_errorcode if hasattr(e, 'sqlite_errorcode') else 'N/A'}")
self.log.error(f"Error name: {e.sqlite_errorname if hasattr(e, 'sqlite_errorname') else 'N/A'}")
return None
def db_close(self):
"""close connection"""
if self.conn is not None:
self.conn.close()
self.conn = None
def db_connected(self) -> bool:
"""
Return True if db connection is not none
Returns:
bool -- _description_
"""
return True if self.conn else False
def __content_exists(self, content_name: str, sql_type: str) -> bool:
"""
Check if some content name for a certain type exists
Arguments:
content_name {str} -- _description_
sql_type {str} -- _description_
Returns:
bool -- _description_
"""
if self.conn is None:
return False
try:
cursor = self.conn.cursor()
cursor.execute("""
SELECT name
FROM sqlite_master
WHERE type = ? AND name = ?
""", (sql_type, content_name,))
return cursor.fetchone() is not None
except sqlite3.Error as e:
self.log.error(f"Error checking table [{content_name}/{sql_type}] existence: {e} [{call_stack()}]")
return False
def table_exists(self, table_name: str) -> bool:
"""
Check if a table exists in the database
"""
return self.__content_exists(table_name, 'table')
def trigger_exists(self, trigger_name: str) -> bool:
"""
Check if a triggere exits
"""
return self.__content_exists(trigger_name, 'trigger')
def index_exists(self, index_name: str) -> bool:
"""
Check if a triggere exits
"""
return self.__content_exists(index_name, 'index')
def meta_data_detail(self, table_name: str) -> list[tuple[Any, ...]] | list[dict[str, Any]] | Literal[False]:
"""table detail"""
query_show_table = """
SELECT
ti.cid, ti.name, ti.type, ti.'notnull', ti.dflt_value, ti.pk,
il_ii.idx_name, il_ii.idx_unique, il_ii.idx_origin, il_ii.idx_partial
FROM
sqlite_schema AS m,
pragma_table_info(m.name) AS ti
LEFT JOIN (
SELECT
il.name AS idx_name, il.'unique' AS idx_unique, il.origin AS idx_origin, il.partial AS idx_partial,
ii.cid AS tbl_cid
FROM
sqlite_schema AS m,
pragma_index_list(m.name) AS il,
pragma_index_info(il.name) AS ii
WHERE m.name = ?1
) AS il_ii ON (ti.cid = il_ii.tbl_cid)
WHERE
m.name = ?1
"""
return self.execute_query(query_show_table, (table_name,))
def execute_cursor(
self, query: str, params: tuple[Any, ...] | None = None
) -> sqlite3.Cursor | Literal[False]:
"""execute a cursor, used in execute query or return one and for fetch_row"""
if self.conn is None:
self.log.warning(f"No connection [{call_stack()}]")
return False
try:
cursor = self.conn.cursor()
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
return cursor
except sqlite3.Error as e:
self.log.error(f"Error during executing cursor [{query}:{params}]: {e} [{call_stack()}]")
return False
def execute_query(
self, query: str, params: tuple[Any, ...] | None = None
) -> list[tuple[Any, ...]] | list[dict[str, Any]] | Literal[False]:
"""query execute with or without params, returns result"""
if self.conn is None:
self.log.warning(f"No connection [{call_stack()}]")
return False
try:
if (cursor := self.execute_cursor(query, params)) is False:
return False
# fetch before commit because we need to get the RETURN before
result = cursor.fetchall()
# this is for INSERT/UPDATE/CREATE only
self.conn.commit()
return result
except sqlite3.Error as e:
self.log.error(f"Error during executing query [{query}:{params}]: {e} [{call_stack()}]")
return False
def return_one(
self, query: str, params: tuple[Any, ...] | None = None
) -> tuple[Any, ...] | dict[str, Any] | Literal[False] | None:
"""return one row, only for SELECT"""
if self.conn is None:
self.log.warning(f"No connection [{call_stack()}]")
return False
try:
if (cursor := self.execute_cursor(query, params)) is False:
return False
return cursor.fetchone()
except sqlite3.Error as e:
self.log.error(f"Error during return one: {e} [{call_stack()}]")
return False
def fetch_row(
self, cursor: sqlite3.Cursor | Literal[False]
) -> tuple[Any, ...] | dict[str, Any] | Literal[False] | None:
"""read from cursor"""
if self.conn is None or cursor is False:
self.log.warning(f"No connection [{call_stack()}]")
return False
try:
return cursor.fetchone()
except sqlite3.Error as e:
self.log.error(f"Error during fetch row: {e} [{call_stack()}]")
return False
# __END__

View File

@@ -0,0 +1,76 @@
"""
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

@@ -4,10 +4,10 @@ Various small helpers for data writing
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from io import TextIOWrapper
from io import TextIOWrapper, StringIO
def write_l(line: str, fpl: 'TextIOWrapper | None' = None, print_line: bool = False):
def write_l(line: str, fpl: 'TextIOWrapper | StringIO | None' = None, print_line: bool = False):
"""
Write a line to screen and to output file

View File

View File

@@ -0,0 +1,199 @@
"""
Send email wrapper
"""
import smtplib
from email.message import EmailMessage
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from corelibs.logging_handling.log import Logger
class SendEmail:
"""
send emails based on a template to a list of receivers
"""
def __init__(
self,
log: "Logger",
settings: dict[str, Any],
template: dict[str, str],
from_email: str,
combined_send: bool = True,
receivers: list[str] | None = None,
data: list[dict[str, str]] | None = None,
):
"""
init send email class
Args:
template (dict): Dictionary with body and subject
from_email (str): from email as "Name" <email>
combined_send (bool): True for sending as one set for all receivers
receivers (list): list of emails to send to
data (dict): data to replace in template
args (Namespace): _description_
"""
self.log = log
self.settings = settings
# internal settings
self.template = template
self.from_email = from_email
self.combined_send = combined_send
self.receivers = receivers
self.data = data
def send_email(
self,
data: list[dict[str, str]] | None,
receivers: list[str] | None,
template: dict[str, str] | None = None,
from_email: str | None = None,
combined_send: bool | None = None,
test_only: bool | None = None
):
"""
build email and send
Arguments:
data {list[dict[str, str]] | None} -- _description_
receivers {list[str] | None} -- _description_
combined_send {bool | None} -- _description_
Keyword Arguments:
template {dict[str, str] | None} -- _description_ (default: {None})
from_email {str | None} -- _description_ (default: {None})
Raises:
ValueError: _description_
ValueError: _description_
"""
if data is None and self.data is not None:
data = self.data
if data is None:
raise ValueError("No replace data set, cannot send email")
if receivers is None and self.receivers is not None:
receivers = self.receivers
if receivers is None:
raise ValueError("No receivers list set, cannot send email")
if combined_send is None:
combined_send = self.combined_send
if test_only is not None:
self.settings['test'] = test_only
if template is None:
template = self.template
if from_email is None:
from_email = self.from_email
if not template['subject'] or not template['body']:
raise ValueError("Both Subject and Body must be set")
self.log.debug(
"[EMAIL]:\n"
f"Subject: {template['subject']}\n"
f"Body: {template['body']}\n"
f"From: {from_email}\n"
f"Combined send: {combined_send}\n"
f"Receivers: {receivers}\n"
f"Replace data: {data}"
)
# send email
self.send_email_list(
self.prepare_email_content(
from_email, template, data
),
receivers,
combined_send,
test_only
)
def prepare_email_content(
self,
from_email: str,
template: dict[str, str],
data: list[dict[str, str]],
) -> list[EmailMessage]:
"""
prepare email for sending
Args:
template (dict): template data for this email
data (dict): data to replace in email
Returns:
list: Email Message Objects as list
"""
_subject = ""
_body = ""
msg: list[EmailMessage] = []
for replace in data:
_subject = template["subject"]
_body = template["body"]
for key, value in replace.items():
_subject = _subject.replace(f"{{{{{key}}}}}", value)
_body = _body.replace(f"{{{{{key}}}}}", value)
# create a simple email and add subhect, from email
msg_email = EmailMessage()
# msg.set_content(_body, charset='utf-8', cte='quoted-printable')
msg_email.set_content(_body, charset="utf-8")
msg_email["Subject"] = _subject
msg_email["From"] = from_email
# push to array for sening
msg.append(msg_email)
return msg
def send_email_list(
self,
email: list[EmailMessage], receivers: list[str],
combined_send: bool | None = None,
test_only: bool | None = None
):
"""
send email to receivers list
Args:
email (list): Email Message object with set obdy, subject, from as list
receivers (array): email receivers list as array
combined_send (bool): True for sending as one set for all receivers
"""
if test_only is not None:
self.settings['test'] = test_only
# localhost (postfix does the rest)
smtp = None
smtp_host = self.settings.get('smtp_host', "localhost")
try:
smtp = smtplib.SMTP(smtp_host)
except ConnectionRefusedError as e:
self.log.error("Could not open SMTP connection to: %s, %s", smtp_host, e)
# loop over messages and then over recievers
for msg in email:
if combined_send is True:
msg["To"] = ", ".join(receivers)
if not self.settings.get('test'):
if smtp is not None:
smtp.send_message(msg, msg["From"], receivers)
else:
self.log.info(f"[EMAIL] Test, not sending email\n{msg}")
else:
for receiver in receivers:
# send to
self.log.debug(f"===> Send to: {receiver}")
if "To" in msg:
msg.replace_header("To", receiver)
else:
msg["To"] = receiver
if not self.settings.get('test'):
if smtp is not None:
smtp.send_message(msg)
else:
self.log.info(f"[EMAIL] Test, not sending email\n{msg}")
# close smtp
if smtp is not None:
smtp.quit()
# __END__

View File

@@ -0,0 +1,152 @@
"""
simple symmetric encryption
Will be moved to CoreLibs
TODO: set key per encryption run
"""
import os
import json
import base64
import hashlib
from typing import TypedDict, cast
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
class PackageData(TypedDict):
"""encryption package"""
encrypted_data: str
salt: str
key_hash: str
class SymmetricEncryption:
"""
simple encryption
the encrypted package has "encrypted_data" and "salt" as fields, salt is needed to create the
key from the password to decrypt
"""
def __init__(self, password: str):
if not password:
raise ValueError("A password must be set")
self.password = password
self.password_hash = hashlib.sha256(password.encode('utf-8')).hexdigest()
def __derive_key_from_password(self, password: str, salt: bytes) -> bytes:
_password = password.encode('utf-8')
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(_password))
return key
def __encrypt_with_metadata(self, data: str | bytes) -> PackageData:
"""Encrypt data and include salt if password-based"""
# convert to bytes (for encoding)
if isinstance(data, str):
data = data.encode('utf-8')
# generate salt and key from password
salt = os.urandom(16)
key = self.__derive_key_from_password(self.password, salt)
# init the cypher suit
cipher_suite = Fernet(key)
encrypted_data = cipher_suite.encrypt(data)
# If using password, include salt in the result
return {
'encrypted_data': base64.urlsafe_b64encode(encrypted_data).decode('utf-8'),
'salt': base64.urlsafe_b64encode(salt).decode('utf-8'),
'key_hash': hashlib.sha256(key).hexdigest()
}
def encrypt_with_metadata(self, data: str | bytes, return_as: str = 'str') -> str | bytes | PackageData:
"""encrypt with metadata, but returns data in string"""
match return_as:
case 'str':
return self.encrypt_with_metadata_return_str(data)
case 'json':
return self.encrypt_with_metadata_return_str(data)
case 'bytes':
return self.encrypt_with_metadata_return_bytes(data)
case 'dict':
return self.encrypt_with_metadata_return_dict(data)
case _:
# default is string json
return self.encrypt_with_metadata_return_str(data)
def encrypt_with_metadata_return_dict(self, data: str | bytes) -> PackageData:
"""encrypt with metadata, but returns data as PackageData dict"""
return self.__encrypt_with_metadata(data)
def encrypt_with_metadata_return_str(self, data: str | bytes) -> str:
"""encrypt with metadata, but returns data in string"""
return json.dumps(self.__encrypt_with_metadata(data))
def encrypt_with_metadata_return_bytes(self, data: str | bytes) -> bytes:
"""encrypt with metadata, but returns data in bytes"""
return json.dumps(self.__encrypt_with_metadata(data)).encode('utf-8')
def decrypt_with_metadata(self, encrypted_package: str | bytes | PackageData, password: str | None = None) -> str:
"""Decrypt data that may include metadata"""
try:
# Try to parse as JSON (password-based encryption)
if isinstance(encrypted_package, bytes):
package_data = cast(PackageData, json.loads(encrypted_package.decode('utf-8')))
elif isinstance(encrypted_package, str):
package_data = cast(PackageData, json.loads(str(encrypted_package)))
else:
package_data = encrypted_package
encrypted_data = base64.urlsafe_b64decode(package_data['encrypted_data'])
salt = base64.urlsafe_b64decode(package_data['salt'])
pwd = password or self.password
key = self.__derive_key_from_password(pwd, salt)
if package_data['key_hash'] != hashlib.sha256(key).hexdigest():
raise ValueError("Key hash is not matching, possible invalid password")
cipher_suite = Fernet(key)
decrypted_data = cipher_suite.decrypt(encrypted_data)
except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid encrypted package format {e}") from e
return decrypted_data.decode('utf-8')
@staticmethod
def encrypt_data(data: str | bytes, password: str) -> str:
"""
Static method to encrypt some data
Arguments:
data {str | bytes} -- _description_
password {str} -- _description_
Returns:
str -- _description_
"""
encryptor = SymmetricEncryption(password)
return encryptor.encrypt_with_metadata_return_str(data)
@staticmethod
def decrypt_data(data: str | bytes | PackageData, password: str) -> str:
"""
Static method to decrypt some data
Arguments:
data {str | bytes | PackageData} -- _description_
password {str} -- _description_
Returns:
str -- _description_
"""
decryptor = SymmetricEncryption(password)
return decryptor.decrypt_with_metadata(data, password=password)
# __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

@@ -0,0 +1,75 @@
"""
File check if BOM encoded, needed for CSV load
"""
from pathlib import Path
from typing import TypedDict
class BomEncodingInfo(TypedDict):
"""BOM encoding info"""
has_bom: bool
bom_type: str | None
encoding: str | None
bom_length: int
bom_pattern: bytes | None
def is_bom_encoded(file_path: Path) -> bool:
"""
Detect if a file is BOM encoded
Args:
file_path (str): Path to the file to check
Returns:
bool: True if file has BOM, False otherwise
"""
return is_bom_encoded_info(file_path)['has_bom']
def is_bom_encoded_info(file_path: Path) -> BomEncodingInfo:
"""
Enhanced BOM detection with additional file analysis
Args:
file_path (str): Path to the file to check
Returns:
dict: Comprehensive BOM and encoding information
"""
try:
# Read first 1024 bytes for analysis
with open(file_path, 'rb') as f:
header = f.read(4)
bom_patterns = {
b'\xef\xbb\xbf': ('UTF-8', 'utf-8', 3),
b'\xff\xfe\x00\x00': ('UTF-32 LE', 'utf-32-le', 4),
b'\x00\x00\xfe\xff': ('UTF-32 BE', 'utf-32-be', 4),
b'\xff\xfe': ('UTF-16 LE', 'utf-16-le', 2),
b'\xfe\xff': ('UTF-16 BE', 'utf-16-be', 2),
}
for bom_pattern, (encoding_name, encoding, length) in bom_patterns.items():
if header.startswith(bom_pattern):
return {
'has_bom': True,
'bom_type': encoding_name,
'encoding': encoding,
'bom_length': length,
'bom_pattern': bom_pattern
}
return {
'has_bom': False,
'bom_type': None,
'encoding': None,
'bom_length': 0,
'bom_pattern': None
}
except Exception as e:
raise ValueError(f"Error checking BOM encoding: {e}") from e
# __END__

View File

@@ -7,7 +7,12 @@ import shutil
from pathlib import Path
def remove_all_in_directory(directory: Path, ignore_files: list[str] | None = None, verbose: bool = False) -> bool:
def remove_all_in_directory(
directory: Path,
ignore_files: list[str] | None = None,
verbose: bool = False,
dry_run: bool = False
) -> bool:
"""
remove all files and folders in a directory
can exclude files or folders
@@ -24,7 +29,10 @@ def remove_all_in_directory(directory: Path, ignore_files: list[str] | None = No
if ignore_files is None:
ignore_files = []
if verbose:
print(f"Remove old files in: {directory.name} [", end="", flush=True)
print(
f"{'[DRY RUN] ' if dry_run else ''}Remove old files in: {directory.name} [",
end="", flush=True
)
# remove all files and folders in given directory by recursive globbing
for file in directory.rglob("*"):
# skip if in ignore files
@@ -32,11 +40,13 @@ def remove_all_in_directory(directory: Path, ignore_files: list[str] | None = No
continue
# remove one file, or a whole directory
if file.is_file():
os.remove(file)
if not dry_run:
os.remove(file)
if verbose:
print(".", end="", flush=True)
elif file.is_dir():
shutil.rmtree(file)
if not dry_run:
shutil.rmtree(file)
if verbose:
print("/", end="", flush=True)
if verbose:

View File

@@ -2,23 +2,41 @@
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 +50,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 +75,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 (
(

View File

@@ -60,4 +60,22 @@ def build_dict(
delete_keys_from_set(any_dict, ignore_entries)
)
def set_entry(dict_set: dict[str, Any], key: str, value_set: Any) -> dict[str, Any]:
"""
set a new entry in the dict set
Arguments:
key {str} -- _description_
dict_set {dict[str, Any]} -- _description_
value_set {Any} -- _description_
Returns:
dict[str, Any] -- _description_
"""
if not dict_set.get(key):
dict_set[key] = {}
dict_set[key] = value_set
return dict_set
# __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

@@ -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

@@ -28,8 +28,12 @@ def jmespath_search(search_data: dict[Any, Any] | list[Any], search_params: str)
raise ValueError(f"Compile failed: {search_params}: {excp}") from excp
except jmespath.exceptions.ParseError as excp:
raise ValueError(f"Parse failed: {search_params}: {excp}") from excp
except jmespath.exceptions.JMESPathTypeError as excp:
raise ValueError(f"Search failed with JMESPathTypeError: {search_params}: {excp}") from excp
except TypeError as excp:
raise ValueError(f"Type error for search_params: {excp}") from excp
return search_result
# TODO: compile jmespath setup
# __END__

View File

@@ -3,15 +3,17 @@ json encoder for datetime
"""
from typing import Any
from json import JSONEncoder
from json import JSONEncoder, dumps
from datetime import datetime, date
import copy
from jsonpath_ng import parse # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType]
# subclass JSONEncoder
class DateTimeEncoder(JSONEncoder):
"""
Override the default method
cls=DateTimeEncoder
dumps(..., cls=DateTimeEncoder, ...)
"""
def default(self, o: Any) -> str | None:
if isinstance(o, (date, datetime)):
@@ -19,13 +21,44 @@ class DateTimeEncoder(JSONEncoder):
return None
def default(obj: Any) -> str | None:
def default_isoformat(obj: Any) -> str | None:
"""
default override
default=default
dumps(..., default=default, ...)
"""
if isinstance(obj, (date, datetime)):
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)
def modify_with_jsonpath(data: dict[Any, Any], path: str, new_value: Any):
"""
Modify dictionary using JSONPath (more powerful than JMESPath for modifications)
"""
result = copy.deepcopy(data)
jsonpath_expr = parse(path) # pyright: ignore[reportUnknownVariableType]
# Find and update all matches
matches = jsonpath_expr.find(result) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
for match in matches: # pyright: ignore[reportUnknownVariableType]
match.full_path.update(result, new_value) # pyright: ignore[reportUnknownMemberType]
return result
# __END__
# __END__

View File

@@ -1,37 +0,0 @@
"""
Dict helpers
"""
def mask(
data_set: dict[str, str],
mask_keys: list[str] | None = None,
mask_str: str = "***",
skip: bool = False
) -> dict[str, str]:
"""
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
Arguments:
data_set {dict[str, str]} -- _description_
Keyword Arguments:
mask_keys {list[str] | None} -- _description_ (default: {None})
mask_str {str} -- _description_ (default: {"***"})
skip {bool} -- _description_ (default: {False})
Returns:
dict[str, str] -- _description_
"""
if skip is True:
return data_set
if mask_keys is None:
mask_keys = ["password", "secret"]
return {
key: mask_str
if any(key.startswith(mask_key) or key.endswith(mask_key) for mask_key in mask_keys) else value
for key, value in data_set.items()
}
# __END__

View File

@@ -1,91 +1,292 @@
"""
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
from typing import Mapping
import atexit
from enum import Flag, auto
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
class Log:
class ConsoleFormat(Flag):
"""console format type bitmap flags"""
TIME = auto()
TIME_SECONDS = auto()
TIME_MILLISECONDS = auto()
TIME_MICROSECONDS = auto()
TIMEZONE = auto()
NAME = auto()
FILE = auto()
FUNCTION = auto()
LINENO = auto()
class ConsoleFormatSettings:
"""Console format quick settings groups"""
# shows everything, time with milliseconds, and time zone, log name, file, function, line number
ALL = (
ConsoleFormat.TIME |
ConsoleFormat.TIMEZONE |
ConsoleFormat.NAME |
ConsoleFormat.FILE |
ConsoleFormat.FUNCTION |
ConsoleFormat.LINENO
)
# show time with no time zone, file and line
CONDENSED = ConsoleFormat.TIME | ConsoleFormat.FILE | ConsoleFormat.LINENO
# only time
MINIMAL = ConsoleFormat.TIME
# only message
BARE = ConsoleFormat(0)
@staticmethod
def from_string(setting_str: str, default: ConsoleFormat | None = None) -> ConsoleFormat | None:
"""
Get a console format setting, if does not exist set to None
Arguments:
setting_str {str} -- what to search for
default {ConsoleFormat | None} -- if not found return this (default: {None})
Returns:
ConsoleFormat | None -- found ConsoleFormat or None
"""
if hasattr(ConsoleFormatSettings, setting_str):
return getattr(ConsoleFormatSettings, setting_str)
return default
# 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
console_format_type: ConsoleFormat
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):
"""
logger setup
Custom formatter with colors for console output
"""
EXCEPTION: int = 60
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 __init__(
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,
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)
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
# self.handlers = []
# 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] '
'[%(name)s:%(process)d] '
'[%(pathname)s:%(funcName)s:%(lineno)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)
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"):
"""
@@ -94,29 +295,561 @@ class Log:
Keyword Arguments:
info {str} -- _description_ (default: {"BREAK"})
"""
self.logger.info("[%s] ================================>", info)
if not hasattr(self, 'logger'):
raise ValueError('Logger is not yet initialized')
self.logger.info("[%s] %s>", info, self.SPACER_CHAR * self.SPACER_LENGTH)
def exception(self, msg: object, *args: object, extra: Mapping[str, object] | None = None) -> None:
# MARK: queue handling
def flush(self, handler_name: str | None = None, timeout: float = 2.0) -> bool:
"""
log on exceotion level
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:
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_
log_level (Any): _description_
Returns:
bool: _description_
"""
return isinstance(getattr(logging, log_level.upper(), None), int)
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,
# do not print log title, file, function and line number
"console_format_type": ConsoleFormatSettings.ALL,
"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] | ConsoleFormat'] | # noqa: E501 # pylint: disable=line-too-long
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'],
console_format_type=self.log_settings['console_format_type'],
))
# 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 hasattr(self, 'log_settings') and self.log_settings.get('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] | ConsoleFormat'] | # noqa: E501 # pylint: disable=line-too-long
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 console log type
default_log_settings['console_format_type'] = cast('ConsoleFormat', log_settings.get(
'console_format_type', self.DEFAULT_LOG_SETTINGS['console_format_type']
))
# 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,
console_format_type: ConsoleFormat = ConsoleFormatSettings.ALL,
) -> 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()
print(f"Console format type: {console_format_type}")
# build the format string based on what flags are set
format_string = ''
# time part if any of the times are requested
if (
ConsoleFormat.TIME in console_format_type or
ConsoleFormat.TIME_SECONDS in console_format_type or
ConsoleFormat.TIME_MILLISECONDS in console_format_type or
ConsoleFormat.TIME_MICROSECONDS in console_format_type
):
format_string += '[%(asctime)s] '
# set log name
if ConsoleFormat.NAME in console_format_type:
format_string += '[%(name)s] '
# for any file/function/line number call
if (
ConsoleFormat.FILE in console_format_type or
ConsoleFormat.FUNCTION in console_format_type or
ConsoleFormat.LINENO in console_format_type
):
format_string += '['
set_group: list[str] = []
if ConsoleFormat.FILE in console_format_type:
set_group.append('%(filename)s')
if ConsoleFormat.FUNCTION in console_format_type:
set_group.append('%(funcName)s')
if ConsoleFormat.LINENO in console_format_type:
set_group.append('%(lineno)d')
format_string += ':'.join(set_group)
format_string += '] '
# always level + message
format_string += '<%(levelname)s> %(message)s'
# basic date, but this will be overridden to ISO in formatTime
# 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)
formatter_console = CustomConsoleFormatter(format_string)
else:
# formatter_console = logging.Formatter(format_string, datefmt=format_date)
formatter_console = logging.Formatter(format_string)
# default for TIME is milliseconds
# if we have multiple set, the smallest precision wins
if ConsoleFormat.TIME_MICROSECONDS in console_format_type:
iso_precision = 'microseconds'
elif (
ConsoleFormat.TIME_MILLISECONDS in console_format_type or
ConsoleFormat.TIME in console_format_type
):
iso_precision = 'milliseconds'
elif ConsoleFormat.TIME_SECONDS in console_format_type:
iso_precision = 'seconds'
else:
iso_precision = 'milliseconds'
# do timestamp modification only if we have time requested
if (
ConsoleFormat.TIME in console_format_type or
ConsoleFormat.TIME_SECONDS in console_format_type or
ConsoleFormat.TIME_MILLISECONDS in console_format_type or
ConsoleFormat.TIME_MICROSECONDS in console_format_type
):
# if we have with TZ we as the asttimezone call
if ConsoleFormat.TIMEZONE in console_format_type:
formatter_console.formatTime = (
lambda record, datefmt=None:
datetime
.fromtimestamp(record.created)
.astimezone()
.isoformat(sep=" ", timespec=iso_precision)
)
else:
formatter_console.formatTime = (
lambda record, datefmt=None:
datetime
.fromtimestamp(record.created)
.isoformat(sep=" ", timespec=iso_precision)
)
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] '
'[%(asctime)s] '
# log name
'[%(name)s] '
# filename + pid
# '[%(filename)s:%(process)d] '
# pid + path/filename + func + line number
'[%(process)d:%(pathname)s:%(funcName)s:%(lineno)d] '
# error level
'<%(levelname)s> '
# message
'%(message)s'
),
datefmt="%Y-%m-%dT%H:%M:%S",
)
formatter_file_handler.formatTime = (
lambda record, datefmt=None:
datetime
.fromtimestamp(record.created)
.astimezone()
.isoformat(sep="T", timespec="microseconds")
)
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__

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

@@ -18,11 +18,12 @@ class Caller:
header: dict[str, str],
verify: bool = True,
timeout: int = 20,
proxy: dict[str, str] | None = None
proxy: dict[str, str] | None = None,
ca_file: str | None = None
):
self.headers = header
self.timeout: int = timeout
self.cafile = "/Library/Application Support/Netskope/STAgent/data/nscacert.pem"
self.cafile = ca_file
self.verify = verify
self.proxy = proxy

View File

@@ -32,7 +32,7 @@ show_position(file pos optional)
import time
from typing import Literal
from math import floor
from corelibs.string_handling.datetime_helpers import convert_timestamp
from corelibs_datetime.timestamp_convert import convert_timestamp
from corelibs.string_handling.byte_helpers import format_bytes

View File

@@ -1,63 +0,0 @@
"""
Various string based date/time helpers
"""
from math import floor
import time
def convert_timestamp(timestamp: float | int, show_micro: bool = True) -> str:
"""
format timestamp into human readable format
Arguments:
timestamp {float} -- _description_
Keyword Arguments:
show_micro {bool} -- _description_ (default: {True})
Returns:
str -- _description_
"""
# cut of the ms, but first round them up to four
__timestamp_ms_split = str(round(timestamp, 4)).split(".")
timestamp = int(__timestamp_ms_split[0])
try:
ms = int(__timestamp_ms_split[1])
except IndexError:
ms = 0
timegroups = (86400, 3600, 60, 1)
output: list[int] = []
for i in timegroups:
output.append(int(floor(timestamp / i)))
timestamp = timestamp % i
# output has days|hours|min|sec ms
time_string = ""
if output[0]:
time_string = f"{output[0]}d"
if output[0] or output[1]:
time_string += f"{output[1]}h "
if output[0] or output[1] or output[2]:
time_string += f"{output[2]}m "
time_string += f"{output[3]}s"
if show_micro:
time_string += f" {ms}ms" if ms else " 0ms"
return time_string
def create_time(timestamp: float, timestamp_format: str = "%Y-%m-%d %H:%M:%S") -> str:
"""
just takes a timestamp and prints out humand readable format
Arguments:
timestamp {float} -- _description_
Keyword Arguments:
timestamp_format {_type_} -- _description_ (default: {"%Y-%m-%d %H:%M:%S"})
Returns:
str -- _description_
"""
return time.strftime(timestamp_format, time.localtime(timestamp))
# __END__

View File

@@ -2,6 +2,7 @@
String helpers
"""
import re
from decimal import Decimal, getcontext
from textwrap import shorten
@@ -101,4 +102,21 @@ def format_number(number: float, precision: int = 0) -> str:
"f}"
).format(_number)
def prepare_url_slash(url: str) -> str:
"""
if the URL does not start with /, add slash
strip all double slashes in URL
Arguments:
url {str} -- _description_
Returns:
str -- _description_
"""
url = re.sub(r'\/+', '/', url)
if not url.startswith("/"):
url = "/" + url
return url
# __END__

View File

@@ -10,40 +10,73 @@ class Colors:
"""
ANSI colors defined
"""
# General sets
bold = '\033[1m'
underline = '\033[4m'
end = '\033[0m'
reset = '\033[0m'
# 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"
__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"
__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'
__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():
@@ -81,4 +114,43 @@ class Colors:
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

@@ -1,26 +0,0 @@
"""
Current timestamp strings and time zones
"""
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
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 __class__.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")

View File

View File

@@ -0,0 +1,83 @@
"""
Enum base classes
"""
from enum import Enum
from warnings import deprecated
from typing import Any
# from corelibs_enum_base.enum_base import EnumBase as CorelibsEnumBase
class EnumBase(Enum):
"""
base for enum
lookup_any and from_any will return "EnumBase" and the sub class name
run the return again to "from_any" to get a clean value, or cast it
"""
@classmethod
@deprecated("Use corelibs_enum_base.EnumBase instead")
def lookup_key(cls, enum_key: str):
"""Lookup from key side (must be string)"""
# if there is a ":", then this is legacy, replace with ___
if ":" in enum_key:
enum_key = enum_key.replace(':', '___')
try:
return cls[enum_key.upper()]
except KeyError as e:
raise ValueError(f"Invalid key: {enum_key}") from e
except AttributeError as e:
raise ValueError(f"Invalid key: {enum_key}") from e
@classmethod
@deprecated("Use corelibs_enum_base.EnumBase instead")
def lookup_value(cls, enum_value: Any):
"""Lookup through value side"""
try:
return cls(enum_value)
except ValueError as e:
raise ValueError(f"Invalid value: {enum_value}") from e
@classmethod
@deprecated("Use corelibs_enum_base.EnumBase instead")
def from_any(cls, enum_any: Any):
"""
This only works in the following order
-> class itself, as is
-> str, assume key lookup
-> if failed try other
Arguments:
enum_any {Any} -- _description_
Returns:
_type_ -- _description_
"""
if isinstance(enum_any, cls):
return enum_any
# try key first if it is string
# if failed try value
if isinstance(enum_any, str):
try:
return cls.lookup_key(enum_any)
except (ValueError, AttributeError):
try:
return cls.lookup_value(enum_any)
except ValueError as e:
raise ValueError(f"Could not find as key or value: {enum_any}") from e
return cls.lookup_value(enum_any)
@deprecated("Use corelibs_enum_base.EnumBase instead")
def to_value(self) -> Any:
"""Convert to value"""
return self.value
@deprecated("Use corelibs_enum_base.EnumBase instead")
def to_lower_case(self) -> str:
"""return lower case"""
return self.name.lower()
@deprecated("Use corelibs_enum_base.EnumBase instead")
def __str__(self) -> str:
"""return [Enum].NAME like it was called with .name"""
return self.name

View File

@@ -0,0 +1,54 @@
"""
variable convert, check, etc helepr
"""
from typing import Any
from warnings import deprecated
import corelibs_var.var_helpers
@deprecated("Use corelibs_var.var_helpers.is_int instead")
def is_int(string: Any) -> bool:
"""
check if a value is int
Arguments:
string {Any} -- _description_
Returns:
bool -- _description_
"""
return corelibs_var.var_helpers.is_int(string)
@deprecated("Use corelibs_var.var_helpers.is_float instead")
def is_float(string: Any) -> bool:
"""
check if a value is float
Arguments:
string {Any} -- _description_
Returns:
bool -- _description_
"""
return corelibs_var.var_helpers.is_float(string)
@deprecated("Use corelibs_var.var_helpers.str_to_bool instead")
def str_to_bool(string: str):
"""
convert string to bool
Arguments:
s {str} -- _description_
Raises:
ValueError: _description_
Returns:
_type_ -- _description_
"""
return corelibs_var.var_helpers.str_to_bool(string)
# __END__

View File

@@ -0,0 +1,34 @@
[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
elemend_d=AB:CD;EF
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")
LOG_DIR: Path = Path("log")
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
log = Log(
log_path=ROOT_PATH.joinpath(LOG_DIR, '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,236 @@
#!/usr/bin/env python3
"""
date string helper test
"""
from datetime import datetime
from corelibs.datetime_handling.datetime_helpers import (
get_datetime_iso8601, get_system_timezone, parse_timezone_data, validate_date,
parse_flexible_date, compare_dates, find_newest_datetime_in_list,
parse_day_of_week_range, parse_time_range, times_overlap_or_connect, is_time_in_range,
reorder_weekdays_from_today
)
def __get_datetime_iso8601():
"""
Comment
"""
for tz in [
'', 'Asia/Tokyo', 'UTC', 'Europe/Vienna',
'America/New_York', 'Australia/Sydney',
'invalid'
]:
print(f"{tz} -> {get_datetime_iso8601(tz)}")
def __parse_timezone_data():
for tz in [
'JST', 'KST', 'UTC', 'CET', 'CEST',
]:
print(f"{tz} -> {parse_timezone_data(tz)}")
def __validate_date():
"""
Comment
"""
test_dates = [
"2024-01-01",
"2024-02-29", # Leap year
"2023-02-29", # Invalid date
"2024-13-01", # Invalid month
"2024-00-10", # Invalid month
"2024-04-31", # Invalid day
"invalid-date"
]
for date_str in test_dates:
is_valid = validate_date(date_str)
print(f"Date '{date_str}' is valid: {is_valid}")
# also test not before and not after
not_before_dates = [
"2023-12-31",
"2024-01-01",
"2024-02-29",
]
not_after_dates = [
"2024-12-31",
"2024-11-30",
"2025-01-01",
]
for date_str in not_before_dates:
datetime.strptime(date_str, "%Y-%m-%d") # Ensure valid date format
is_valid = validate_date(date_str, not_before=datetime.strptime("2024-01-01", "%Y-%m-%d"))
print(f"Date '{date_str}' is valid (not before 2024-01-01): {is_valid}")
for date_str in not_after_dates:
is_valid = validate_date(date_str, not_after=datetime.strptime("2024-12-31", "%Y-%m-%d"))
print(f"Date '{date_str}' is valid (not after 2024-12-31): {is_valid}")
for date_str in test_dates:
is_valid = validate_date(
date_str,
not_before=datetime.strptime("2024-01-01", "%Y-%m-%d"),
not_after=datetime.strptime("2024-12-31", "%Y-%m-%d")
)
print(f"Date '{date_str}' is valid (2024 only): {is_valid}")
def __parse_flexible_date():
for date_str in [
"2024-01-01",
"01/02/2024",
"February 29, 2024",
"Invalid date",
"2025-01-01 12:18:10",
"2025-01-01 12:18:10.566",
"2025-01-01T12:18:10.566",
"2025-01-01T12:18:10.566+02:00",
]:
print(f"{date_str} -> {parse_flexible_date(date_str)}")
def __compare_dates():
for date1, date2 in [
("2024-01-01 12:00:00", "2024-01-01 15:30:00"),
("2024-01-02", "2024-01-01"),
("2024-01-01T10:00:00+02:00", "2024-01-01T08:00:00Z"),
("invalid-date", "2024-01-01"),
("2024-01-01", "invalid-date"),
("invalid-date", "also-invalid"),
]:
result = compare_dates(date1, date2)
print(f"Comparing '{date1}' and '{date2}': {result}")
def __find_newest_datetime_in_list():
date_list = [
"2024-01-01 12:00:00",
"2024-01-02 09:30:00",
"2023-12-31 23:59:59",
"2024-01-02 15:45:00",
"2024-01-02T15:45:00.001",
"invalid-date",
]
newest_date = find_newest_datetime_in_list(date_list)
print(f"Newest date in list: {newest_date}")
def __parse_day_of_week_range():
ranges = [
"Mon-Fri",
"Saturday-Sunday",
"Wed-Mon",
"Fri-Fri",
"mon-tue",
"Invalid-Range"
]
for range_str in ranges:
try:
days = parse_day_of_week_range(range_str)
print(f"Day range '{range_str}' -> {days}")
except ValueError as e:
print(f"[!] Error parsing day range '{range_str}': {e}")
def __parse_time_range():
ranges = [
"08:00-17:00",
"22:00-06:00",
"12:30-12:30",
"invalid-range"
]
for range_str in ranges:
try:
start_time, end_time = parse_time_range(range_str)
print(f"Time range '{range_str}' -> Start: {start_time}, End: {end_time}")
except ValueError as e:
print(f"[!] Error parsing time range '{range_str}': {e}")
def __times_overlap_or_connect():
time_format = "%H:%M"
time_ranges = [
(("08:00", "12:00"), ("11:00", "15:00")), # Overlap
(("22:00", "02:00"), ("01:00", "05:00")), # Overlap across midnight
(("10:00", "12:00"), ("12:00", "14:00")), # Connect
(("09:00", "11:00"), ("12:00", "14:00")), # No overlap
]
for (start1, end1), (start2, end2) in time_ranges:
start1 = datetime.strptime(start1, time_format).time()
end1 = datetime.strptime(end1, time_format).time()
start2 = datetime.strptime(start2, time_format).time()
end2 = datetime.strptime(end2, time_format).time()
overlap = times_overlap_or_connect((start1, end1), (start2, end2))
overlap_connect = times_overlap_or_connect((start1, end1), (start2, end2), True)
print(f"Time ranges {start1}-{end1} and {start2}-{end2} overlap/connect: {overlap}/{overlap_connect}")
def __is_time_in_range():
time_format = "%H:%M:%S"
test_cases = [
("10:00:00", "09:00:00", "11:00:00"),
("23:30:00", "22:00:00", "01:00:00"), # Across midnight
("05:00:00", "06:00:00", "10:00:00"), # Not in range
("12:00:00", "12:00:00", "12:00:00"), # Exact match
]
for (check_time, start_time, end_time) in test_cases:
start_time = datetime.strptime(start_time, time_format).time()
end_time = datetime.strptime(end_time, time_format).time()
in_range = is_time_in_range(
f"{check_time}", start_time.strftime("%H:%M:%S"), end_time.strftime("%H:%M:%S")
)
print(f"Time {check_time} in range {start_time}-{end_time}: {in_range}")
def __reorder_weekdays_from_today():
for base_day in [
"Tue", "Wed", "Sunday", "Fri", "InvalidDay"
]:
try:
reordered_days = reorder_weekdays_from_today(base_day)
print(f"Reordered weekdays from {base_day}: {reordered_days}")
except ValueError as e:
print(f"[!] Error reordering weekdays from '{base_day}': {e}")
def main() -> None:
"""
Comment
"""
print("\nDatetime ISO 8601 tests:\n")
__get_datetime_iso8601()
print("\nSystem time test:")
print(f"System time: {get_system_timezone()}")
print("\nParse timezone data tests:\n")
__parse_timezone_data()
print("\nValidate date tests:\n")
__validate_date()
print("\nParse flexible date tests:\n")
__parse_flexible_date()
print("\nCompare dates tests:\n")
__compare_dates()
print("\nFind newest datetime in list tests:\n")
__find_newest_datetime_in_list()
print("\nParse day of week range tests:\n")
__parse_day_of_week_range()
print("\nParse time range tests:\n")
__parse_time_range()
print("\nTimes overlap or connect tests:\n")
__times_overlap_or_connect()
print("\nIs time in range tests:\n")
__is_time_in_range()
print("\nReorder weekdays from today tests:\n")
__reorder_weekdays_from_today()
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
timestamp string checks
"""
from corelibs.datetime_handling.timestamp_convert import (
convert_timestamp, seconds_to_string, convert_to_seconds, TimeParseError, TimeUnitError
)
def main() -> None:
"""
Comment
"""
print("\n--- Testing convert_to_seconds ---\n")
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
"-45s", # -45 seconds
"-1h", # -1 hour
"-30m", # -30 minutes
"-2h 30m 45s", # -2 hours, 30 minutes, 45 seconds
"-1d 12h", # -1 day, 12 hours
"-3M 2d 4h", # -3 months, 2 days, 4 hours
"-1Y 2M 3d", # -1 year, 2 months, 3 days
"-2 hours 15 minutes", # -2 hours, 15 minutes
"-1 year 2 months", # -1 year, 2 months
"-2Y 6M 15d 8h 30m 45s", # Complex negative example
"1 year 2 months", # 1 year, 2 months
"2Y 6M 15d 8h 30m 45s", # Complex example
# invalid tests
"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"Human readable to seconds: {time_string} => {result}")
except (TimeParseError, TimeUnitError) as e:
print(f"Error encountered for {time_string}: {type(e).__name__}: {e}")
print("\n--- Testing seconds_to_string and convert_timestamp ---\n")
test_values = [
'as is string',
-172800.001234, # -2 days, -0.001234 seconds
-90061.789, # -1 day, -1 hour, -1 minute, -1.789 seconds
-3661.456, # -1 hour, -1 minute, -1.456 seconds
-65.123, # -1 minute, -5.123 seconds
-1.5, # -1.5 seconds
-0.001, # -1 millisecond
-0.000001, # -1 microsecond
0, # 0 seconds
0.000001, # 1 microsecond
0.001, # 1 millisecond
1.5, # 1.5 seconds
65.123, # 1 minute, 5.123 seconds
3661.456, # 1 hour, 1 minute, 1.456 seconds
90061.789, # 1 day, 1 hour, 1 minute, 1.789 seconds
172800.001234 # 2 days, 0.001234 seconds
]
for time_value in test_values:
result = seconds_to_string(time_value, show_microseconds=True)
result_alt = convert_timestamp(time_value, show_microseconds=True)
print(f"Seconds to human readable: {time_value} => {result} / {result_alt}")
if __name__ == "__main__":
main()
# __END__

View File

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

2
test-run/db_handling/log/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Main comment
"""
from pathlib import Path
from uuid import uuid4
import json
import sqlite3
from corelibs.debug_handling.dump_data import dump_data
from corelibs.logging_handling.log import Log, Logger
from corelibs.db_handling.sqlite_io import SQLiteIO
SCRIPT_PATH: Path = Path(__file__).resolve().parent
ROOT_PATH: Path = SCRIPT_PATH
DATABASE_DIR: Path = Path("database")
LOG_DIR: Path = Path("log")
def main() -> None:
"""
Comment
"""
log = Log(
log_path=ROOT_PATH.joinpath(LOG_DIR, 'sqlite_io.log'),
log_name="SQLite IO",
log_settings={
"log_level_console": 'DEBUG',
"log_level_file": 'DEBUG',
}
)
db = SQLiteIO(
log=Logger(log.get_logger_settings()),
db_name=ROOT_PATH.joinpath(DATABASE_DIR, 'test_sqlite_io.db'),
row_factory='Dict'
)
if db.db_connected():
log.info(f"Connected to DB: {db.db_name}")
if db.trigger_exists('trg_test_a_set_date_updated_on_update'):
log.info("Trigger trg_test_a_set_date_updated_on_update exists")
if db.table_exists('test_a'):
log.info("Table test_a exists, dropping for clean test")
db.execute_query("DROP TABLE test_a;")
# create a dummy table
table_sql = """
CREATE TABLE IF NOT EXISTS test_a (
test_a_id INTEGER PRIMARY KEY,
date_created TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
date_updated TEXT,
uid TEXT NOT NULL UNIQUE,
set_current_timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
text_a TEXT,
content,
int_a INTEGER,
float_a REAL
);
"""
result = db.execute_query(table_sql)
log.debug(f"Create table result: {result}")
trigger_sql = """
CREATE TRIGGER trg_test_a_set_date_updated_on_update
AFTER UPDATE ON test_a
FOR EACH ROW
WHEN OLD.date_updated IS NULL OR NEW.date_updated = OLD.date_updated
BEGIN
UPDATE test_a
SET date_updated = (strftime('%Y-%m-%d %H:%M:%f', 'now'))
WHERE test_a_id = NEW.test_a_id;
END;
"""
result = db.execute_query(trigger_sql)
log.debug(f"Create trigger result: {result}")
result = db.meta_data_detail('test_a')
log.debug(f"Table meta data detail: {dump_data(result)}")
# INSERT DATA
sql = """
INSERT INTO test_a (uid, text_a, content, int_a, float_a)
VALUES (?, ?, ?, ?, ?)
RETURNING test_a_id, uid;
"""
result = db.execute_query(
sql,
(
str(uuid4()),
'Some text A',
json.dumps({'foo': 'bar', 'number': 42}),
123,
123.456,
)
)
log.debug(f"[1] Insert data result: {dump_data(result)}")
__uid: str = ''
if result is not False:
# first one only of interest
result = dict(result[0])
__uid = str(result.get('uid', ''))
# second insert
result = db.execute_query(
sql,
(
str(uuid4()),
'Some text A',
json.dumps({'foo': 'bar', 'number': 42}),
123,
123.456,
)
)
log.debug(f"[2] Insert data result: {dump_data(result)}")
result = db.execute_query("SELECT * FROM test_a;")
log.debug(f"Select data result: {dump_data(result)}")
result = db.return_one("SELECT * FROM test_a WHERE uid = ?;", (__uid,))
log.debug(f"Fetch row result: {dump_data(result)}")
sql = """
UPDATE test_a
SET text_a = ?
WHERE uid = ?;
"""
result = db.execute_query(
sql,
(
'Some updated text A',
__uid,
)
)
log.debug(f"Update data result: {dump_data(result)}")
result = db.return_one("SELECT * FROM test_a WHERE uid = ?;", (__uid,))
log.debug(f"Fetch row after update result: {dump_data(result)}")
db.db_close()
db = SQLiteIO(
log=Logger(log.get_logger_settings()),
db_name=ROOT_PATH.joinpath(DATABASE_DIR, 'test_sqlite_io.db'),
row_factory='Row'
)
result = db.return_one("SELECT * FROM test_a WHERE uid = ?;", (__uid,))
if result is not None and result is not False:
log.debug(f"Fetch row result: {dump_data(result)} -> {dict(result)} -> {result.keys()}")
log.debug(f"Access via index: {result[5]} -> {result['text_a']}")
if isinstance(result, sqlite3.Row):
log.debug('Result is sqlite3.Row as expected')
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
Symmetric encryption test
"""
import json
from corelibs.debug_handling.dump_data import dump_data
from corelibs.encryption_handling.symmetric_encryption import SymmetricEncryption
def main() -> None:
"""
Comment
"""
password = "strongpassword"
se = SymmetricEncryption(password)
plaintext = "Hello, World!"
ciphertext = se.encrypt_with_metadata_return_str(plaintext)
decrypted = se.decrypt_with_metadata(ciphertext)
print(f"Encrypted: {dump_data(json.loads(ciphertext))}")
print(f"Input: {plaintext} -> {decrypted}")
static_ciphertext = SymmetricEncryption.encrypt_data(plaintext, password)
decrypted = SymmetricEncryption.decrypt_data(static_ciphertext, password)
print(f"Static Encrypted: {dump_data(json.loads(static_ciphertext))}")
print(f"Input: {plaintext} -> {decrypted}")
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""
BOM check for files
"""
from pathlib import Path
from corelibs.file_handling.file_bom_encoding import is_bom_encoded, is_bom_encoded_info
from corelibs.debug_handling.dump_data import dump_data
def main() -> None:
"""
Check files for BOM encoding
"""
base_path = Path(__file__).resolve().parent
for file_path in [
'test-data/sample_with_bom.csv',
'test-data/sample_without_bom.csv',
]:
has_bom = is_bom_encoded(base_path.joinpath(file_path))
bom_info = is_bom_encoded_info(base_path.joinpath(file_path))
print(f'File: {file_path}')
print(f' Has BOM: {has_bom}')
print(f' BOM Info: {dump_data(bom_info)}')
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,6 @@
Name,Age,City,Country
John Doe,25,New York,USA
Jane Smith,30,London,UK
山田太郎,28,東京,Japan
María García,35,Madrid,Spain
François Dupont,42,Paris,France
1 Name Age City Country
2 John Doe 25 New York USA
3 Jane Smith 30 London UK
4 山田太郎 28 東京 Japan
5 María García 35 Madrid Spain
6 François Dupont 42 Paris France

View File

@@ -0,0 +1,6 @@
Name,Age,City,Country
John Doe,25,New York,USA
Jane Smith,30,London,UK
山田太郎,28,東京,Japan
María García,35,Madrid,Spain
François Dupont,42,Paris,France
1 Name Age City Country
2 John Doe 25 New York USA
3 Jane Smith 30 London UK
4 山田太郎 28 東京 Japan
5 María García 35 Madrid Spain
6 François Dupont 42 Paris France

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,120 @@
"""
Iterator helper testing
"""
from typing import Any
from corelibs.debug_handling.dump_data import dump_data
from corelibs.iterator_handling.dict_mask import mask
from corelibs.iterator_handling.dict_helpers import set_entry
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 __set_dict_value_entry():
dict_empty: dict[str, Any] = {}
new = set_entry(dict_empty, 'a.b.c', 1)
print(f"[1] Set dict entry: {dump_data(new)}")
new = set_entry(new, 'dict', {'key': 'value'})
print(f"[2] Set dict entry: {dump_data(new)}")
new = set_entry(new, 'list', [1, 2, 3])
print(f"[3] Set dict entry: {dump_data(new)}")
def main():
"""
Test: corelibs.string_handling.string_helpers
"""
__mask()
__set_dict_value_entry()
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,54 @@
#!/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',
"[?\"c\" && contains(\"c\", 'b')]",
"[?contains(\"c\", 'b')]",
]
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,52 @@
#!/usr/bin/env python3
"""
JSON content replace tets
"""
from deepdiff import DeepDiff
from corelibs.debug_handling.dump_data import dump_data
from corelibs.json_handling.json_helper import modify_with_jsonpath
def main() -> None:
"""
Comment
"""
__data = {
'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']
}
}
# Modify some values using JSONPath
__replace_data = modify_with_jsonpath(__data, 'bar.a', 42)
__replace_data = modify_with_jsonpath(__replace_data, 'foo.b[1]', 'modified')
__replace_data = modify_with_jsonpath(__replace_data, 'baz[0].ab', 'changed')
print(f"Original Data:\n{dump_data(__data)}\n")
print(f"Modified Data:\n{dump_data(__replace_data)}\n")
print(f"Differences:\n{dump_data(DeepDiff(__data, __replace_data, verbose_level=2))}\n")
if __name__ == "__main__":
main()
# __END__

View File

@@ -0,0 +1,116 @@
"""
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, ConsoleFormat, ConsoleFormatSettings
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,
# Set console log type, must be sent as value for ConsoleFormat or bitwise of ConsoleFormatType
# "console_format_type": ConsoleFormatSettings.BARE,
# "console_format_type": ConsoleFormatSettings.MINIMAL,
# "console_format_type": ConsoleFormatType.TIME_MICROSECONDS | ConsoleFormatType.NAME,
# "console_format_type": ConsoleFormatType.NAME,
"console_format_type": ConsoleFormat.TIME | ConsoleFormat.TIMEZONE | ConsoleFormat.LINENO,
}
)
logn = Logger(log.get_logger_settings())
log.info("ConsoleFormatType FILE is: %s", ConsoleFormat.FILE)
log.info("ConsoleFormatSettings ALL is: %s", ConsoleFormatSettings.ALL)
log.info("ConsoleFormatSettings lookup is: %s", ConsoleFormatSettings.from_string('ALL'))
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

@@ -9,8 +9,9 @@ from random import randint
import sys
import io
from pathlib import Path
from corelibs.file_handling.progress import Progress
from corelibs.string_handling.datetime_helpers import convert_timestamp, create_time
from corelibs.script_handling.progress import Progress
from corelibs.datetime_handling.datetime_helpers import create_time
from corelibs.datetime_handling.timestamp_convert import convert_timestamp
def main():

View File

@@ -5,7 +5,7 @@ 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.string_helpers import shorten_string, format_number, prepare_url_slash
from corelibs.string_handling.text_colors import Colors
@@ -52,7 +52,7 @@ def __sh_format_number():
print(f"Format {number} ({precision}) -> {result}")
def _sh_colors():
def __sh_colors():
for color in [
"black",
"red",
@@ -73,13 +73,26 @@ def _sh_colors():
print(f"Underline/Yellow/Bold: {Colors.underline}{Colors.bold}{Colors.yellow}UNDERLINE YELLOW BOLD{Colors.reset}")
def __prepare_url_slash():
urls = [
"api/v1/resource",
"/api/v1/resource",
"///api//v1//resource//",
"api//v1/resource/",
]
for url in urls:
prepared = prepare_url_slash(url)
print(f"IN: {url} -> OUT: {prepared}")
def main():
"""
Test: corelibs.string_handling.string_helpers
"""
__sh_shorten_string()
__sh_format_number()
_sh_colors()
__sh_colors()
__prepare_url_slash()
if __name__ == "__main__":

View File

@@ -4,10 +4,12 @@
Test for double byte format
"""
from corelibs.string_handling.timestamp_strings import TimestampStrings
from zoneinfo import ZoneInfo
from corelibs.datetime_handling.timestamp_strings import TimestampStrings
def main():
"""test"""
ts = TimestampStrings()
print(f"TS: {ts.timestamp_now}")
@@ -16,6 +18,14 @@ def main():
except ValueError as e:
print(f"Value error: {e}")
ts = TimestampStrings("Europe/Vienna")
print(f"TZ: {ts.time_zone} -> TS: {ts.timestamp_now_tz}")
ts = TimestampStrings(ZoneInfo("Europe/Vienna"))
print(f"TZ: {ts.time_zone} -> TS: {ts.timestamp_now_tz}")
custom_tz = 'Europe/Paris'
ts = TimestampStrings(time_zone=custom_tz)
print(f"TZ: {ts.time_zone} -> TS: {ts.timestamp_now_tz}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""
Enum handling
"""
from corelibs.var_handling.enum_base import EnumBase
class TestBlock(EnumBase):
"""Test block enum"""
BLOCK_A = "block_a"
HAS_NUM = 5
def main() -> None:
"""
Comment
"""
print(f"BLOCK A: {TestBlock.from_any('BLOCK_A')}")
print(f"HAS NUM: {TestBlock.from_any(5)}")
print(f"DIRECT BLOCK: {TestBlock.BLOCK_A.name} -> {TestBlock.BLOCK_A.value}")
if __name__ == "__main__":
main()
# __END__

View File

View File

View File

@@ -0,0 +1 @@
"""Unit tests for check_handling module."""

View File

@@ -0,0 +1,336 @@
"""
Unit tests for regex_constants module.
Tests all regex patterns defined in the check_handling.regex_constants module.
"""
import re
import pytest
from corelibs.check_handling.regex_constants import (
compile_re,
EMAIL_BASIC_REGEX,
DOMAIN_WITH_LOCALHOST_REGEX,
DOMAIN_WITH_LOCALHOST_PORT_REGEX,
DOMAIN_REGEX,
)
class TestCompileRe:
"""Test cases for the compile_re function."""
def test_compile_re_returns_pattern(self) -> None:
"""Test that compile_re returns a compiled regex Pattern object."""
pattern = compile_re(r"test")
assert isinstance(pattern, re.Pattern)
def test_compile_re_with_verbose_flag(self) -> None:
"""Test that compile_re compiles with VERBOSE flag."""
# Verbose mode allows whitespace and comments in regex
verbose_regex = r"""
\d+ # digits
\s+ # whitespace
"""
pattern = compile_re(verbose_regex)
assert pattern.match("123 ")
assert not pattern.match("abc")
def test_compile_re_simple_pattern(self) -> None:
"""Test compile_re with a simple pattern."""
pattern = compile_re(r"^\d{3}$")
assert pattern.match("123")
assert not pattern.match("12")
assert not pattern.match("1234")
class TestEmailBasicRegex:
"""Test cases for EMAIL_BASIC_REGEX pattern."""
@pytest.fixture
def email_pattern(self) -> re.Pattern[str]:
"""Fixture that returns compiled email regex pattern."""
return compile_re(EMAIL_BASIC_REGEX)
@pytest.mark.parametrize("valid_email", [
"user@example.com",
"test.user@example.com",
"user+tag@example.co.uk",
"first.last@subdomain.example.com",
"user123@test-domain.com",
"a@example.com",
"user_name@example.com",
"user-name@example.com",
"user@sub.domain.example.com",
"test!#$%&'*+-/=?^_`{|}~@example.com",
"1234567890@example.com",
"user@example-domain.com",
"user@domain.co",
# Regex allows these (even if not strictly RFC compliant):
"user.@example.com", # ends with dot before @
"user..name@example.com", # consecutive dots in local part
])
def test_valid_emails(
self, email_pattern: re.Pattern[str], valid_email: str
) -> None:
"""Test that valid email addresses match the pattern."""
assert email_pattern.match(valid_email), (
f"Failed to match valid email: {valid_email}"
)
@pytest.mark.parametrize("invalid_email", [
"", # empty string
"@example.com", # missing local part
"user@", # missing domain
"user", # no @ symbol
"user@.com", # domain starts with dot
"user@domain", # no TLD
"user @example.com", # space in local part
"user@exam ple.com", # space in domain
".user@example.com", # starts with dot
"user@-example.com", # domain starts with hyphen
"user@example-.com", # domain part ends with hyphen
"user@example.c", # TLD too short (1 char)
"user@example.toolong", # TLD too long (>6 chars)
"user@@example.com", # double @
"user@example@com", # multiple @
"user@.example.com", # domain starts with dot
"user@example.com.", # ends with dot
"user@123.456.789.012", # numeric TLD not allowed
])
def test_invalid_emails(
self, email_pattern: re.Pattern[str], invalid_email: str
) -> None:
"""Test that invalid email addresses do not match the pattern."""
assert not email_pattern.match(invalid_email), (
f"Incorrectly matched invalid email: {invalid_email}"
)
def test_email_max_local_part_length(
self, email_pattern: re.Pattern[str]
) -> None:
"""Test email with maximum local part length (64 characters)."""
# Local part can be up to 64 chars (first char + 63 more)
local_part = "a" * 64
email = f"{local_part}@example.com"
assert email_pattern.match(email)
def test_email_exceeds_local_part_length(
self, email_pattern: re.Pattern[str]
) -> None:
"""Test email exceeding maximum local part length."""
# 65 characters should not match
local_part = "a" * 65
email = f"{local_part}@example.com"
assert not email_pattern.match(email)
class TestDomainWithLocalhostRegex:
"""Test cases for DOMAIN_WITH_LOCALHOST_REGEX pattern."""
@pytest.fixture
def domain_localhost_pattern(self) -> re.Pattern[str]:
"""Fixture that returns compiled domain with localhost regex pattern."""
return compile_re(DOMAIN_WITH_LOCALHOST_REGEX)
@pytest.mark.parametrize("valid_domain", [
"localhost",
"example.com",
"subdomain.example.com",
"sub.domain.example.com",
"test-domain.com",
"example.co.uk",
"a.com",
"test123.example.com",
"my-site.example.org",
"multi.level.subdomain.example.com",
])
def test_valid_domains(
self, domain_localhost_pattern: re.Pattern[str], valid_domain: str
) -> None:
"""Test that valid domains (including localhost) match the pattern."""
assert domain_localhost_pattern.match(valid_domain), (
f"Failed to match valid domain: {valid_domain}"
)
@pytest.mark.parametrize("invalid_domain", [
"", # empty string
"example", # no TLD
"-example.com", # starts with hyphen
"example-.com", # ends with hyphen
".example.com", # starts with dot
"example.com.", # ends with dot
"example..com", # consecutive dots
"exam ple.com", # space in domain
"example.c", # TLD too short
"localhost:8080", # port not allowed in this pattern
"example.com:8080", # port not allowed in this pattern
"@example.com", # invalid character
"example@com", # invalid character
])
def test_invalid_domains(
self, domain_localhost_pattern: re.Pattern[str], invalid_domain: str
) -> None:
"""Test that invalid domains do not match the pattern."""
assert not domain_localhost_pattern.match(invalid_domain), (
f"Incorrectly matched invalid domain: {invalid_domain}"
)
class TestDomainWithLocalhostPortRegex:
"""Test cases for DOMAIN_WITH_LOCALHOST_PORT_REGEX pattern."""
@pytest.fixture
def domain_localhost_port_pattern(self) -> re.Pattern[str]:
"""Fixture that returns compiled domain and localhost with port pattern."""
return compile_re(DOMAIN_WITH_LOCALHOST_PORT_REGEX)
@pytest.mark.parametrize("valid_domain", [
"localhost",
"localhost:8080",
"localhost:3000",
"localhost:80",
"localhost:443",
"localhost:65535",
"example.com",
"example.com:8080",
"subdomain.example.com:3000",
"test-domain.com:443",
"example.co.uk",
"example.co.uk:8000",
"a.com:1",
"multi.level.subdomain.example.com:9999",
])
def test_valid_domains_with_port(
self, domain_localhost_port_pattern: re.Pattern[str], valid_domain: str
) -> None:
"""Test that valid domains with optional ports match the pattern."""
assert domain_localhost_port_pattern.match(valid_domain), (
f"Failed to match valid domain: {valid_domain}"
)
@pytest.mark.parametrize("invalid_domain", [
"", # empty string
"example", # no TLD
"-example.com", # starts with hyphen
"example-.com", # ends with hyphen
".example.com", # starts with dot
"example.com.", # ends with dot
"localhost:", # port without number
"example.com:", # port without number
"example.com:abc", # non-numeric port
"example.com: 8080", # space before port
"example.com:80 80", # space in port
"exam ple.com", # space in domain
"localhost :8080", # space before colon
])
def test_invalid_domains_with_port(
self,
domain_localhost_port_pattern: re.Pattern[str],
invalid_domain: str,
) -> None:
"""Test that invalid domains do not match the pattern."""
assert not domain_localhost_port_pattern.match(invalid_domain), (
f"Incorrectly matched invalid domain: {invalid_domain}"
)
def test_large_port_number(
self, domain_localhost_port_pattern: re.Pattern[str]
) -> None:
"""Test domain with large port numbers."""
assert domain_localhost_port_pattern.match("example.com:65535")
# Regex doesn't validate port range
assert domain_localhost_port_pattern.match("example.com:99999")
class TestDomainRegex:
"""Test cases for DOMAIN_REGEX pattern (no localhost)."""
@pytest.fixture
def domain_pattern(self) -> re.Pattern[str]:
"""Fixture that returns compiled domain regex pattern."""
return compile_re(DOMAIN_REGEX)
@pytest.mark.parametrize("valid_domain", [
"example.com",
"subdomain.example.com",
"sub.domain.example.com",
"test-domain.com",
"example.co.uk",
"a.com",
"test123.example.com",
"my-site.example.org",
"multi.level.subdomain.example.com",
"example.co",
])
def test_valid_domains_no_localhost(
self, domain_pattern: re.Pattern[str], valid_domain: str
) -> None:
"""Test that valid domains match the pattern."""
assert domain_pattern.match(valid_domain), (
f"Failed to match valid domain: {valid_domain}"
)
@pytest.mark.parametrize("invalid_domain", [
"", # empty string
"localhost", # localhost not allowed
"example", # no TLD
"-example.com", # starts with hyphen
"example-.com", # ends with hyphen
".example.com", # starts with dot
"example.com.", # ends with dot
"example..com", # consecutive dots
"exam ple.com", # space in domain
"example.c", # TLD too short
"example.com:8080", # port not allowed
"@example.com", # invalid character
"example@com", # invalid character
])
def test_invalid_domains_no_localhost(
self, domain_pattern: re.Pattern[str], invalid_domain: str
) -> None:
"""Test that invalid domains do not match the pattern."""
assert not domain_pattern.match(invalid_domain), (
f"Incorrectly matched invalid domain: {invalid_domain}"
)
def test_localhost_not_allowed(
self, domain_pattern: re.Pattern[str]
) -> None:
"""Test that localhost is explicitly not allowed in DOMAIN_REGEX."""
assert not domain_pattern.match("localhost")
class TestRegexPatternConsistency:
"""Test cases for consistency across regex patterns."""
def test_all_patterns_compile(self) -> None:
"""Test that all regex patterns can be compiled without errors."""
patterns = [
EMAIL_BASIC_REGEX,
DOMAIN_WITH_LOCALHOST_REGEX,
DOMAIN_WITH_LOCALHOST_PORT_REGEX,
DOMAIN_REGEX,
]
for pattern in patterns:
compiled = compile_re(pattern)
assert isinstance(compiled, re.Pattern)
def test_domain_patterns_are_strings(self) -> None:
"""Test that all regex constants are strings."""
assert isinstance(EMAIL_BASIC_REGEX, str)
assert isinstance(DOMAIN_WITH_LOCALHOST_REGEX, str)
assert isinstance(DOMAIN_WITH_LOCALHOST_PORT_REGEX, str)
assert isinstance(DOMAIN_REGEX, str)
def test_domain_patterns_hierarchy(self) -> None:
"""Test that domain patterns follow expected hierarchy."""
# DOMAIN_WITH_LOCALHOST_PORT_REGEX should accept everything
# DOMAIN_WITH_LOCALHOST_REGEX accepts
domain_localhost = compile_re(DOMAIN_WITH_LOCALHOST_REGEX)
domain_localhost_port = compile_re(DOMAIN_WITH_LOCALHOST_PORT_REGEX)
test_cases = ["example.com", "subdomain.example.com", "localhost"]
for test_case in test_cases:
if domain_localhost.match(test_case):
assert domain_localhost_port.match(test_case), (
f"{test_case} should match both patterns"
)

View File

@@ -0,0 +1,708 @@
"""
Unit tests for SettingsLoader class
"""
import configparser
from pathlib import Path
from unittest.mock import Mock
import pytest
from pytest import CaptureFixture
from corelibs.config_handling.settings_loader import SettingsLoader
from corelibs.logging_handling.log import Log
class TestSettingsLoaderInit:
"""Test cases for SettingsLoader initialization"""
def test_init_with_valid_config_file(self, tmp_path: Path):
"""Test initialization with a valid config file"""
config_file = tmp_path / "test.ini"
config_file.write_text("[Section]\nkey=value\n")
loader = SettingsLoader(
args={},
config_file=config_file,
log=None,
always_print=False
)
assert loader.args == {}
assert loader.config_file == config_file
assert loader.log is None
assert loader.always_print is False
assert loader.config_parser is not None
assert isinstance(loader.config_parser, configparser.ConfigParser)
def test_init_with_missing_config_file(self, tmp_path: Path):
"""Test initialization with missing config file"""
config_file = tmp_path / "missing.ini"
loader = SettingsLoader(
args={},
config_file=config_file,
log=None,
always_print=False
)
assert loader.config_parser is None
def test_init_with_invalid_config_folder(self):
"""Test initialization with invalid config folder path"""
config_file = Path("/nonexistent/path/test.ini")
with pytest.raises(ValueError, match="Cannot find the config folder"):
SettingsLoader(
args={},
config_file=config_file,
log=None,
always_print=False
)
def test_init_with_log(self, tmp_path: Path):
"""Test initialization with Log object"""
config_file = tmp_path / "test.ini"
config_file.write_text("[Section]\nkey=value\n")
mock_log = Mock(spec=Log)
loader = SettingsLoader(
args={"test": "value"},
config_file=config_file,
log=mock_log,
always_print=True
)
assert loader.log == mock_log
assert loader.always_print is True
class TestLoadSettings:
"""Test cases for load_settings method"""
def test_load_settings_basic(self, tmp_path: Path):
"""Test loading basic settings without validation"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nkey1=value1\nkey2=value2\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings("TestSection")
assert result == {"key1": "value1", "key2": "value2"}
def test_load_settings_with_missing_section(self, tmp_path: Path):
"""Test loading settings with missing section"""
config_file = tmp_path / "test.ini"
config_file.write_text("[OtherSection]\nkey=value\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Cannot read \\[MissingSection\\]"):
loader.load_settings("MissingSection")
def test_load_settings_allow_not_exist(self, tmp_path: Path):
"""Test loading settings with allow_not_exist flag"""
config_file = tmp_path / "test.ini"
config_file.write_text("[OtherSection]\nkey=value\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings("MissingSection", allow_not_exist=True)
assert result == {}
def test_load_settings_mandatory_field_present(self, tmp_path: Path):
"""Test mandatory field validation when field is present"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nrequired_field=value\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"required_field": ["mandatory:yes"]}
)
assert result["required_field"] == "value"
def test_load_settings_mandatory_field_missing(self, tmp_path: Path):
"""Test mandatory field validation when field is missing"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nother_field=value\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"required_field": ["mandatory:yes"]}
)
def test_load_settings_mandatory_field_empty(self, tmp_path: Path):
"""Test mandatory field validation when field is empty"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nrequired_field=\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"required_field": ["mandatory:yes"]}
)
def test_load_settings_with_split(self, tmp_path: Path):
"""Test splitting values into lists"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nlist_field=a,b,c,d\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"list_field": ["split:,"]}
)
assert result["list_field"] == ["a", "b", "c", "d"]
def test_load_settings_with_custom_split_char(self, tmp_path: Path):
"""Test splitting with custom delimiter"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nlist_field=a|b|c|d\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"list_field": ["split:|"]}
)
assert result["list_field"] == ["a", "b", "c", "d"]
def test_load_settings_split_removes_spaces(self, tmp_path: Path):
"""Test that split removes spaces from values"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nlist_field=a, b , c , d\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"list_field": ["split:,"]}
)
assert result["list_field"] == ["a", "b", "c", "d"]
def test_load_settings_empty_split_char_fallback(self, tmp_path: Path, capsys: CaptureFixture[str]):
"""Test fallback to default split char when empty"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nlist_field=a,b,c\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"list_field": ["split:"]}
)
assert result["list_field"] == ["a", "b", "c"]
captured = capsys.readouterr()
assert "fallback to:" in captured.out
def test_load_settings_convert_to_int(self, tmp_path: Path):
"""Test converting values to int"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nnumber=123\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"number": ["convert:int"]}
)
assert result["number"] == 123
assert isinstance(result["number"], int)
def test_load_settings_convert_to_float(self, tmp_path: Path):
"""Test converting values to float"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nnumber=123.45\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"number": ["convert:float"]}
)
assert result["number"] == 123.45
assert isinstance(result["number"], float)
def test_load_settings_convert_to_bool_true(self, tmp_path: Path):
"""Test converting values to boolean True"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nflag1=true\nflag2=True\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"flag1": ["convert:bool"], "flag2": ["convert:bool"]}
)
assert result["flag1"] is True
assert result["flag2"] is True
def test_load_settings_convert_to_bool_false(self, tmp_path: Path):
"""Test converting values to boolean False"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nflag1=false\nflag2=False\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"flag1": ["convert:bool"], "flag2": ["convert:bool"]}
)
assert result["flag1"] is False
assert result["flag2"] is False
def test_load_settings_convert_invalid_type(self, tmp_path: Path):
"""Test converting with invalid type raises error"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=test\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="convert type is invalid"):
loader.load_settings(
"TestSection",
{"value": ["convert:invalid"]}
)
def test_load_settings_empty_set_to_none(self, tmp_path: Path):
"""Test setting empty values to None"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nother=value\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"field": ["empty:"]}
)
assert result["field"] is None
def test_load_settings_empty_set_to_custom_value(self, tmp_path: Path):
"""Test setting empty values to custom value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nother=value\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"field": ["empty:default"]}
)
assert result["field"] == "default"
def test_load_settings_matching_valid(self, tmp_path: Path):
"""Test matching validation with valid value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nmode=production\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"mode": ["matching:development|staging|production"]}
)
assert result["mode"] == "production"
def test_load_settings_matching_invalid(self, tmp_path: Path):
"""Test matching validation with invalid value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nmode=invalid\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"mode": ["matching:development|staging|production"]}
)
def test_load_settings_in_valid(self, tmp_path: Path):
"""Test 'in' validation with valid value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nallowed=a,b,c\nvalue=b\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{
"allowed": ["split:,"],
"value": ["in:allowed"]
}
)
assert result["value"] == "b"
def test_load_settings_in_invalid(self, tmp_path: Path):
"""Test 'in' validation with invalid value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nallowed=a,b,c\nvalue=d\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{
"allowed": ["split:,"],
"value": ["in:allowed"]
}
)
def test_load_settings_in_missing_target(self, tmp_path: Path):
"""Test 'in' validation with missing target"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=a\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"value": ["in:missing_target"]}
)
def test_load_settings_length_exact(self, tmp_path: Path):
"""Test length validation with exact match"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=test\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"value": ["length:4"]}
)
assert result["value"] == "test"
def test_load_settings_length_exact_invalid(self, tmp_path: Path):
"""Test length validation with exact match failure"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=test\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"value": ["length:5"]}
)
def test_load_settings_length_range(self, tmp_path: Path):
"""Test length validation with range"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=testing\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"value": ["length:5-10"]}
)
assert result["value"] == "testing"
def test_load_settings_length_min_only(self, tmp_path: Path):
"""Test length validation with minimum only"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=testing\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"value": ["length:5-"]}
)
assert result["value"] == "testing"
def test_load_settings_length_max_only(self, tmp_path: Path):
"""Test length validation with maximum only"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=test\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"value": ["length:-10"]}
)
assert result["value"] == "test"
def test_load_settings_range_valid(self, tmp_path: Path):
"""Test range validation with valid value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nnumber=25\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"number": ["range:10-50"]}
)
assert result["number"] == "25"
def test_load_settings_range_invalid(self, tmp_path: Path):
"""Test range validation with invalid value"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nnumber=100\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"number": ["range:10-50"]}
)
def test_load_settings_check_int_valid(self, tmp_path: Path):
"""Test check:int with valid integer"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nnumber=12345\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"number": ["check:int"]}
)
assert result["number"] == "12345"
def test_load_settings_check_int_cleanup(self, tmp_path: Path):
"""Test check:int with cleanup"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nnumber=12a34b5\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"number": ["check:int"]}
)
assert result["number"] == "12345"
def test_load_settings_check_email_valid(self, tmp_path: Path):
"""Test check:string.email.basic with valid email"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nemail=test@example.com\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"email": ["check:string.email.basic"]}
)
assert result["email"] == "test@example.com"
def test_load_settings_check_email_invalid(self, tmp_path: Path):
"""Test check:string.email.basic with invalid email"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nemail=not-an-email\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Missing or incorrect settings data"):
loader.load_settings(
"TestSection",
{"email": ["check:string.email.basic"]}
)
def test_load_settings_args_override(self, tmp_path: Path, capsys: CaptureFixture[str]):
"""Test command line arguments override config values"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=config_value\n")
loader = SettingsLoader(
args={"value": "arg_value"},
config_file=config_file
)
result = loader.load_settings(
"TestSection",
{"value": []}
)
assert result["value"] == "arg_value"
captured = capsys.readouterr()
assert "Command line option override" in captured.out
def test_load_settings_no_config_file_with_args(self, tmp_path: Path):
"""Test loading settings without config file but with mandatory args"""
config_file = tmp_path / "missing.ini"
loader = SettingsLoader(
args={"required": "value"},
config_file=config_file
)
result = loader.load_settings(
"TestSection",
{"required": ["mandatory:yes"]}
)
assert result["required"] == "value"
def test_load_settings_no_config_file_missing_args(self, tmp_path: Path):
"""Test loading settings without config file and missing args"""
config_file = tmp_path / "missing.ini"
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Cannot find file"):
loader.load_settings(
"TestSection",
{"required": ["mandatory:yes"]}
)
def test_load_settings_check_list_with_split(self, tmp_path: Path):
"""Test check validation with list values"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nlist=abc,def,ghi\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"list": ["split:,", "check:string.alphanumeric"]}
)
assert result["list"] == ["abc", "def", "ghi"]
def test_load_settings_check_list_cleanup(self, tmp_path: Path):
"""Test check validation cleans up list values"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nlist=ab-c,de_f,gh!i\n")
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"TestSection",
{"list": ["split:,", "check:string.alphanumeric"]}
)
assert result["list"] == ["abc", "def", "ghi"]
def test_load_settings_invalid_check_type(self, tmp_path: Path):
"""Test with invalid check type"""
config_file = tmp_path / "test.ini"
config_file.write_text("[TestSection]\nvalue=test\n")
loader = SettingsLoader(args={}, config_file=config_file)
with pytest.raises(ValueError, match="Cannot get SettingsLoaderCheck.CHECK_SETTINGS"):
loader.load_settings(
"TestSection",
{"value": ["check:invalid.check.type"]}
)
class TestComplexScenarios:
"""Test cases for complex real-world scenarios"""
def test_complex_validation_scenario(self, tmp_path: Path):
"""Test complex scenario with multiple validations"""
config_file = tmp_path / "test.ini"
config_file.write_text(
"[Production]\n"
"environment=production\n"
"allowed_envs=development,staging,production\n"
"port=8080\n"
"host=example.com\n"
"timeout=30\n"
"debug=false\n"
"features=auth,logging,monitoring\n"
)
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"Production",
{
"environment": [
"mandatory:yes",
"matching:development|staging|production",
"in:allowed_envs"
],
"allowed_envs": ["split:,"],
"port": ["mandatory:yes", "convert:int", "range:1-65535"],
"host": ["mandatory:yes"],
"timeout": ["convert:int", "range:1-"],
"debug": ["convert:bool"],
"features": ["split:,", "check:string.alphanumeric"],
}
)
assert result["environment"] == "production"
assert result["allowed_envs"] == ["development", "staging", "production"]
assert result["port"] == 8080
assert isinstance(result["port"], int)
assert result["host"] == "example.com"
assert result["timeout"] == 30
assert result["debug"] is False
assert result["features"] == ["auth", "logging", "monitoring"]
def test_email_list_validation(self, tmp_path: Path):
"""Test email list with validation"""
config_file = tmp_path / "test.ini"
config_file.write_text(
"[EmailConfig]\n"
"emails=test@example.com,admin@domain.org,user+tag@site.co.uk\n"
)
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"EmailConfig",
{"emails": ["split:,", "mandatory:yes", "check:string.email.basic"]}
)
assert len(result["emails"]) == 3
assert "test@example.com" in result["emails"]
def test_mixed_args_and_config(self, tmp_path: Path):
"""Test mixing command line args and config file"""
config_file = tmp_path / "test.ini"
config_file.write_text(
"[Settings]\n"
"value1=config_value1\n"
"value2=config_value2\n"
)
loader = SettingsLoader(
args={"value1": "arg_value1"},
config_file=config_file
)
result = loader.load_settings(
"Settings",
{"value1": [], "value2": []}
)
assert result["value1"] == "arg_value1" # Overridden by arg
assert result["value2"] == "config_value2" # From config
def test_multiple_check_types(self, tmp_path: Path):
"""Test multiple different check types"""
config_file = tmp_path / "test.ini"
config_file.write_text(
"[Checks]\n"
"numbers=123,456,789\n"
"alphas=abc,def,ghi\n"
"emails=test@example.com\n"
"date=2025-01-15\n"
)
loader = SettingsLoader(args={}, config_file=config_file)
result = loader.load_settings(
"Checks",
{
"numbers": ["split:,", "check:int"],
"alphas": ["split:,", "check:string.alphanumeric"],
"emails": ["check:string.email.basic"],
"date": ["check:string.date"],
}
)
assert result["numbers"] == ["123", "456", "789"]
assert result["alphas"] == ["abc", "def", "ghi"]
assert result["emails"] == "test@example.com"
assert result["date"] == "2025-01-15"
# __END__

View File

@@ -0,0 +1,3 @@
"""
db_handling tests
"""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,639 @@
"""
Unit tests for debug_handling.debug_helpers module
"""
import sys
import pytest
from corelibs.debug_handling.debug_helpers import (
call_stack,
exception_stack,
OptExcInfo
)
class TestCallStack:
"""Test cases for call_stack function"""
def test_call_stack_basic(self):
"""Test basic call_stack functionality"""
result = call_stack()
assert isinstance(result, str)
assert "test_debug_helpers.py" in result
assert "test_call_stack_basic" in result
def test_call_stack_with_default_separator(self):
"""Test call_stack with default separator"""
result = call_stack()
assert " -> " in result
def test_call_stack_with_custom_separator(self):
"""Test call_stack with custom separator"""
result = call_stack(separator=" | ")
assert " | " in result
assert " -> " not in result
def test_call_stack_with_empty_separator(self):
"""Test call_stack with empty separator (should default to ' -> ')"""
result = call_stack(separator="")
assert " -> " in result
def test_call_stack_format(self):
"""Test call_stack output format (filename:function:lineno)"""
result = call_stack()
parts = result.split(" -> ")
for part in parts:
# Each part should have format: filename:function:lineno
assert part.count(":") >= 2
# Most parts should contain .py but some system frames might not
# Just check that we have some .py files in the trace
assert ".py" in result or "test_debug_helpers" in result
def test_call_stack_with_start_offset(self):
"""Test call_stack with start offset"""
result_no_offset = call_stack(start=0)
result_with_offset = call_stack(start=2)
# With offset, we should get fewer frames
parts_no_offset = result_no_offset.split(" -> ")
parts_with_offset = result_with_offset.split(" -> ")
assert len(parts_with_offset) <= len(parts_no_offset)
def test_call_stack_with_skip_last(self):
"""Test call_stack with skip_last parameter"""
result_skip_default = call_stack(skip_last=-1)
result_skip_more = call_stack(skip_last=-3)
# Skipping more should result in fewer frames
parts_default = result_skip_default.split(" -> ")
parts_more = result_skip_more.split(" -> ")
assert len(parts_more) <= len(parts_default)
def test_call_stack_skip_last_positive_converts_to_negative(self):
"""Test that positive skip_last is converted to negative"""
# Both should produce same result
result_negative = call_stack(skip_last=-2)
result_positive = call_stack(skip_last=2)
assert result_negative == result_positive
def test_call_stack_nested_calls(self):
"""Test call_stack in nested function calls"""
def level_one():
return level_two()
def level_two():
return level_three()
def level_three():
return call_stack()
result = level_one()
assert "level_one" in result
assert "level_two" in result
assert "level_three" in result
def test_call_stack_reset_start_if_empty_false(self):
"""Test call_stack with high start value and reset_start_if_empty=False"""
# Using a very high start value should result in empty stack
result = call_stack(start=1000, reset_start_if_empty=False)
assert result == ""
def test_call_stack_reset_start_if_empty_true(self):
"""Test call_stack with high start value and reset_start_if_empty=True"""
# Using a very high start value with reset should give non-empty result
result = call_stack(start=1000, reset_start_if_empty=True)
assert result != ""
assert "test_debug_helpers.py" in result
def test_call_stack_contains_line_numbers(self):
"""Test that call_stack includes line numbers"""
result = call_stack()
# Extract parts and check for numbers
parts = result.split(" -> ")
for part in parts:
# Line numbers should be present (digits at the end)
assert any(char.isdigit() for char in part)
def test_call_stack_separator_none(self):
"""Test call_stack with None separator"""
result = call_stack(separator="") # Use empty string instead of None
# Empty string should be converted to default ' -> '
assert " -> " in result
def test_call_stack_multiple_separators(self):
"""Test call_stack with various custom separators"""
separators = [" | ", " >> ", " => ", " / ", "\n"]
for sep in separators:
result = call_stack(separator=sep)
assert sep in result or result == "" # May be empty based on stack depth
class TestExceptionStack:
"""Test cases for exception_stack function"""
def test_exception_stack_with_active_exception(self):
"""Test exception_stack when an exception is active"""
try:
raise ValueError("Test exception")
except ValueError:
result = exception_stack()
assert isinstance(result, str)
assert "test_debug_helpers.py" in result
assert "test_exception_stack_with_active_exception" in result
def test_exception_stack_format(self):
"""Test exception_stack output format"""
try:
raise RuntimeError("Test error")
except RuntimeError:
result = exception_stack()
parts = result.split(" -> ")
for part in parts:
# Each part should have format: filename:function:lineno
assert part.count(":") >= 2
def test_exception_stack_with_custom_separator(self):
"""Test exception_stack with custom separator"""
def nested_call():
def inner_call():
raise TypeError("Test type error")
inner_call()
try:
nested_call()
except TypeError:
result = exception_stack(separator=" | ")
# Only check separator if there are multiple frames
if " | " in result or result.count(":") == 2:
# Single frame or has separator
assert isinstance(result, str)
assert " -> " not in result
def test_exception_stack_with_empty_separator(self):
"""Test exception_stack with empty separator (should default to ' -> ')"""
def nested_call():
def inner_call():
raise KeyError("Test key error")
inner_call()
try:
nested_call()
except KeyError:
result = exception_stack(separator="")
# Should use default separator if multiple frames exist
assert isinstance(result, str)
def test_exception_stack_separator_none(self):
"""Test exception_stack with empty separator"""
def nested_call():
def inner_call():
raise IndexError("Test index error")
inner_call()
try:
nested_call()
except IndexError:
result = exception_stack(separator="") # Use empty string instead of None
assert isinstance(result, str)
def test_exception_stack_nested_exceptions(self):
"""Test exception_stack with nested function calls"""
def level_one():
level_two()
def level_two():
level_three()
def level_three():
raise ValueError("Nested exception")
try:
level_one()
except ValueError:
result = exception_stack()
# Should contain all levels in the stack
assert "level_one" in result or "level_two" in result or "level_three" in result
def test_exception_stack_with_provided_exc_info(self):
"""Test exception_stack with explicitly provided exc_info"""
try:
raise AttributeError("Test attribute error")
except AttributeError:
exc_info = sys.exc_info()
result = exception_stack(exc_stack=exc_info)
assert isinstance(result, str)
assert len(result) > 0
def test_exception_stack_no_active_exception(self):
"""Test exception_stack when no exception is active"""
# This should handle the case gracefully
# When no exception is active, sys.exc_info() returns (None, None, None)
result = exception_stack()
# With no traceback, should return empty string or handle gracefully
assert isinstance(result, str)
def test_exception_stack_contains_line_numbers(self):
"""Test that exception_stack includes line numbers"""
try:
raise OSError("Test OS error")
except OSError:
result = exception_stack()
if result: # May be empty
parts = result.split(" -> ")
for part in parts:
# Line numbers should be present
assert any(char.isdigit() for char in part)
def test_exception_stack_multiple_exceptions(self):
"""Test exception_stack captures the current exception only"""
first_result = None
second_result = None
try:
raise ValueError("First exception")
except ValueError:
first_result = exception_stack()
try:
raise TypeError("Second exception")
except TypeError:
second_result = exception_stack()
# Both should be valid but may differ
assert isinstance(first_result, str)
assert isinstance(second_result, str)
def test_exception_stack_with_multiple_separators(self):
"""Test exception_stack with various custom separators"""
separators = [" | ", " >> ", " => ", " / ", "\n"]
def nested_call():
def inner_call():
raise ValueError("Test exception")
inner_call()
for sep in separators:
try:
nested_call()
except ValueError:
result = exception_stack(separator=sep)
assert isinstance(result, str)
# Separator only appears if there are multiple frames
class TestOptExcInfo:
"""Test cases for OptExcInfo type definition"""
def test_opt_exc_info_type_none_tuple(self):
"""Test OptExcInfo can be None tuple"""
exc_info: OptExcInfo = (None, None, None)
assert exc_info == (None, None, None)
def test_opt_exc_info_type_exception_tuple(self):
"""Test OptExcInfo can be exception tuple"""
try:
raise ValueError("Test")
except ValueError:
exc_info: OptExcInfo = sys.exc_info()
assert exc_info[0] is not None
assert exc_info[1] is not None
assert exc_info[2] is not None
def test_opt_exc_info_with_exception_stack(self):
"""Test that OptExcInfo works with exception_stack function"""
try:
raise RuntimeError("Test runtime error")
except RuntimeError:
exc_info = sys.exc_info()
result = exception_stack(exc_stack=exc_info)
assert isinstance(result, str)
class TestIntegration:
"""Integration tests combining multiple scenarios"""
def test_call_stack_and_exception_stack_together(self):
"""Test using both call_stack and exception_stack in error handling"""
def faulty_function():
_ = call_stack() # Get call stack before exception
raise ValueError("Intentional error")
try:
faulty_function()
except ValueError:
exception_trace = exception_stack()
assert isinstance(exception_trace, str)
assert "faulty_function" in exception_trace or "test_debug_helpers.py" in exception_trace
def test_nested_exception_with_call_stack(self):
"""Test call_stack within exception handling"""
def outer():
return inner()
def inner():
try:
raise RuntimeError("Inner error")
except RuntimeError:
return {
'call_stack': call_stack(),
'exception_stack': exception_stack()
}
result = outer()
assert 'call_stack' in result
assert 'exception_stack' in result
assert isinstance(result['call_stack'], str)
assert isinstance(result['exception_stack'], str)
def test_multiple_nested_levels(self):
"""Test with multiple nested function levels"""
def level_a():
return level_b()
def level_b():
return level_c()
def level_c():
return level_d()
def level_d():
try:
raise ValueError("Deep error")
except ValueError:
return {
'call': call_stack(),
'exception': exception_stack()
}
result = level_a()
# Should contain information about the call chain
assert result['call']
assert result['exception']
def test_different_separators_consistency(self):
"""Test that different separators work consistently"""
separators = [" -> ", " | ", " / ", " >> "]
def nested_call():
def inner_call():
raise ValueError("Test")
inner_call()
for sep in separators:
try:
nested_call()
except ValueError:
exc_result = exception_stack(separator=sep)
call_result = call_stack(separator=sep)
assert isinstance(exc_result, str)
assert isinstance(call_result, str)
# Both should be valid strings (separator check only if multiple frames)
class TestEdgeCases:
"""Test edge cases and boundary conditions"""
def test_call_stack_with_zero_start(self):
"""Test call_stack with start=0 (should include all frames)"""
result = call_stack(start=0)
assert isinstance(result, str)
assert len(result) > 0
def test_call_stack_with_large_skip_last(self):
"""Test call_stack with very large skip_last value"""
result = call_stack(skip_last=-100)
# Should handle gracefully, may be empty
assert isinstance(result, str)
def test_exception_stack_none_exc_info(self):
"""Test exception_stack with None as exc_stack"""
result = exception_stack(exc_stack=None)
assert isinstance(result, str)
def test_exception_stack_empty_tuple(self):
"""Test exception_stack with empty exception info"""
exc_info: OptExcInfo = (None, None, None)
result = exception_stack(exc_stack=exc_info)
assert isinstance(result, str)
def test_call_stack_special_characters_in_separator(self):
"""Test call_stack with special characters in separator"""
special_separators = ["\n", "\t", "->", "||", "//"]
for sep in special_separators:
result = call_stack(separator=sep)
assert isinstance(result, str)
def test_very_deep_call_stack(self):
"""Test call_stack with very deep recursion (up to a limit)"""
def recursive_call(depth: int, max_depth: int = 5) -> str:
if depth >= max_depth:
return call_stack()
return recursive_call(depth + 1, max_depth)
result = recursive_call(0)
assert isinstance(result, str)
# Should contain multiple recursive_call entries
assert result.count("recursive_call") > 0
def test_exception_stack_different_exception_types(self):
"""Test exception_stack with various exception types"""
exception_types = [
ValueError("value"),
TypeError("type"),
KeyError("key"),
IndexError("index"),
AttributeError("attr"),
RuntimeError("runtime"),
]
for exc in exception_types:
try:
raise exc
except (ValueError, TypeError, KeyError, IndexError, AttributeError, RuntimeError):
result = exception_stack()
assert isinstance(result, str)
class TestRealWorldScenarios:
"""Test real-world debugging scenarios"""
def test_debugging_workflow(self):
"""Test typical debugging workflow with both functions"""
def process_data(data: str) -> str:
_ = call_stack() # Capture call stack for debugging
if not data:
raise ValueError("No data provided")
return data.upper()
# Success case
result = process_data("test")
assert result == "TEST"
# Error case
try:
process_data("")
except ValueError:
exc_trace = exception_stack()
assert isinstance(exc_trace, str)
def test_logging_context(self):
"""Test using call_stack for logging context"""
def get_logging_context():
return {
'timestamp': 'now',
'stack': call_stack(start=1, separator=" > "),
'function': 'get_logging_context'
}
context = get_logging_context()
assert 'stack' in context
assert 'timestamp' in context
assert isinstance(context['stack'], str)
def test_error_reporting(self):
"""Test comprehensive error reporting"""
def dangerous_operation() -> dict[str, str]:
try:
# Simulate some operation
_ = 1 / 0
except ZeroDivisionError:
return {
'error': 'Division by zero',
'call_stack': call_stack(),
'exception_stack': exception_stack(),
}
return {} # Fallback return
error_report = dangerous_operation()
assert error_report is not None
assert 'error' in error_report
assert 'call_stack' in error_report
assert 'exception_stack' in error_report
assert error_report['error'] == 'Division by zero'
def test_function_tracing(self):
"""Test function call tracing"""
traces: list[str] = []
def traced_function_a() -> str:
traces.append(call_stack())
return traced_function_b()
def traced_function_b() -> str:
traces.append(call_stack())
return traced_function_c()
def traced_function_c() -> str:
traces.append(call_stack())
return "done"
result = traced_function_a()
assert result == "done"
assert len(traces) == 3
# Each trace should be different (different call depths)
assert all(isinstance(t, str) for t in traces)
def test_exception_chain_tracking(self):
"""Test tracking exception chains"""
exception_traces: list[str] = []
def operation_one() -> None:
try:
operation_two()
except ValueError:
exception_traces.append(exception_stack())
raise
def operation_two() -> None:
try:
operation_three()
except TypeError as exc:
exception_traces.append(exception_stack())
raise ValueError("Wrapped error") from exc
def operation_three() -> None:
raise TypeError("Original error")
try:
operation_one()
except ValueError:
exception_traces.append(exception_stack())
# Should have captured multiple exception stacks
assert len(exception_traces) > 0
assert all(isinstance(t, str) for t in exception_traces)
class TestParametrized:
"""Parametrized tests for comprehensive coverage"""
@pytest.mark.parametrize("start", [0, 1, 2, 5, 10])
def test_call_stack_various_starts(self, start: int) -> None:
"""Test call_stack with various start values"""
result = call_stack(start=start)
assert isinstance(result, str)
@pytest.mark.parametrize("skip_last", [-1, -2, -3, -5, 1, 2, 3, 5])
def test_call_stack_various_skip_lasts(self, skip_last: int) -> None:
"""Test call_stack with various skip_last values"""
result = call_stack(skip_last=skip_last)
assert isinstance(result, str)
@pytest.mark.parametrize("separator", [" -> ", " | ", " / ", " >> ", " => ", "\n", "\t"])
def test_call_stack_various_separators(self, separator: str) -> None:
"""Test call_stack with various separators"""
result = call_stack(separator=separator)
assert isinstance(result, str)
if result:
assert separator in result
@pytest.mark.parametrize("reset_start", [True, False])
def test_call_stack_reset_start_variations(self, reset_start: bool) -> None:
"""Test call_stack with reset_start_if_empty variations"""
result = call_stack(start=100, reset_start_if_empty=reset_start)
assert isinstance(result, str)
if reset_start:
assert len(result) > 0 # Should have content after reset
else:
assert len(result) == 0 # Should be empty
@pytest.mark.parametrize("separator", [" -> ", " | ", " / ", " >> ", "\n"])
def test_exception_stack_various_separators(self, separator: str) -> None:
"""Test exception_stack with various separators"""
def nested_call():
def inner_call():
raise ValueError("Test")
inner_call()
try:
nested_call()
except ValueError:
result = exception_stack(separator=separator)
assert isinstance(result, str)
# Check that result is valid (separator only if multiple frames exist)
@pytest.mark.parametrize("exception_type", [
ValueError,
TypeError,
KeyError,
IndexError,
AttributeError,
RuntimeError,
OSError,
])
def test_exception_stack_various_exception_types(self, exception_type: type[Exception]) -> None:
"""Test exception_stack with various exception types"""
try:
raise exception_type("Test exception")
except (ValueError, TypeError, KeyError, IndexError, AttributeError, RuntimeError, OSError):
result = exception_stack()
assert isinstance(result, str)
# __END__

View File

@@ -0,0 +1,288 @@
"""
Unit tests for debug_handling.dump_data module
"""
import json
from datetime import datetime, date
from decimal import Decimal
from typing import Any
import pytest
from corelibs.debug_handling.dump_data import dump_data
class TestDumpData:
"""Test cases for dump_data function"""
def test_dump_simple_dict(self):
"""Test dumping a simple dictionary"""
data = {"name": "John", "age": 30}
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_simple_list(self):
"""Test dumping a simple list"""
data = [1, 2, 3, 4, 5]
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_nested_dict(self):
"""Test dumping a nested dictionary"""
data = {
"user": {
"name": "Alice",
"address": {
"city": "Tokyo",
"country": "Japan"
}
}
}
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_mixed_types(self):
"""Test dumping data with mixed types"""
data = {
"string": "test",
"number": 42,
"float": 3.14,
"boolean": True,
"null": None,
"list": [1, 2, 3]
}
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_with_indent_default(self):
"""Test that indent is applied by default"""
data = {"a": 1, "b": 2}
result = dump_data(data)
# With indent, result should contain newlines
assert "\n" in result
assert " " in result # 4 spaces for indent
def test_dump_with_indent_true(self):
"""Test explicit indent=True"""
data = {"a": 1, "b": 2}
result = dump_data(data, use_indent=True)
# With indent, result should contain newlines
assert "\n" in result
assert " " in result # 4 spaces for indent
def test_dump_without_indent(self):
"""Test dumping without indentation"""
data = {"a": 1, "b": 2}
result = dump_data(data, use_indent=False)
# Without indent, result should be compact
assert "\n" not in result
assert result == '{"a": 1, "b": 2}'
def test_dump_unicode_characters(self):
"""Test that unicode characters are preserved (ensure_ascii=False)"""
data = {"message": "こんにちは", "emoji": "😀", "german": "Müller"}
result = dump_data(data)
# Unicode characters should be preserved, not escaped
assert "こんにちは" in result
assert "😀" in result
assert "Müller" in result
parsed = json.loads(result)
assert parsed == data
def test_dump_datetime_object(self):
"""Test dumping data with datetime objects (using default=str)"""
now = datetime(2023, 10, 15, 14, 30, 0)
data = {"timestamp": now}
result = dump_data(data)
assert isinstance(result, str)
# datetime should be converted to string
assert "2023-10-15" in result
def test_dump_date_object(self):
"""Test dumping data with date objects"""
today = date(2023, 10, 15)
data = {"date": today}
result = dump_data(data)
assert isinstance(result, str)
assert "2023-10-15" in result
def test_dump_decimal_object(self):
"""Test dumping data with Decimal objects"""
data = {"amount": Decimal("123.45")}
result = dump_data(data)
assert isinstance(result, str)
assert "123.45" in result
def test_dump_empty_dict(self):
"""Test dumping an empty dictionary"""
data = {}
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == {}
def test_dump_empty_list(self):
"""Test dumping an empty list"""
data = []
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == []
def test_dump_string_directly(self):
"""Test dumping a string directly"""
data = "Hello, World!"
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_number_directly(self):
"""Test dumping a number directly"""
data = 42
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_boolean_directly(self):
"""Test dumping a boolean directly"""
data = True
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed is True
def test_dump_none_directly(self):
"""Test dumping None directly"""
data = None
result = dump_data(data)
assert isinstance(result, str)
assert result == "null"
parsed = json.loads(result)
assert parsed is None
def test_dump_complex_nested_structure(self):
"""Test dumping a complex nested structure"""
data = {
"users": [
{
"id": 1,
"name": "Alice",
"tags": ["admin", "user"],
"metadata": {
"created": datetime(2023, 1, 1),
"active": True
}
},
{
"id": 2,
"name": "Bob",
"tags": ["user"],
"metadata": {
"created": datetime(2023, 6, 15),
"active": False
}
}
],
"total": 2
}
result = dump_data(data)
assert isinstance(result, str)
# Check that it's valid JSON
parsed = json.loads(result)
assert len(parsed["users"]) == 2
assert parsed["total"] == 2
def test_dump_special_characters(self):
"""Test dumping data with special characters"""
data = {
"quote": 'He said "Hello"',
"backslash": "path\\to\\file",
"newline": "line1\nline2",
"tab": "col1\tcol2"
}
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
def test_dump_large_numbers(self):
"""Test dumping large numbers"""
data = {
"big_int": 123456789012345678901234567890,
"big_float": 1.23456789e100
}
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed["big_int"] == data["big_int"]
def test_dump_list_of_dicts(self):
"""Test dumping a list of dictionaries"""
data = [
{"id": 1, "name": "Item 1"},
{"id": 2, "name": "Item 2"},
{"id": 3, "name": "Item 3"}
]
result = dump_data(data)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == data
assert len(parsed) == 3
class CustomObject:
"""Custom class for testing default=str conversion"""
def __init__(self, value: Any):
self.value = value
def __str__(self):
return f"CustomObject({self.value})"
class TestDumpDataWithCustomObjects:
"""Test cases for dump_data with custom objects"""
def test_dump_custom_object(self):
"""Test that custom objects are converted using str()"""
obj = CustomObject("test")
data = {"custom": obj}
result = dump_data(data)
assert isinstance(result, str)
assert "CustomObject(test)" in result
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,560 @@
"""
Unit tests for corelibs.debug_handling.profiling module
"""
import time
import tracemalloc
from corelibs.debug_handling.profiling import display_top, Profiling
class TestDisplayTop:
"""Test display_top function"""
def test_display_top_basic(self):
"""Test that display_top returns a string with basic stats"""
tracemalloc.start()
# Allocate some memory
data = [0] * 10000
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
result = display_top(snapshot)
assert isinstance(result, str)
assert "Top 10 lines" in result
assert "KiB" in result
assert "Total allocated size:" in result
# Clean up
del data
def test_display_top_with_custom_limit(self):
"""Test display_top with custom limit parameter"""
tracemalloc.start()
# Allocate some memory
data = [0] * 10000
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
result = display_top(snapshot, limit=5)
assert isinstance(result, str)
assert "Top 5 lines" in result
# Clean up
del data
def test_display_top_with_different_key_type(self):
"""Test display_top with different key_type parameter"""
tracemalloc.start()
# Allocate some memory
data = [0] * 10000
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
result = display_top(snapshot, key_type='filename')
assert isinstance(result, str)
assert "Top 10 lines" in result
# Clean up
del data
def test_display_top_filters_traces(self):
"""Test that display_top filters out bootstrap and unknown traces"""
tracemalloc.start()
# Allocate some memory
data = [0] * 10000
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
result = display_top(snapshot)
# Should not contain filtered traces
assert "<frozen importlib._bootstrap>" not in result
assert "<unknown>" not in result
# Clean up
del data
def test_display_top_with_limit_larger_than_stats(self):
"""Test display_top when limit is larger than available stats"""
tracemalloc.start()
# Allocate some memory
data = [0] * 100
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
result = display_top(snapshot, limit=1000)
assert isinstance(result, str)
assert "Top 1000 lines" in result
assert "Total allocated size:" in result
# Clean up
del data
def test_display_top_empty_snapshot(self):
"""Test display_top with a snapshot that has minimal traces"""
tracemalloc.start()
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
result = display_top(snapshot, limit=1)
assert isinstance(result, str)
assert "Top 1 lines" in result
class TestProfilingInitialization:
"""Test Profiling class initialization"""
def test_profiling_initialization(self):
"""Test that Profiling initializes correctly"""
profiler = Profiling()
# Should be able to create instance
assert isinstance(profiler, Profiling)
def test_profiling_initial_state(self):
"""Test that Profiling starts in a clean state"""
profiler = Profiling()
# Should not raise an error when calling end_profiling
# even though start_profiling wasn't called
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
class TestProfilingStartEnd:
"""Test start_profiling and end_profiling functionality"""
def test_start_profiling(self):
"""Test that start_profiling can be called"""
profiler = Profiling()
# Should not raise an error
profiler.start_profiling("test_operation")
def test_end_profiling(self):
"""Test that end_profiling can be called"""
profiler = Profiling()
profiler.start_profiling("test_operation")
# Should not raise an error
profiler.end_profiling()
def test_start_profiling_with_different_idents(self):
"""Test start_profiling with different identifier strings"""
profiler = Profiling()
identifiers = ["short", "longer_identifier", "very_long_identifier_with_many_chars"]
for ident in identifiers:
profiler.start_profiling(ident)
profiler.end_profiling()
result = profiler.print_profiling()
assert ident in result
def test_end_profiling_without_start(self):
"""Test that end_profiling can be called without start_profiling"""
profiler = Profiling()
# Should not raise an error but internal state should indicate warning
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
def test_profiling_measures_time(self):
"""Test that profiling measures elapsed time"""
profiler = Profiling()
profiler.start_profiling("time_test")
sleep_duration = 0.05 # 50ms
time.sleep(sleep_duration)
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "time:" in result
# Should have some time measurement
assert "ms" in result or "s" in result
def test_profiling_measures_memory(self):
"""Test that profiling measures memory usage"""
profiler = Profiling()
profiler.start_profiling("memory_test")
# Allocate some memory
data = [0] * 100000
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "RSS:" in result
assert "VMS:" in result
assert "time:" in result
# Clean up
del data
class TestProfilingPrintProfiling:
"""Test print_profiling functionality"""
def test_print_profiling_returns_string(self):
"""Test that print_profiling returns a string"""
profiler = Profiling()
profiler.start_profiling("test")
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
def test_print_profiling_contains_identifier(self):
"""Test that print_profiling includes the identifier"""
profiler = Profiling()
identifier = "my_test_operation"
profiler.start_profiling(identifier)
profiler.end_profiling()
result = profiler.print_profiling()
assert identifier in result
def test_print_profiling_format(self):
"""Test that print_profiling has expected format"""
profiler = Profiling()
profiler.start_profiling("test")
profiler.end_profiling()
result = profiler.print_profiling()
# Check for expected components
assert "Profiling:" in result
assert "RSS:" in result
assert "VMS:" in result
assert "time:" in result
def test_print_profiling_multiple_calls(self):
"""Test that print_profiling can be called multiple times"""
profiler = Profiling()
profiler.start_profiling("test")
profiler.end_profiling()
result1 = profiler.print_profiling()
result2 = profiler.print_profiling()
# Should return the same result
assert result1 == result2
def test_print_profiling_time_formats(self):
"""Test different time format outputs"""
profiler = Profiling()
# Very short duration (milliseconds)
profiler.start_profiling("ms_test")
time.sleep(0.001)
profiler.end_profiling()
result = profiler.print_profiling()
assert "ms" in result
# Slightly longer duration (seconds)
profiler.start_profiling("s_test")
time.sleep(0.1)
profiler.end_profiling()
result = profiler.print_profiling()
# Could be ms or s depending on timing
assert ("ms" in result or "s" in result)
def test_print_profiling_memory_formats(self):
"""Test different memory format outputs"""
profiler = Profiling()
profiler.start_profiling("memory_format_test")
# Allocate some memory
data = [0] * 50000
profiler.end_profiling()
result = profiler.print_profiling()
# Should have some memory unit (B, kB, MB, GB)
assert any(unit in result for unit in ["B", "kB", "MB", "GB"])
# Clean up
del data
class TestProfilingIntegration:
"""Integration tests for Profiling class"""
def test_complete_profiling_cycle(self):
"""Test a complete profiling cycle from start to print"""
profiler = Profiling()
profiler.start_profiling("complete_cycle")
# Do some work
data = [i for i in range(10000)]
time.sleep(0.01)
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "complete_cycle" in result
assert "RSS:" in result
assert "VMS:" in result
assert "time:" in result
# Clean up
del data
def test_multiple_profiling_sessions(self):
"""Test running multiple profiling sessions"""
profiler = Profiling()
# First session
profiler.start_profiling("session_1")
time.sleep(0.01)
profiler.end_profiling()
result1 = profiler.print_profiling()
# Second session (same profiler instance)
profiler.start_profiling("session_2")
data = [0] * 100000
time.sleep(0.01)
profiler.end_profiling()
result2 = profiler.print_profiling()
# Results should be different
assert "session_1" in result1
assert "session_2" in result2
assert result1 != result2
# Clean up
del data
def test_profiling_with_zero_work(self):
"""Test profiling with minimal work"""
profiler = Profiling()
profiler.start_profiling("zero_work")
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "zero_work" in result
def test_profiling_with_heavy_computation(self):
"""Test profiling with heavier computation"""
profiler = Profiling()
profiler.start_profiling("heavy_computation")
# Do some computation
result_data: list[list[int]] = []
for _ in range(1000):
result_data.append([j * 2 for j in range(100)])
time.sleep(0.05)
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "heavy_computation" in result
# Should show measurable time and memory
assert "time:" in result
# Clean up
del result_data
def test_independent_profilers(self):
"""Test that multiple Profiling instances are independent"""
profiler1 = Profiling()
profiler2 = Profiling()
profiler1.start_profiling("profiler_1")
time.sleep(0.01)
profiler2.start_profiling("profiler_2")
data = [0] * 100000
time.sleep(0.01)
profiler1.end_profiling()
profiler2.end_profiling()
result1 = profiler1.print_profiling()
result2 = profiler2.print_profiling()
# Should have different identifiers
assert "profiler_1" in result1
assert "profiler_2" in result2
# Results should be different
assert result1 != result2
# Clean up
del data
class TestProfilingEdgeCases:
"""Test edge cases and boundary conditions"""
def test_empty_identifier(self):
"""Test profiling with empty identifier"""
profiler = Profiling()
profiler.start_profiling("")
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "Profiling:" in result
def test_very_long_identifier(self):
"""Test profiling with very long identifier"""
profiler = Profiling()
long_ident = "a" * 100
profiler.start_profiling(long_ident)
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert long_ident in result
def test_special_characters_in_identifier(self):
"""Test profiling with special characters in identifier"""
profiler = Profiling()
special_ident = "test_@#$%_operation"
profiler.start_profiling(special_ident)
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert special_ident in result
def test_rapid_consecutive_profiling(self):
"""Test rapid consecutive profiling cycles"""
profiler = Profiling()
for i in range(5):
profiler.start_profiling(f"rapid_{i}")
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert f"rapid_{i}" in result
def test_profiling_negative_memory_change(self):
"""Test profiling when memory usage decreases"""
profiler = Profiling()
# Allocate some memory before profiling
pre_data = [0] * 1000000
profiler.start_profiling("memory_decrease")
# Free the memory
del pre_data
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "memory_decrease" in result
# Should handle negative memory change gracefully
def test_very_short_duration(self):
"""Test profiling with extremely short duration"""
profiler = Profiling()
profiler.start_profiling("instant")
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
assert "instant" in result
assert "ms" in result # Should show milliseconds for very short duration
class TestProfilingContextManager:
"""Test profiling usage patterns similar to context managers"""
def test_typical_usage_pattern(self):
"""Test typical usage pattern for profiling"""
profiler = Profiling()
# Typical pattern
profiler.start_profiling("typical_operation")
# Perform operation
result_list: list[int] = []
for _ in range(1000):
result_list.append(_ * 2)
profiler.end_profiling()
# Get results
output = profiler.print_profiling()
assert isinstance(output, str)
assert "typical_operation" in output
# Clean up
del result_list
def test_profiling_without_end(self):
"""Test what happens when end_profiling is not called"""
profiler = Profiling()
profiler.start_profiling("no_end")
# Don't call end_profiling
result = profiler.print_profiling()
# Should still return a string (though data might be incomplete)
assert isinstance(result, str)
def test_profiling_end_without_start(self):
"""Test calling end_profiling multiple times without start"""
profiler = Profiling()
profiler.end_profiling()
profiler.end_profiling()
result = profiler.print_profiling()
assert isinstance(result, str)
# __END__

View File

@@ -0,0 +1,405 @@
"""
Unit tests for corelibs.debug_handling.timer module
"""
import time
from datetime import datetime, timedelta
from corelibs.debug_handling.timer import Timer
class TestTimerInitialization:
"""Test Timer class initialization"""
def test_timer_initialization(self):
"""Test that Timer initializes with correct default values"""
timer = Timer()
# Check that start times are set
assert isinstance(timer.get_overall_start_time(), datetime)
assert isinstance(timer.get_start_time(), datetime)
# Check that end times are None
assert timer.get_overall_end_time() is None
assert timer.get_end_time() is None
# Check that run times are None
assert timer.get_overall_run_time() is None
assert timer.get_run_time() is None
def test_timer_start_times_are_recent(self):
"""Test that start times are set to current time on initialization"""
before_init = datetime.now()
timer = Timer()
after_init = datetime.now()
overall_start = timer.get_overall_start_time()
start = timer.get_start_time()
assert before_init <= overall_start <= after_init
assert before_init <= start <= after_init
def test_timer_start_times_are_same(self):
"""Test that overall_start_time and start_time are initialized to the same time"""
timer = Timer()
overall_start = timer.get_overall_start_time()
start = timer.get_start_time()
# They should be very close (within a few microseconds)
time_diff = abs((overall_start - start).total_seconds())
assert time_diff < 0.001 # Less than 1 millisecond
class TestOverallRunTime:
"""Test overall run time functionality"""
def test_overall_run_time_returns_timedelta(self):
"""Test that overall_run_time returns a timedelta object"""
timer = Timer()
time.sleep(0.01) # Sleep for 10ms
result = timer.overall_run_time()
assert isinstance(result, timedelta)
def test_overall_run_time_sets_end_time(self):
"""Test that calling overall_run_time sets the end time"""
timer = Timer()
assert timer.get_overall_end_time() is None
timer.overall_run_time()
assert isinstance(timer.get_overall_end_time(), datetime)
def test_overall_run_time_sets_run_time(self):
"""Test that calling overall_run_time sets the run time"""
timer = Timer()
assert timer.get_overall_run_time() is None
timer.overall_run_time()
assert isinstance(timer.get_overall_run_time(), timedelta)
def test_overall_run_time_accuracy(self):
"""Test that overall_run_time calculates time difference accurately"""
timer = Timer()
sleep_duration = 0.05 # 50ms
time.sleep(sleep_duration)
result = timer.overall_run_time()
# Allow for some variance (10ms tolerance)
assert sleep_duration - 0.01 <= result.total_seconds() <= sleep_duration + 0.01
def test_overall_run_time_multiple_calls(self):
"""Test that calling overall_run_time multiple times updates the values"""
timer = Timer()
time.sleep(0.01)
first_result = timer.overall_run_time()
first_end_time = timer.get_overall_end_time()
time.sleep(0.01)
second_result = timer.overall_run_time()
second_end_time = timer.get_overall_end_time()
# Second call should have longer runtime
assert second_result > first_result
assert second_end_time is not None
assert first_end_time is not None
# End time should be updated
assert second_end_time > first_end_time
def test_overall_run_time_consistency(self):
"""Test that get_overall_run_time returns the same value as overall_run_time"""
timer = Timer()
time.sleep(0.01)
calculated_time = timer.overall_run_time()
retrieved_time = timer.get_overall_run_time()
assert calculated_time == retrieved_time
class TestRunTime:
"""Test run time functionality"""
def test_run_time_returns_timedelta(self):
"""Test that run_time returns a timedelta object"""
timer = Timer()
time.sleep(0.01)
result = timer.run_time()
assert isinstance(result, timedelta)
def test_run_time_sets_end_time(self):
"""Test that calling run_time sets the end time"""
timer = Timer()
assert timer.get_end_time() is None
timer.run_time()
assert isinstance(timer.get_end_time(), datetime)
def test_run_time_sets_run_time(self):
"""Test that calling run_time sets the run time"""
timer = Timer()
assert timer.get_run_time() is None
timer.run_time()
assert isinstance(timer.get_run_time(), timedelta)
def test_run_time_accuracy(self):
"""Test that run_time calculates time difference accurately"""
timer = Timer()
sleep_duration = 0.05 # 50ms
time.sleep(sleep_duration)
result = timer.run_time()
# Allow for some variance (10ms tolerance)
assert sleep_duration - 0.01 <= result.total_seconds() <= sleep_duration + 0.01
def test_run_time_multiple_calls(self):
"""Test that calling run_time multiple times updates the values"""
timer = Timer()
time.sleep(0.01)
first_result = timer.run_time()
first_end_time = timer.get_end_time()
time.sleep(0.01)
second_result = timer.run_time()
second_end_time = timer.get_end_time()
# Second call should have longer runtime
assert second_result > first_result
assert second_end_time is not None
assert first_end_time is not None
# End time should be updated
assert second_end_time > first_end_time
def test_run_time_consistency(self):
"""Test that get_run_time returns the same value as run_time"""
timer = Timer()
time.sleep(0.01)
calculated_time = timer.run_time()
retrieved_time = timer.get_run_time()
assert calculated_time == retrieved_time
class TestResetRunTime:
"""Test reset_run_time functionality"""
def test_reset_run_time_resets_start_time(self):
"""Test that reset_run_time updates the start time"""
timer = Timer()
original_start = timer.get_start_time()
time.sleep(0.02)
timer.reset_run_time()
new_start = timer.get_start_time()
assert new_start > original_start
def test_reset_run_time_clears_end_time(self):
"""Test that reset_run_time clears the end time"""
timer = Timer()
timer.run_time()
assert timer.get_end_time() is not None
timer.reset_run_time()
assert timer.get_end_time() is None
def test_reset_run_time_clears_run_time(self):
"""Test that reset_run_time clears the run time"""
timer = Timer()
timer.run_time()
assert timer.get_run_time() is not None
timer.reset_run_time()
assert timer.get_run_time() is None
def test_reset_run_time_does_not_affect_overall_times(self):
"""Test that reset_run_time does not affect overall times"""
timer = Timer()
overall_start = timer.get_overall_start_time()
timer.overall_run_time()
overall_end = timer.get_overall_end_time()
overall_run = timer.get_overall_run_time()
timer.reset_run_time()
# Overall times should remain unchanged
assert timer.get_overall_start_time() == overall_start
assert timer.get_overall_end_time() == overall_end
assert timer.get_overall_run_time() == overall_run
def test_reset_run_time_allows_new_measurement(self):
"""Test that reset_run_time allows for new time measurements"""
timer = Timer()
time.sleep(0.02)
timer.run_time()
first_run_time = timer.get_run_time()
timer.reset_run_time()
time.sleep(0.01)
timer.run_time()
second_run_time = timer.get_run_time()
assert second_run_time is not None
assert first_run_time is not None
# Second measurement should be shorter since we reset
assert second_run_time < first_run_time
class TestTimerIntegration:
"""Integration tests for Timer class"""
def test_independent_timers(self):
"""Test that multiple Timer instances are independent"""
timer1 = Timer()
time.sleep(0.01)
timer2 = Timer()
# timer1 should have earlier start time
assert timer1.get_start_time() < timer2.get_start_time()
assert timer1.get_overall_start_time() < timer2.get_overall_start_time()
def test_overall_and_run_time_independence(self):
"""Test that overall time and run time are independent"""
timer = Timer()
time.sleep(0.02)
# Reset run time but not overall
timer.reset_run_time()
time.sleep(0.01)
run_time = timer.run_time()
overall_time = timer.overall_run_time()
# Overall time should be longer than run time
assert overall_time > run_time
def test_typical_usage_pattern(self):
"""Test a typical usage pattern of the Timer class"""
timer = Timer()
# Measure first operation
time.sleep(0.01)
first_operation = timer.run_time()
assert first_operation.total_seconds() > 0
# Reset and measure second operation
timer.reset_run_time()
time.sleep(0.01)
second_operation = timer.run_time()
assert second_operation.total_seconds() > 0
# Get overall time
overall = timer.overall_run_time()
# Overall should be greater than individual operations
assert overall > first_operation
assert overall > second_operation
def test_zero_sleep_timer(self):
"""Test timer with minimal sleep (edge case)"""
timer = Timer()
# Call run_time immediately
result = timer.run_time()
# Should still return a valid timedelta (very small)
assert isinstance(result, timedelta)
assert result.total_seconds() >= 0
def test_getter_methods_before_calculation(self):
"""Test that getter methods return None before calculation methods are called"""
timer = Timer()
# Before calling run_time()
assert timer.get_end_time() is None
assert timer.get_run_time() is None
# Before calling overall_run_time()
assert timer.get_overall_end_time() is None
assert timer.get_overall_run_time() is None
# But start times should always be set
assert timer.get_start_time() is not None
assert timer.get_overall_start_time() is not None
class TestTimerEdgeCases:
"""Test edge cases and boundary conditions"""
def test_rapid_consecutive_calls(self):
"""Test rapid consecutive calls to run_time"""
timer = Timer()
results: list[timedelta] = []
for _ in range(5):
results.append(timer.run_time())
# Each result should be greater than or equal to the previous
for i in range(1, len(results)):
assert results[i] >= results[i - 1]
def test_very_short_duration(self):
"""Test timer with very short duration"""
timer = Timer()
result = timer.run_time()
# Should be a very small positive timedelta
assert isinstance(result, timedelta)
assert result.total_seconds() >= 0
assert result.total_seconds() < 0.1 # Less than 100ms
def test_reset_multiple_times(self):
"""Test resetting the timer multiple times"""
timer = Timer()
for _ in range(3):
timer.reset_run_time()
time.sleep(0.01)
result = timer.run_time()
assert isinstance(result, timedelta)
assert result.total_seconds() > 0
def test_overall_time_persists_through_resets(self):
"""Test that overall time continues even when run_time is reset"""
timer = Timer()
time.sleep(0.01)
timer.reset_run_time()
time.sleep(0.01)
timer.reset_run_time()
overall = timer.overall_run_time()
# Overall time should reflect total elapsed time
assert overall.total_seconds() >= 0.02
# __END__

View File

@@ -0,0 +1,975 @@
"""
Unit tests for debug_handling.writeline module
"""
import io
import pytest
from pytest import CaptureFixture
from corelibs.debug_handling.writeline import (
write_l,
pr_header,
pr_title,
pr_open,
pr_close,
pr_act
)
class TestWriteL:
"""Test cases for write_l function"""
def test_write_l_print_only(self, capsys: CaptureFixture[str]):
"""Test write_l with print_line=True and no file"""
write_l("Test line", print_line=True)
captured = capsys.readouterr()
assert captured.out == "Test line\n"
def test_write_l_no_print_no_file(self, capsys: CaptureFixture[str]):
"""Test write_l with print_line=False and no file (should do nothing)"""
write_l("Test line", print_line=False)
captured = capsys.readouterr()
assert captured.out == ""
def test_write_l_file_only(self, capsys: CaptureFixture[str]):
"""Test write_l with file handler only (no print)"""
fpl = io.StringIO()
write_l("Test line", fpl=fpl, print_line=False)
captured = capsys.readouterr()
assert captured.out == ""
assert fpl.getvalue() == "Test line\n"
fpl.close()
def test_write_l_both_print_and_file(self, capsys: CaptureFixture[str]):
"""Test write_l with both print and file output"""
fpl = io.StringIO()
write_l("Test line", fpl=fpl, print_line=True)
captured = capsys.readouterr()
assert captured.out == "Test line\n"
assert fpl.getvalue() == "Test line\n"
fpl.close()
def test_write_l_multiple_lines_to_file(self):
"""Test write_l writing multiple lines to file"""
fpl = io.StringIO()
write_l("Line 1", fpl=fpl, print_line=False)
write_l("Line 2", fpl=fpl, print_line=False)
write_l("Line 3", fpl=fpl, print_line=False)
assert fpl.getvalue() == "Line 1\nLine 2\nLine 3\n"
fpl.close()
def test_write_l_empty_string(self, capsys: CaptureFixture[str]):
"""Test write_l with empty string"""
fpl = io.StringIO()
write_l("", fpl=fpl, print_line=True)
captured = capsys.readouterr()
assert captured.out == "\n"
assert fpl.getvalue() == "\n"
fpl.close()
def test_write_l_special_characters(self):
"""Test write_l with special characters"""
fpl = io.StringIO()
special_line = "Special: \t\n\r\\ 特殊文字 €"
write_l(special_line, fpl=fpl, print_line=False)
assert special_line + "\n" in fpl.getvalue()
fpl.close()
def test_write_l_long_string(self):
"""Test write_l with long string"""
fpl = io.StringIO()
long_line = "A" * 1000
write_l(long_line, fpl=fpl, print_line=False)
assert fpl.getvalue() == long_line + "\n"
fpl.close()
def test_write_l_unicode_content(self):
"""Test write_l with unicode content"""
fpl = io.StringIO()
unicode_line = "Hello 世界 🌍 Привет"
write_l(unicode_line, fpl=fpl, print_line=False)
assert fpl.getvalue() == unicode_line + "\n"
fpl.close()
def test_write_l_default_parameters(self, capsys: CaptureFixture[str]):
"""Test write_l with default parameters"""
write_l("Test")
captured = capsys.readouterr()
# Default print_line is False
assert captured.out == ""
def test_write_l_with_newline_in_string(self):
"""Test write_l with newline characters in the string"""
fpl = io.StringIO()
write_l("Line with\nnewline", fpl=fpl, print_line=False)
assert fpl.getvalue() == "Line with\nnewline\n"
fpl.close()
class TestPrHeader:
"""Test cases for pr_header function"""
def test_pr_header_default(self, capsys: CaptureFixture[str]):
"""Test pr_header with default parameters"""
pr_header("TEST")
captured = capsys.readouterr()
assert "#" in captured.out
assert "TEST" in captured.out
def test_pr_header_custom_marker(self, capsys: CaptureFixture[str]):
"""Test pr_header with custom marker string"""
pr_header("TEST", marker_string="*")
captured = capsys.readouterr()
assert "*" in captured.out
assert "TEST" in captured.out
assert "#" not in captured.out
def test_pr_header_custom_width(self, capsys: CaptureFixture[str]):
"""Test pr_header with custom width"""
pr_header("TEST", width=50)
captured = capsys.readouterr()
# Check that output is formatted
assert "TEST" in captured.out
def test_pr_header_short_tag(self, capsys: CaptureFixture[str]):
"""Test pr_header with short tag"""
pr_header("X")
captured = capsys.readouterr()
assert "X" in captured.out
assert "#" in captured.out
def test_pr_header_long_tag(self, capsys: CaptureFixture[str]):
"""Test pr_header with long tag"""
pr_header("This is a very long header tag")
captured = capsys.readouterr()
assert "This is a very long header tag" in captured.out
def test_pr_header_empty_tag(self, capsys: CaptureFixture[str]):
"""Test pr_header with empty tag"""
pr_header("")
captured = capsys.readouterr()
assert "#" in captured.out
def test_pr_header_special_characters(self, capsys: CaptureFixture[str]):
"""Test pr_header with special characters in tag"""
pr_header("TEST: 123! @#$")
captured = capsys.readouterr()
assert "TEST: 123! @#$" in captured.out
def test_pr_header_unicode(self, capsys: CaptureFixture[str]):
"""Test pr_header with unicode characters"""
pr_header("テスト 🎉")
captured = capsys.readouterr()
assert "テスト 🎉" in captured.out
def test_pr_header_various_markers(self, capsys: CaptureFixture[str]):
"""Test pr_header with various marker strings"""
markers = ["*", "=", "-", "+", "~", "@"]
for marker in markers:
pr_header("TEST", marker_string=marker)
captured = capsys.readouterr()
assert marker in captured.out
assert "TEST" in captured.out
def test_pr_header_zero_width(self, capsys: CaptureFixture[str]):
"""Test pr_header with width of 0"""
pr_header("TEST", width=0)
captured = capsys.readouterr()
assert "TEST" in captured.out
def test_pr_header_large_width(self, capsys: CaptureFixture[str]):
"""Test pr_header with large width"""
pr_header("TEST", width=100)
captured = capsys.readouterr()
assert "TEST" in captured.out
assert "#" in captured.out
def test_pr_header_format(self, capsys: CaptureFixture[str]):
"""Test pr_header output format"""
pr_header("CENTER", marker_string="#", width=20)
captured = capsys.readouterr()
# Should have spaces around centered text
assert " CENTER " in captured.out or "CENTER" in captured.out
class TestPrTitle:
"""Test cases for pr_title function"""
def test_pr_title_default(self, capsys: CaptureFixture[str]):
"""Test pr_title with default parameters"""
pr_title("Test Title")
captured = capsys.readouterr()
assert "Test Title" in captured.out
assert "|" in captured.out
assert "." in captured.out
assert ":" in captured.out
def test_pr_title_custom_prefix(self, capsys: CaptureFixture[str]):
"""Test pr_title with custom prefix string"""
pr_title("Test", prefix_string=">")
captured = capsys.readouterr()
assert ">" in captured.out
assert "Test" in captured.out
assert "|" not in captured.out
def test_pr_title_custom_space_filler(self, capsys: CaptureFixture[str]):
"""Test pr_title with custom space filler"""
pr_title("Test", space_filler="-")
captured = capsys.readouterr()
assert "Test" in captured.out
assert "-" in captured.out
assert "." not in captured.out
def test_pr_title_custom_width(self, capsys: CaptureFixture[str]):
"""Test pr_title with custom width"""
pr_title("Test", width=50)
captured = capsys.readouterr()
assert "Test" in captured.out
def test_pr_title_short_tag(self, capsys: CaptureFixture[str]):
"""Test pr_title with short tag"""
pr_title("X")
captured = capsys.readouterr()
assert "X" in captured.out
assert "." in captured.out
def test_pr_title_long_tag(self, capsys: CaptureFixture[str]):
"""Test pr_title with long tag"""
pr_title("This is a very long title tag")
captured = capsys.readouterr()
assert "This is a very long title tag" in captured.out
def test_pr_title_empty_tag(self, capsys: CaptureFixture[str]):
"""Test pr_title with empty tag"""
pr_title("")
captured = capsys.readouterr()
assert "|" in captured.out
assert ":" in captured.out
def test_pr_title_special_characters(self, capsys: CaptureFixture[str]):
"""Test pr_title with special characters"""
pr_title("Task #123!")
captured = capsys.readouterr()
assert "Task #123!" in captured.out
def test_pr_title_unicode(self, capsys: CaptureFixture[str]):
"""Test pr_title with unicode characters"""
pr_title("タイトル 📝")
captured = capsys.readouterr()
assert "タイトル 📝" in captured.out
def test_pr_title_various_fillers(self, capsys: CaptureFixture[str]):
"""Test pr_title with various space fillers"""
fillers = [".", "-", "_", "*", " ", "~"]
for filler in fillers:
pr_title("Test", space_filler=filler)
captured = capsys.readouterr()
assert "Test" in captured.out
def test_pr_title_zero_width(self, capsys: CaptureFixture[str]):
"""Test pr_title with width of 0"""
pr_title("Test", width=0)
captured = capsys.readouterr()
assert "Test" in captured.out
def test_pr_title_large_width(self, capsys: CaptureFixture[str]):
"""Test pr_title with large width"""
pr_title("Test", width=100)
captured = capsys.readouterr()
assert "Test" in captured.out
def test_pr_title_format_left_align(self, capsys: CaptureFixture[str]):
"""Test pr_title output format (should be left-aligned with filler)"""
pr_title("Start", space_filler=".", width=10)
captured = capsys.readouterr()
# Should have the tag followed by dots
assert "Start" in captured.out
assert ":" in captured.out
class TestPrOpen:
"""Test cases for pr_open function"""
def test_pr_open_default(self, capsys: CaptureFixture[str]):
"""Test pr_open with default parameters"""
pr_open("Processing")
captured = capsys.readouterr()
assert "Processing" in captured.out
assert "|" in captured.out
assert "." in captured.out
assert "[" in captured.out
# Should not have newline at the end
assert not captured.out.endswith("\n")
def test_pr_open_custom_prefix(self, capsys: CaptureFixture[str]):
"""Test pr_open with custom prefix string"""
pr_open("Task", prefix_string=">")
captured = capsys.readouterr()
assert ">" in captured.out
assert "Task" in captured.out
assert "|" not in captured.out
def test_pr_open_custom_space_filler(self, capsys: CaptureFixture[str]):
"""Test pr_open with custom space filler"""
pr_open("Task", space_filler="-")
captured = capsys.readouterr()
assert "Task" in captured.out
assert "-" in captured.out
assert "." not in captured.out
def test_pr_open_custom_width(self, capsys: CaptureFixture[str]):
"""Test pr_open with custom width"""
pr_open("Task", width=50)
captured = capsys.readouterr()
assert "Task" in captured.out
assert "[" in captured.out
def test_pr_open_short_tag(self, capsys: CaptureFixture[str]):
"""Test pr_open with short tag"""
pr_open("X")
captured = capsys.readouterr()
assert "X" in captured.out
assert "[" in captured.out
def test_pr_open_long_tag(self, capsys: CaptureFixture[str]):
"""Test pr_open with long tag"""
pr_open("This is a very long task tag")
captured = capsys.readouterr()
assert "This is a very long task tag" in captured.out
def test_pr_open_empty_tag(self, capsys: CaptureFixture[str]):
"""Test pr_open with empty tag"""
pr_open("")
captured = capsys.readouterr()
assert "[" in captured.out
assert "|" in captured.out
def test_pr_open_no_newline(self, capsys: CaptureFixture[str]):
"""Test pr_open doesn't end with newline"""
pr_open("Test")
captured = capsys.readouterr()
# Output should not end with newline (uses end="")
assert not captured.out.endswith("\n")
def test_pr_open_special_characters(self, capsys: CaptureFixture[str]):
"""Test pr_open with special characters"""
pr_open("Loading: 50%")
captured = capsys.readouterr()
assert "Loading: 50%" in captured.out
def test_pr_open_unicode(self, capsys: CaptureFixture[str]):
"""Test pr_open with unicode characters"""
pr_open("処理中 ⏳")
captured = capsys.readouterr()
assert "処理中 ⏳" in captured.out
def test_pr_open_format(self, capsys: CaptureFixture[str]):
"""Test pr_open output format"""
pr_open("Task", prefix_string="|", space_filler=".", width=20)
captured = capsys.readouterr()
assert "|" in captured.out
assert "Task" in captured.out
assert "[" in captured.out
class TestPrClose:
"""Test cases for pr_close function"""
def test_pr_close_default(self, capsys: CaptureFixture[str]):
"""Test pr_close with default (empty) tag"""
pr_close()
captured = capsys.readouterr()
assert captured.out == "]\n"
def test_pr_close_with_tag(self, capsys: CaptureFixture[str]):
"""Test pr_close with custom tag"""
pr_close("DONE")
captured = capsys.readouterr()
assert "DONE" in captured.out
assert "]" in captured.out
assert captured.out.endswith("\n")
def test_pr_close_with_space(self, capsys: CaptureFixture[str]):
"""Test pr_close with space in tag"""
pr_close(" OK ")
captured = capsys.readouterr()
assert " OK " in captured.out
assert "]" in captured.out
def test_pr_close_empty_string(self, capsys: CaptureFixture[str]):
"""Test pr_close with empty string (same as default)"""
pr_close("")
captured = capsys.readouterr()
assert captured.out == "]\n"
def test_pr_close_special_characters(self, capsys: CaptureFixture[str]):
"""Test pr_close with special characters"""
pr_close("")
captured = capsys.readouterr()
assert "" in captured.out
assert "]" in captured.out
def test_pr_close_unicode(self, capsys: CaptureFixture[str]):
"""Test pr_close with unicode characters"""
pr_close("完了")
captured = capsys.readouterr()
assert "完了" in captured.out
assert "]" in captured.out
def test_pr_close_newline(self, capsys: CaptureFixture[str]):
"""Test pr_close ends with newline"""
pr_close("OK")
captured = capsys.readouterr()
assert captured.out.endswith("\n")
def test_pr_close_various_tags(self, capsys: CaptureFixture[str]):
"""Test pr_close with various tags"""
tags = ["OK", "DONE", "", "", "SKIP", "PASS", "FAIL"]
for tag in tags:
pr_close(tag)
captured = capsys.readouterr()
assert tag in captured.out
assert "]" in captured.out
class TestPrAct:
"""Test cases for pr_act function"""
def test_pr_act_default(self, capsys: CaptureFixture[str]):
"""Test pr_act with default dot"""
pr_act()
captured = capsys.readouterr()
assert captured.out == "."
assert not captured.out.endswith("\n")
def test_pr_act_custom_character(self, capsys: CaptureFixture[str]):
"""Test pr_act with custom character"""
pr_act("#")
captured = capsys.readouterr()
assert captured.out == "#"
def test_pr_act_multiple_calls(self, capsys: CaptureFixture[str]):
"""Test pr_act with multiple calls"""
pr_act(".")
pr_act(".")
pr_act(".")
captured = capsys.readouterr()
assert captured.out == "..."
def test_pr_act_various_characters(self, capsys: CaptureFixture[str]):
"""Test pr_act with various characters"""
characters = [".", "#", "*", "+", "-", "=", ">", "~"]
for char in characters:
pr_act(char)
captured = capsys.readouterr()
assert "".join(characters) in captured.out
def test_pr_act_empty_string(self, capsys: CaptureFixture[str]):
"""Test pr_act with empty string"""
pr_act("")
captured = capsys.readouterr()
assert captured.out == ""
def test_pr_act_special_character(self, capsys: CaptureFixture[str]):
"""Test pr_act with special characters"""
pr_act("")
captured = capsys.readouterr()
assert captured.out == ""
def test_pr_act_unicode(self, capsys: CaptureFixture[str]):
"""Test pr_act with unicode character"""
pr_act("")
captured = capsys.readouterr()
assert captured.out == ""
def test_pr_act_no_newline(self, capsys: CaptureFixture[str]):
"""Test pr_act doesn't add newline"""
pr_act("x")
captured = capsys.readouterr()
assert not captured.out.endswith("\n")
def test_pr_act_multiple_characters(self, capsys: CaptureFixture[str]):
"""Test pr_act with multiple characters in string"""
pr_act("...")
captured = capsys.readouterr()
assert captured.out == "..."
def test_pr_act_whitespace(self, capsys: CaptureFixture[str]):
"""Test pr_act with whitespace"""
pr_act(" ")
captured = capsys.readouterr()
assert captured.out == " "
class TestProgressCombinations:
"""Test combinations of progress printer functions"""
def test_complete_progress_flow(self, capsys: CaptureFixture[str]):
"""Test complete progress output flow"""
pr_header("PROCESS")
pr_title("Task 1")
pr_open("Subtask")
pr_act(".")
pr_act(".")
pr_act(".")
pr_close(" OK")
captured = capsys.readouterr()
assert "PROCESS" in captured.out
assert "Task 1" in captured.out
assert "Subtask" in captured.out
assert "..." in captured.out
assert " OK]" in captured.out
def test_multiple_tasks_progress(self, capsys: CaptureFixture[str]):
"""Test multiple tasks with progress"""
pr_header("BATCH PROCESS")
for i in range(3):
pr_open(f"Task {i + 1}")
for _ in range(5):
pr_act(".")
pr_close(" DONE")
captured = capsys.readouterr()
assert "BATCH PROCESS" in captured.out
assert "Task 1" in captured.out
assert "Task 2" in captured.out
assert "Task 3" in captured.out
assert " DONE]" in captured.out
def test_nested_progress(self, capsys: CaptureFixture[str]):
"""Test nested progress indicators"""
pr_header("MAIN TASK", marker_string="=")
pr_title("Subtask A", prefix_string=">")
pr_open("Processing")
pr_act("#")
pr_act("#")
pr_close()
pr_title("Subtask B", prefix_string=">")
pr_open("Processing")
pr_act("*")
pr_act("*")
pr_close(" OK")
captured = capsys.readouterr()
assert "MAIN TASK" in captured.out
assert "Subtask A" in captured.out
assert "Subtask B" in captured.out
assert "##" in captured.out
assert "**" in captured.out
def test_progress_with_different_markers(self, capsys: CaptureFixture[str]):
"""Test progress with different marker styles"""
pr_header("Process", marker_string="*")
pr_title("Step 1", prefix_string=">>", space_filler="-")
pr_open("Work", prefix_string=">>", space_filler="-")
pr_act("+")
pr_close("")
captured = capsys.readouterr()
assert "*" in captured.out
assert ">>" in captured.out
assert "-" in captured.out
assert "+" in captured.out
assert "" in captured.out
def test_empty_progress_sequence(self, capsys: CaptureFixture[str]):
"""Test progress sequence with no actual progress"""
pr_open("Quick task")
pr_close(" SKIP")
captured = capsys.readouterr()
assert "Quick task" in captured.out
assert " SKIP]" in captured.out
class TestIntegration:
"""Integration tests combining multiple scenarios"""
def test_file_and_console_logging(self, capsys: CaptureFixture[str]):
"""Test logging to both file and console"""
fpl = io.StringIO()
write_l("Starting process", fpl=fpl, print_line=True)
write_l("Processing item 1", fpl=fpl, print_line=True)
write_l("Processing item 2", fpl=fpl, print_line=True)
write_l("Complete", fpl=fpl, print_line=True)
captured = capsys.readouterr()
file_content = fpl.getvalue()
# Check console output
assert "Starting process\n" in captured.out
assert "Processing item 1\n" in captured.out
assert "Processing item 2\n" in captured.out
assert "Complete\n" in captured.out
# Check file output
assert "Starting process\n" in file_content
assert "Processing item 1\n" in file_content
assert "Processing item 2\n" in file_content
assert "Complete\n" in file_content
fpl.close()
def test_progress_with_logging(self, capsys: CaptureFixture[str]):
"""Test combining progress output with file logging"""
fpl = io.StringIO()
write_l("=== Process Start ===", fpl=fpl, print_line=True)
pr_header("MAIN PROCESS")
write_l("Header shown", fpl=fpl, print_line=False)
pr_open("Task 1")
pr_act(".")
pr_act(".")
pr_close(" OK")
write_l("Task 1 completed", fpl=fpl, print_line=False)
write_l("=== Process End ===", fpl=fpl, print_line=True)
captured = capsys.readouterr()
file_content = fpl.getvalue()
assert "=== Process Start ===" in captured.out
assert "MAIN PROCESS" in captured.out
assert "Task 1" in captured.out
assert "=== Process End ===" in captured.out
assert "=== Process Start ===\n" in file_content
assert "Header shown\n" in file_content
assert "Task 1 completed\n" in file_content
assert "=== Process End ===\n" in file_content
fpl.close()
def test_complex_workflow(self, capsys: CaptureFixture[str]):
"""Test complex workflow with all functions"""
fpl = io.StringIO()
write_l("Log: Starting batch process", fpl=fpl, print_line=False)
pr_header("BATCH PROCESSOR", marker_string="=", width=40)
for i in range(2):
write_l(f"Log: Processing batch {i + 1}", fpl=fpl, print_line=False)
pr_title(f"Batch {i + 1}", prefix_string="|", space_filler=".")
pr_open(f"Item {i + 1}", prefix_string="|", space_filler=".")
for j in range(3):
pr_act("*")
write_l(f"Log: Progress {j + 1}/3", fpl=fpl, print_line=False)
pr_close("")
write_l(f"Log: Batch {i + 1} complete", fpl=fpl, print_line=False)
write_l("Log: All batches complete", fpl=fpl, print_line=False)
captured = capsys.readouterr()
file_content = fpl.getvalue()
# Check console has progress indicators
assert "BATCH PROCESSOR" in captured.out
assert "Batch 1" in captured.out
assert "Batch 2" in captured.out
assert "***" in captured.out
assert "" in captured.out
# Check file has all log entries
assert "Log: Starting batch process\n" in file_content
assert "Log: Processing batch 1\n" in file_content
assert "Log: Processing batch 2\n" in file_content
assert "Log: Progress 1/3\n" in file_content
assert "Log: Batch 1 complete\n" in file_content
assert "Log: All batches complete\n" in file_content
fpl.close()
class TestEdgeCases:
"""Test edge cases and boundary conditions"""
def test_write_l_none_file_handler(self, capsys: CaptureFixture[str]):
"""Test write_l explicitly with None file handler"""
write_l("Test", fpl=None, print_line=True)
captured = capsys.readouterr()
assert captured.out == "Test\n"
def test_pr_header_negative_width(self):
"""Test pr_header with negative width raises ValueError"""
with pytest.raises(ValueError):
pr_header("Test", width=-10)
def test_pr_title_negative_width(self):
"""Test pr_title with negative width raises ValueError"""
with pytest.raises(ValueError):
pr_title("Test", width=-10)
def test_pr_open_negative_width(self):
"""Test pr_open with negative width raises ValueError"""
with pytest.raises(ValueError):
pr_open("Test", width=-10)
def test_multiple_pr_act_no_close(self, capsys: CaptureFixture[str]):
"""Test multiple pr_act calls without pr_close"""
pr_act(".")
pr_act(".")
pr_act(".")
captured = capsys.readouterr()
assert captured.out == "..."
def test_pr_close_without_pr_open(self, capsys: CaptureFixture[str]):
"""Test pr_close without prior pr_open (should still work)"""
pr_close(" OK")
captured = capsys.readouterr()
assert " OK]" in captured.out
def test_very_long_strings(self):
"""Test with very long strings"""
fpl = io.StringIO()
long_str = "A" * 10000
write_l(long_str, fpl=fpl, print_line=False)
assert len(fpl.getvalue()) == 10001 # string + newline
fpl.close()
def test_pr_header_very_long_tag(self, capsys: CaptureFixture[str]):
"""Test pr_header with tag longer than width"""
pr_header("This is a very long tag that exceeds the width", width=10)
captured = capsys.readouterr()
assert "This is a very long tag that exceeds the width" in captured.out
def test_pr_title_very_long_tag(self, capsys: CaptureFixture[str]):
"""Test pr_title with tag longer than width"""
pr_title("This is a very long tag that exceeds the width", width=10)
captured = capsys.readouterr()
assert "This is a very long tag that exceeds the width" in captured.out
def test_write_l_closed_file(self):
"""Test write_l with closed file should raise error"""
fpl = io.StringIO()
fpl.close()
with pytest.raises(ValueError):
write_l("Test", fpl=fpl, print_line=False)
class TestParametrized:
"""Parametrized tests for comprehensive coverage"""
@pytest.mark.parametrize("print_line", [True, False])
def test_write_l_print_line_variations(self, print_line: bool, capsys: CaptureFixture[str]):
"""Test write_l with different print_line values"""
write_l("Test", print_line=print_line)
captured = capsys.readouterr()
if print_line:
assert captured.out == "Test\n"
else:
assert captured.out == ""
@pytest.mark.parametrize("marker", ["#", "*", "=", "-", "+", "~", "@", "^"])
def test_pr_header_various_markers_param(self, marker: str, capsys: CaptureFixture[str]):
"""Test pr_header with various markers"""
pr_header("TEST", marker_string=marker)
captured = capsys.readouterr()
assert marker in captured.out
assert "TEST" in captured.out
@pytest.mark.parametrize("width", [0, 5, 10, 20, 35, 50, 100])
def test_pr_header_various_widths(self, width: int, capsys: CaptureFixture[str]):
"""Test pr_header with various widths"""
pr_header("TEST", width=width)
captured = capsys.readouterr()
assert "TEST" in captured.out
@pytest.mark.parametrize("filler", [".", "-", "_", "*", " ", "~", "="])
def test_pr_title_various_fillers_param(self, filler: str, capsys: CaptureFixture[str]):
"""Test pr_title with various space fillers"""
pr_title("Test", space_filler=filler)
captured = capsys.readouterr()
assert "Test" in captured.out
@pytest.mark.parametrize("prefix", ["|", ">", ">>", "*", "-", "+"])
def test_pr_title_various_prefixes(self, prefix: str, capsys: CaptureFixture[str]):
"""Test pr_title with various prefix strings"""
pr_title("Test", prefix_string=prefix)
captured = capsys.readouterr()
assert prefix in captured.out
assert "Test" in captured.out
@pytest.mark.parametrize("act_char", [".", "#", "*", "+", "-", "=", ">", "~", "", ""])
def test_pr_act_various_characters_param(self, act_char: str, capsys: CaptureFixture[str]):
"""Test pr_act with various characters"""
pr_act(act_char)
captured = capsys.readouterr()
assert captured.out == act_char
@pytest.mark.parametrize("close_tag", ["", " OK", " DONE", "", "", " SKIP", " PASS"])
def test_pr_close_various_tags_param(self, close_tag: str, capsys: CaptureFixture[str]):
"""Test pr_close with various tags"""
pr_close(close_tag)
captured = capsys.readouterr()
assert f"{close_tag}]" in captured.out
@pytest.mark.parametrize("content", [
"Simple text",
"Text with 特殊文字",
"Text with emoji 🎉",
"Text\twith\ttabs",
"Multiple\n\nNewlines",
"",
"A" * 100,
])
def test_write_l_various_content(self, content: str, capsys: CaptureFixture[str]):
"""Test write_l with various content types"""
fpl = io.StringIO()
write_l(content, fpl=fpl, print_line=True)
captured = capsys.readouterr()
assert content in captured.out
assert content + "\n" in fpl.getvalue()
fpl.close()
class TestRealWorldScenarios:
"""Test real-world usage scenarios"""
def test_batch_processing_output(self, capsys: CaptureFixture[str]):
"""Test typical batch processing output"""
pr_header("BATCH PROCESSOR", marker_string="=", width=50)
items = ["file1.txt", "file2.txt", "file3.txt"]
for item in items:
pr_open(f"Processing {item}")
for _ in range(10):
pr_act(".")
pr_close("")
captured = capsys.readouterr()
assert "BATCH PROCESSOR" in captured.out
for item in items:
assert item in captured.out
assert "" in captured.out
def test_logging_workflow(self, capsys: CaptureFixture[str]):
"""Test typical logging workflow"""
log_file = io.StringIO()
# Simulate a workflow with logging
write_l("[INFO] Starting process", fpl=log_file, print_line=True)
write_l("[INFO] Initializing components", fpl=log_file, print_line=True)
write_l("[DEBUG] Component A loaded", fpl=log_file, print_line=False)
write_l("[DEBUG] Component B loaded", fpl=log_file, print_line=False)
write_l("[INFO] Processing data", fpl=log_file, print_line=True)
write_l("[INFO] Process complete", fpl=log_file, print_line=True)
captured = capsys.readouterr()
log_content = log_file.getvalue()
# Console should only have INFO messages
assert "[INFO] Starting process" in captured.out
assert "[DEBUG] Component A loaded" not in captured.out
# Log file should have all messages
assert "[INFO] Starting process\n" in log_content
assert "[DEBUG] Component A loaded\n" in log_content
assert "[DEBUG] Component B loaded\n" in log_content
log_file.close()
def test_progress_indicator_for_long_task(self, capsys: CaptureFixture[str]):
"""Test progress indicator for a long-running task"""
pr_header("DATA PROCESSING")
pr_open("Loading data", width=50)
# Simulate progress
for i in range(20):
if i % 5 == 0:
pr_act(str(i // 5))
else:
pr_act(".")
pr_close(" COMPLETE")
captured = capsys.readouterr()
assert "DATA PROCESSING" in captured.out
assert "Loading data" in captured.out
assert "COMPLETE" in captured.out
def test_multi_stage_process(self, capsys: CaptureFixture[str]):
"""Test multi-stage process with titles and progress"""
pr_header("DEPLOYMENT PIPELINE", marker_string="=")
stages = ["Build", "Test", "Deploy"]
for stage in stages:
pr_title(stage)
pr_open(f"Running {stage.lower()}")
pr_act("#")
pr_act("#")
pr_act("#")
pr_close(" OK")
captured = capsys.readouterr()
assert "DEPLOYMENT PIPELINE" in captured.out
for stage in stages:
assert stage in captured.out
assert "###" in captured.out
def test_error_reporting_with_logging(self, capsys: CaptureFixture[str]):
"""Test error reporting workflow"""
error_log = io.StringIO()
pr_header("VALIDATION", marker_string="!")
pr_open("Checking files")
write_l("[ERROR] File not found: data.csv", fpl=error_log, print_line=False)
pr_act("")
write_l("[ERROR] Permission denied: output.txt", fpl=error_log, print_line=False)
pr_act("")
pr_close(" FAILED")
captured = capsys.readouterr()
log_content = error_log.getvalue()
assert "VALIDATION" in captured.out
assert "Checking files" in captured.out
assert "✗✗" in captured.out
assert "FAILED" in captured.out
assert "[ERROR] File not found: data.csv\n" in log_content
assert "[ERROR] Permission denied: output.txt\n" in log_content
error_log.close()
def test_detailed_reporting(self, capsys: CaptureFixture[str]):
"""Test detailed reporting with mixed output"""
report_file = io.StringIO()
pr_header("SYSTEM REPORT", marker_string="#", width=60)
write_l("=== System Report Generated ===", fpl=report_file, print_line=False)
pr_title("Database Status", prefix_string=">>")
write_l("Database: Connected", fpl=report_file, print_line=False)
write_l("Tables: 15", fpl=report_file, print_line=False)
write_l("Records: 1,234,567", fpl=report_file, print_line=False)
pr_title("API Status", prefix_string=">>")
write_l("API: Online", fpl=report_file, print_line=False)
write_l("Requests/min: 1,500", fpl=report_file, print_line=False)
write_l("=== Report Complete ===", fpl=report_file, print_line=False)
captured = capsys.readouterr()
report_content = report_file.getvalue()
assert "SYSTEM REPORT" in captured.out
assert "Database Status" in captured.out
assert "API Status" in captured.out
assert "=== System Report Generated ===\n" in report_content
assert "Database: Connected\n" in report_content
assert "API: Online\n" in report_content
assert "=== Report Complete ===\n" in report_content
report_file.close()
# __END__

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
"""
Unit tests for encryption_handling module
"""

View File

@@ -0,0 +1,665 @@
"""
PyTest: encryption_handling/symmetric_encryption
"""
# pylint: disable=redefined-outer-name
# ^ Disabled because pytest fixtures intentionally redefine names
import os
import json
import base64
import hashlib
import pytest
from corelibs.encryption_handling.symmetric_encryption import (
SymmetricEncryption
)
class TestSymmetricEncryptionInitialization:
"""Tests for SymmetricEncryption initialization"""
def test_valid_password_initialization(self):
"""Test initialization with a valid password"""
encryptor = SymmetricEncryption("test_password")
assert encryptor.password == "test_password"
assert encryptor.password_hash == hashlib.sha256("test_password".encode('utf-8')).hexdigest()
def test_empty_password_raises_error(self):
"""Test that empty password raises ValueError"""
with pytest.raises(ValueError, match="A password must be set"):
SymmetricEncryption("")
def test_password_hash_is_consistent(self):
"""Test that password hash is consistently generated"""
encryptor1 = SymmetricEncryption("test_password")
encryptor2 = SymmetricEncryption("test_password")
assert encryptor1.password_hash == encryptor2.password_hash
def test_different_passwords_different_hashes(self):
"""Test that different passwords produce different hashes"""
encryptor1 = SymmetricEncryption("password1")
encryptor2 = SymmetricEncryption("password2")
assert encryptor1.password_hash != encryptor2.password_hash
class TestEncryptWithMetadataReturnDict:
"""Tests for encrypt_with_metadata_return_dict method"""
def test_encrypt_string_returns_package_data(self):
"""Test encrypting a string returns PackageData dict"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_dict("test data")
assert isinstance(result, dict)
assert 'encrypted_data' in result
assert 'salt' in result
assert 'key_hash' in result
def test_encrypt_bytes_returns_package_data(self):
"""Test encrypting bytes returns PackageData dict"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_dict(b"test data")
assert isinstance(result, dict)
assert 'encrypted_data' in result
assert 'salt' in result
assert 'key_hash' in result
def test_encrypted_data_is_base64_encoded(self):
"""Test that encrypted_data is base64 encoded"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_dict("test data")
# Should not raise exception when decoding
base64.urlsafe_b64decode(result['encrypted_data'])
def test_salt_is_base64_encoded(self):
"""Test that salt is base64 encoded"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_dict("test data")
# Should not raise exception when decoding
salt = base64.urlsafe_b64decode(result['salt'])
# Salt should be 16 bytes
assert len(salt) == 16
def test_key_hash_is_valid_hex(self):
"""Test that key_hash is a valid hex string"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_dict("test data")
# Should be 64 characters (SHA256 hex)
assert len(result['key_hash']) == 64
# Should only contain hex characters
int(result['key_hash'], 16)
def test_different_salts_for_each_encryption(self):
"""Test that each encryption uses a different salt"""
encryptor = SymmetricEncryption("test_password")
result1 = encryptor.encrypt_with_metadata_return_dict("test data")
result2 = encryptor.encrypt_with_metadata_return_dict("test data")
assert result1['salt'] != result2['salt']
assert result1['encrypted_data'] != result2['encrypted_data']
class TestEncryptWithMetadataReturnStr:
"""Tests for encrypt_with_metadata_return_str method"""
def test_returns_json_string(self):
"""Test that method returns a valid JSON string"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_str("test data")
assert isinstance(result, str)
# Should be valid JSON
parsed = json.loads(result)
assert 'encrypted_data' in parsed
assert 'salt' in parsed
assert 'key_hash' in parsed
def test_json_string_parseable(self):
"""Test that returned JSON string can be parsed back"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_str("test data")
parsed = json.loads(result)
assert isinstance(parsed, dict)
class TestEncryptWithMetadataReturnBytes:
"""Tests for encrypt_with_metadata_return_bytes method"""
def test_returns_bytes(self):
"""Test that method returns bytes"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_bytes("test data")
assert isinstance(result, bytes)
def test_bytes_contains_valid_json(self):
"""Test that returned bytes contain valid JSON"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata_return_bytes("test data")
# Should be valid JSON when decoded
parsed = json.loads(result.decode('utf-8'))
assert 'encrypted_data' in parsed
assert 'salt' in parsed
assert 'key_hash' in parsed
class TestEncryptWithMetadata:
"""Tests for encrypt_with_metadata method with different return types"""
def test_return_as_str(self):
"""Test encrypt_with_metadata with return_as='str'"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data", return_as='str')
assert isinstance(result, str)
json.loads(result) # Should be valid JSON
def test_return_as_json(self):
"""Test encrypt_with_metadata with return_as='json'"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data", return_as='json')
assert isinstance(result, str)
json.loads(result) # Should be valid JSON
def test_return_as_bytes(self):
"""Test encrypt_with_metadata with return_as='bytes'"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data", return_as='bytes')
assert isinstance(result, bytes)
def test_return_as_dict(self):
"""Test encrypt_with_metadata with return_as='dict'"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data", return_as='dict')
assert isinstance(result, dict)
assert 'encrypted_data' in result
def test_default_return_type(self):
"""Test encrypt_with_metadata default return type"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data")
# Default should be 'str'
assert isinstance(result, str)
def test_invalid_return_type_defaults_to_str(self):
"""Test that invalid return_as defaults to str"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data", return_as='invalid')
assert isinstance(result, str)
class TestDecryptWithMetadata:
"""Tests for decrypt_with_metadata method"""
def test_decrypt_string_package(self):
"""Test decrypting a string JSON package"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
def test_decrypt_bytes_package(self):
"""Test decrypting a bytes JSON package"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_bytes("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
def test_decrypt_dict_package(self):
"""Test decrypting a dict PackageData"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_dict("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
def test_decrypt_with_different_password_fails(self):
"""Test that decrypting with wrong password fails"""
encryptor = SymmetricEncryption("password1")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
decryptor = SymmetricEncryption("password2")
with pytest.raises(ValueError, match="Key hash is not matching"):
decryptor.decrypt_with_metadata(encrypted)
def test_decrypt_with_explicit_password(self):
"""Test decrypting with explicitly provided password"""
encryptor = SymmetricEncryption("password1")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
# Decrypt with different password parameter
decryptor = SymmetricEncryption("password1")
decrypted = decryptor.decrypt_with_metadata(encrypted, password="password1")
assert decrypted == "test data"
def test_decrypt_invalid_json_raises_error(self):
"""Test that invalid JSON raises ValueError"""
encryptor = SymmetricEncryption("test_password")
with pytest.raises(ValueError, match="Invalid encrypted package format"):
encryptor.decrypt_with_metadata("not valid json")
def test_decrypt_missing_fields_raises_error(self):
"""Test that missing required fields raises ValueError"""
encryptor = SymmetricEncryption("test_password")
invalid_package = json.dumps({"encrypted_data": "test"})
with pytest.raises(ValueError, match="Invalid encrypted package format"):
encryptor.decrypt_with_metadata(invalid_package)
def test_decrypt_unicode_data(self):
"""Test encrypting and decrypting unicode data"""
encryptor = SymmetricEncryption("test_password")
unicode_data = "Hello 世界 🌍"
encrypted = encryptor.encrypt_with_metadata_return_str(unicode_data)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == unicode_data
def test_decrypt_empty_string(self):
"""Test encrypting and decrypting empty string"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str("")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == ""
def test_decrypt_long_data(self):
"""Test encrypting and decrypting long data"""
encryptor = SymmetricEncryption("test_password")
long_data = "A" * 10000
encrypted = encryptor.encrypt_with_metadata_return_str(long_data)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == long_data
class TestStaticMethods:
"""Tests for static methods encrypt_data and decrypt_data"""
def test_encrypt_data_static_method(self):
"""Test static encrypt_data method"""
encrypted = SymmetricEncryption.encrypt_data("test data", "test_password")
assert isinstance(encrypted, str)
# Should be valid JSON
parsed = json.loads(encrypted)
assert 'encrypted_data' in parsed
assert 'salt' in parsed
assert 'key_hash' in parsed
def test_decrypt_data_static_method(self):
"""Test static decrypt_data method"""
encrypted = SymmetricEncryption.encrypt_data("test data", "test_password")
decrypted = SymmetricEncryption.decrypt_data(encrypted, "test_password")
assert decrypted == "test data"
def test_static_methods_roundtrip(self):
"""Test complete roundtrip using static methods"""
original = "test data with special chars: !@#$%^&*()"
encrypted = SymmetricEncryption.encrypt_data(original, "test_password")
decrypted = SymmetricEncryption.decrypt_data(encrypted, "test_password")
assert decrypted == original
def test_static_decrypt_with_bytes(self):
"""Test static decrypt_data with bytes input"""
encrypted = SymmetricEncryption.encrypt_data("test data", "test_password")
encrypted_bytes = encrypted.encode('utf-8')
decrypted = SymmetricEncryption.decrypt_data(encrypted_bytes, "test_password")
assert decrypted == "test data"
def test_static_decrypt_with_dict(self):
"""Test static decrypt_data with PackageData dict"""
encryptor = SymmetricEncryption("test_password")
encrypted_dict = encryptor.encrypt_with_metadata_return_dict("test data")
decrypted = SymmetricEncryption.decrypt_data(encrypted_dict, "test_password")
assert decrypted == "test data"
def test_static_encrypt_bytes_data(self):
"""Test static encrypt_data with bytes input"""
encrypted = SymmetricEncryption.encrypt_data(b"test data", "test_password")
decrypted = SymmetricEncryption.decrypt_data(encrypted, "test_password")
assert decrypted == "test data"
class TestEncryptionSecurity:
"""Security-related tests for encryption"""
def test_same_data_different_encryption(self):
"""Test that same data produces different encrypted outputs due to salt"""
encryptor = SymmetricEncryption("test_password")
encrypted1 = encryptor.encrypt_with_metadata_return_str("test data")
encrypted2 = encryptor.encrypt_with_metadata_return_str("test data")
assert encrypted1 != encrypted2
def test_password_not_recoverable_from_hash(self):
"""Test that password hash is one-way"""
encryptor = SymmetricEncryption("secret_password")
# The password_hash should be SHA256 hex (64 chars)
assert len(encryptor.password_hash) == 64
# Password should not be easily derivable from hash
assert "secret_password" not in encryptor.password_hash
def test_encrypted_data_not_plaintext(self):
"""Test that encrypted data doesn't contain plaintext"""
encryptor = SymmetricEncryption("test_password")
plaintext = "very_secret_data_12345"
encrypted = encryptor.encrypt_with_metadata_return_str(plaintext)
# Plaintext should not appear in encrypted output
assert plaintext not in encrypted
def test_modified_encrypted_data_fails_decryption(self):
"""Test that modified encrypted data fails to decrypt"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
# Modify the encrypted data
encrypted_dict = json.loads(encrypted)
encrypted_dict['encrypted_data'] = encrypted_dict['encrypted_data'][:-5] + "AAAAA"
modified_encrypted = json.dumps(encrypted_dict)
# Should fail to decrypt
with pytest.raises(Exception): # Fernet will raise an exception
encryptor.decrypt_with_metadata(modified_encrypted)
def test_modified_salt_fails_decryption(self):
"""Test that modified salt fails to decrypt"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
# Modify the salt
encrypted_dict = json.loads(encrypted)
original_salt = base64.urlsafe_b64decode(encrypted_dict['salt'])
modified_salt = bytes([b ^ 1 for b in original_salt])
encrypted_dict['salt'] = base64.urlsafe_b64encode(modified_salt).decode('utf-8')
modified_encrypted = json.dumps(encrypted_dict)
# Should fail to decrypt due to key hash mismatch
with pytest.raises(ValueError, match="Key hash is not matching"):
encryptor.decrypt_with_metadata(modified_encrypted)
class TestEdgeCases:
"""Edge case tests"""
def test_very_long_password(self):
"""Test with very long password"""
long_password = "a" * 1000
encryptor = SymmetricEncryption(long_password)
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
def test_special_characters_in_data(self):
"""Test encryption of data with special characters"""
special_data = "!@#$%^&*()_+-=[]{}|;':\",./<>?\n\t\r"
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(special_data)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == special_data
def test_binary_data_utf8_bytes(self):
"""Test encryption of UTF-8 encoded bytes"""
# Test with UTF-8 encoded bytes
utf8_bytes = "Hello 世界 🌍".encode('utf-8')
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(utf8_bytes)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "Hello 世界 🌍"
def test_binary_data_with_base64_encoding(self):
"""Test encryption of arbitrary binary data using base64 encoding"""
# For arbitrary binary data, encode to base64 first
binary_data = bytes(range(256))
base64_encoded = base64.b64encode(binary_data).decode('utf-8')
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(base64_encoded)
decrypted = encryptor.decrypt_with_metadata(encrypted)
# Decode back to binary
decoded_binary = base64.b64decode(decrypted)
assert decoded_binary == binary_data
def test_binary_data_image_simulation(self):
"""Test encryption of simulated binary image data"""
# Simulate image binary data (random bytes)
image_data = os.urandom(1024) # 1KB of random binary data
base64_encoded = base64.b64encode(image_data).decode('utf-8')
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(base64_encoded)
decrypted = encryptor.decrypt_with_metadata(encrypted)
# Verify round-trip
decoded_data = base64.b64decode(decrypted)
assert decoded_data == image_data
def test_binary_data_with_null_bytes(self):
"""Test encryption of data containing null bytes"""
# Create data with null bytes
data_with_nulls = "text\x00with\x00nulls\x00bytes"
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(data_with_nulls)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == data_with_nulls
def test_binary_data_bytes_input(self):
"""Test encryption with bytes input directly"""
# UTF-8 compatible bytes
byte_data = b"Binary data test"
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(byte_data)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "Binary data test"
def test_binary_data_large_file_simulation(self):
"""Test encryption of large binary data (simulated file)"""
# Simulate a larger binary file (10KB)
large_data = os.urandom(10240)
base64_encoded = base64.b64encode(large_data).decode('utf-8')
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(base64_encoded)
decrypted = encryptor.decrypt_with_metadata(encrypted)
# Verify integrity
decoded_data = base64.b64decode(decrypted)
assert len(decoded_data) == 10240
assert decoded_data == large_data
def test_binary_data_json_with_base64(self):
"""Test encryption of JSON containing base64 encoded binary data"""
binary_data = os.urandom(256)
json_data = json.dumps({
"filename": "test.bin",
"data": base64.b64encode(binary_data).decode('utf-8'),
"size": len(binary_data)
})
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(json_data)
decrypted = encryptor.decrypt_with_metadata(encrypted)
# Parse and verify
parsed = json.loads(decrypted)
assert parsed["filename"] == "test.bin"
assert parsed["size"] == 256
decoded_binary = base64.b64decode(parsed["data"])
assert decoded_binary == binary_data
def test_numeric_password(self):
"""Test with numeric string password"""
encryptor = SymmetricEncryption("12345")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
def test_unicode_password(self):
"""Test with unicode password"""
encryptor = SymmetricEncryption("パスワード123")
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
class TestIntegration:
"""Integration tests"""
def test_multiple_encrypt_decrypt_cycles(self):
"""Test multiple encryption/decryption cycles"""
encryptor = SymmetricEncryption("test_password")
original = "test data"
# Encrypt and decrypt multiple times
for _ in range(5):
encrypted = encryptor.encrypt_with_metadata_return_str(original)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == original
def test_different_return_types_interoperability(self):
"""Test that different return types can be decrypted"""
encryptor = SymmetricEncryption("test_password")
original = "test data"
# Encrypt with different return types
encrypted_str = encryptor.encrypt_with_metadata_return_str(original)
encrypted_bytes = encryptor.encrypt_with_metadata_return_bytes(original)
encrypted_dict = encryptor.encrypt_with_metadata_return_dict(original)
# All should decrypt to the same value
assert encryptor.decrypt_with_metadata(encrypted_str) == original
assert encryptor.decrypt_with_metadata(encrypted_bytes) == original
assert encryptor.decrypt_with_metadata(encrypted_dict) == original
def test_cross_instance_encryption_decryption(self):
"""Test that different instances with same password can decrypt"""
encryptor1 = SymmetricEncryption("test_password")
encryptor2 = SymmetricEncryption("test_password")
encrypted = encryptor1.encrypt_with_metadata_return_str("test data")
decrypted = encryptor2.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
def test_static_and_instance_methods_compatible(self):
"""Test that static and instance methods are compatible"""
# Encrypt with static method
encrypted = SymmetricEncryption.encrypt_data("test data", "test_password")
# Decrypt with instance method
decryptor = SymmetricEncryption("test_password")
decrypted = decryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
# And vice versa
encryptor = SymmetricEncryption("test_password")
encrypted2 = encryptor.encrypt_with_metadata_return_str("test data 2")
decrypted2 = SymmetricEncryption.decrypt_data(encrypted2, "test_password")
assert decrypted2 == "test data 2"
# Parametrized tests
@pytest.mark.parametrize("data", [
"simple text",
"text with spaces and punctuation!",
"123456789",
"unicode: こんにちは",
"emoji: 🔐🔑",
"",
"a" * 1000, # Long string
])
def test_encrypt_decrypt_various_data(data: str):
"""Parametrized test for various data types"""
encryptor = SymmetricEncryption("test_password")
encrypted = encryptor.encrypt_with_metadata_return_str(data)
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == data
@pytest.mark.parametrize("password", [
"simple",
"with spaces",
"special!@#$%",
"unicode世界",
"123456",
"a" * 100, # Long password
])
def test_various_passwords(password: str):
"""Parametrized test for various passwords"""
encryptor = SymmetricEncryption(password)
encrypted = encryptor.encrypt_with_metadata_return_str("test data")
decrypted = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test data"
@pytest.mark.parametrize("return_type,expected_type", [
("str", str),
("json", str),
("bytes", bytes),
("dict", dict),
])
def test_return_types_parametrized(return_type: str, expected_type: type):
"""Parametrized test for different return types"""
encryptor = SymmetricEncryption("test_password")
result = encryptor.encrypt_with_metadata("test data", return_as=return_type)
assert isinstance(result, expected_type)
# Fixtures
@pytest.fixture
def encryptor() -> SymmetricEncryption:
"""Fixture providing a basic encryptor instance"""
return SymmetricEncryption("test_password")
@pytest.fixture
def sample_encrypted_data(encryptor: SymmetricEncryption) -> str:
"""Fixture providing sample encrypted data"""
return encryptor.encrypt_with_metadata_return_str("sample data")
def test_with_encryptor_fixture(encryptor: SymmetricEncryption) -> None:
"""Test using encryptor fixture"""
encrypted: str = encryptor.encrypt_with_metadata_return_str("test")
decrypted: str = encryptor.decrypt_with_metadata(encrypted)
assert decrypted == "test"
def test_with_encrypted_data_fixture(encryptor: SymmetricEncryption, sample_encrypted_data: str) -> None:
"""Test using encrypted data fixture"""
decrypted: str = encryptor.decrypt_with_metadata(sample_encrypted_data)
assert decrypted == "sample data"
# __END__

View File

@@ -0,0 +1,538 @@
"""
PyTest: file_handling/file_bom_encoding
"""
from pathlib import Path
import pytest
from corelibs.file_handling.file_bom_encoding import (
is_bom_encoded,
is_bom_encoded_info,
BomEncodingInfo,
)
class TestIsBomEncoded:
"""Test suite for is_bom_encoded function"""
def test_utf8_bom_file(self, tmp_path: Path):
"""Test detection of UTF-8 BOM encoded file"""
test_file = tmp_path / "utf8_bom.txt"
# UTF-8 BOM: EF BB BF
content = b'\xef\xbb\xbfHello, World!'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
assert isinstance(result, bool)
def test_utf16_le_bom_file(self, tmp_path: Path):
"""Test detection of UTF-16 LE BOM encoded file"""
test_file = tmp_path / "utf16_le_bom.txt"
# UTF-16 LE BOM: FF FE
content = b'\xff\xfeH\x00e\x00l\x00l\x00o\x00'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
def test_utf16_be_bom_file(self, tmp_path: Path):
"""Test detection of UTF-16 BE BOM encoded file"""
test_file = tmp_path / "utf16_be_bom.txt"
# UTF-16 BE BOM: FE FF
content = b'\xfe\xff\x00H\x00e\x00l\x00l\x00o'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
def test_utf32_le_bom_file(self, tmp_path: Path):
"""Test detection of UTF-32 LE BOM encoded file"""
test_file = tmp_path / "utf32_le_bom.txt"
# UTF-32 LE BOM: FF FE 00 00
content = b'\xff\xfe\x00\x00H\x00\x00\x00e\x00\x00\x00'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
def test_utf32_be_bom_file(self, tmp_path: Path):
"""Test detection of UTF-32 BE BOM encoded file"""
test_file = tmp_path / "utf32_be_bom.txt"
# UTF-32 BE BOM: 00 00 FE FF
content = b'\x00\x00\xfe\xff\x00\x00\x00H\x00\x00\x00e'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
def test_no_bom_ascii_file(self, tmp_path: Path):
"""Test detection of ASCII file without BOM"""
test_file = tmp_path / "ascii.txt"
content = b'Hello, World!'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is False
def test_no_bom_utf8_file(self, tmp_path: Path):
"""Test detection of UTF-8 file without BOM"""
test_file = tmp_path / "utf8_no_bom.txt"
content = 'Hello, 世界!'.encode('utf-8')
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is False
def test_empty_file(self, tmp_path: Path):
"""Test detection on empty file"""
test_file = tmp_path / "empty.txt"
test_file.write_bytes(b'')
result = is_bom_encoded(test_file)
assert result is False
def test_binary_file_no_bom(self, tmp_path: Path):
"""Test detection on binary file without BOM"""
test_file = tmp_path / "binary.bin"
content = bytes(range(256))
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is False
def test_partial_bom_pattern(self, tmp_path: Path):
"""Test file with partial BOM pattern that shouldn't match"""
test_file = tmp_path / "partial_bom.txt"
# Only first two bytes of UTF-8 BOM
content = b'\xef\xbbHello'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is False
def test_false_positive_bom_pattern(self, tmp_path: Path):
"""Test file that contains BOM-like bytes but not at the start"""
test_file = tmp_path / "false_positive.txt"
content = b'Hello\xef\xbb\xbfWorld'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is False
def test_nonexistent_file(self, tmp_path: Path):
"""Test that function raises error for non-existent file"""
test_file = tmp_path / "nonexistent.txt"
with pytest.raises(ValueError, match="Error checking BOM encoding"):
is_bom_encoded(test_file)
def test_very_small_file(self, tmp_path: Path):
"""Test file smaller than largest BOM pattern (4 bytes)"""
test_file = tmp_path / "small.txt"
content = b'Hi'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is False
def test_exactly_bom_size_utf8(self, tmp_path: Path):
"""Test file that is exactly the size of UTF-8 BOM"""
test_file = tmp_path / "exact_bom.txt"
content = b'\xef\xbb\xbf'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
def test_exactly_bom_size_utf32(self, tmp_path: Path):
"""Test file that is exactly the size of UTF-32 BOM"""
test_file = tmp_path / "exact_bom_utf32.txt"
content = b'\xff\xfe\x00\x00'
test_file.write_bytes(content)
result = is_bom_encoded(test_file)
assert result is True
class TestIsBomEncodedInfo:
"""Test suite for is_bom_encoded_info function"""
def test_utf8_bom_info(self, tmp_path: Path):
"""Test detailed info for UTF-8 BOM encoded file"""
test_file = tmp_path / "utf8_bom.txt"
content = b'\xef\xbb\xbfHello, UTF-8!'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert isinstance(result, dict)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-8'
assert result['encoding'] == 'utf-8'
assert result['bom_length'] == 3
assert result['bom_pattern'] == b'\xef\xbb\xbf'
def test_utf16_le_bom_info(self, tmp_path: Path):
"""Test detailed info for UTF-16 LE BOM encoded file"""
test_file = tmp_path / "utf16_le_bom.txt"
content = b'\xff\xfeH\x00e\x00l\x00l\x00o\x00'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-16 LE'
assert result['encoding'] == 'utf-16-le'
assert result['bom_length'] == 2
assert result['bom_pattern'] == b'\xff\xfe'
def test_utf16_be_bom_info(self, tmp_path: Path):
"""Test detailed info for UTF-16 BE BOM encoded file"""
test_file = tmp_path / "utf16_be_bom.txt"
content = b'\xfe\xff\x00H\x00e\x00l\x00l\x00o'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-16 BE'
assert result['encoding'] == 'utf-16-be'
assert result['bom_length'] == 2
assert result['bom_pattern'] == b'\xfe\xff'
def test_utf32_le_bom_info(self, tmp_path: Path):
"""Test detailed info for UTF-32 LE BOM encoded file"""
test_file = tmp_path / "utf32_le_bom.txt"
content = b'\xff\xfe\x00\x00H\x00\x00\x00e\x00\x00\x00'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-32 LE'
assert result['encoding'] == 'utf-32-le'
assert result['bom_length'] == 4
assert result['bom_pattern'] == b'\xff\xfe\x00\x00'
def test_utf32_be_bom_info(self, tmp_path: Path):
"""Test detailed info for UTF-32 BE BOM encoded file"""
test_file = tmp_path / "utf32_be_bom.txt"
content = b'\x00\x00\xfe\xff\x00\x00\x00H\x00\x00\x00e'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-32 BE'
assert result['encoding'] == 'utf-32-be'
assert result['bom_length'] == 4
assert result['bom_pattern'] == b'\x00\x00\xfe\xff'
def test_no_bom_info(self, tmp_path: Path):
"""Test detailed info for file without BOM"""
test_file = tmp_path / "no_bom.txt"
content = b'Hello, World!'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is False
assert result['bom_type'] is None
assert result['encoding'] is None
assert result['bom_length'] == 0
assert result['bom_pattern'] is None
def test_empty_file_info(self, tmp_path: Path):
"""Test detailed info for empty file"""
test_file = tmp_path / "empty.txt"
test_file.write_bytes(b'')
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is False
assert result['bom_type'] is None
assert result['encoding'] is None
assert result['bom_length'] == 0
assert result['bom_pattern'] is None
def test_bom_precedence_utf32_vs_utf16(self, tmp_path: Path):
"""Test that UTF-32 LE BOM takes precedence over UTF-16 LE when both match"""
test_file = tmp_path / "precedence.txt"
# UTF-32 LE BOM starts with UTF-16 LE BOM pattern
content = b'\xff\xfe\x00\x00Additional content'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
# Should detect UTF-32 LE, not UTF-16 LE
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-32 LE'
assert result['encoding'] == 'utf-32-le'
assert result['bom_length'] == 4
assert result['bom_pattern'] == b'\xff\xfe\x00\x00'
def test_return_type_validation(self, tmp_path: Path):
"""Test that return type matches BomEncodingInfo TypedDict"""
test_file = tmp_path / "test.txt"
test_file.write_bytes(b'Test content')
result = is_bom_encoded_info(test_file)
# Check all required keys are present
required_keys = {'has_bom', 'bom_type', 'encoding', 'bom_length', 'bom_pattern'}
assert set(result.keys()) == required_keys
# Check types
assert isinstance(result['has_bom'], bool)
assert result['bom_type'] is None or isinstance(result['bom_type'], str)
assert result['encoding'] is None or isinstance(result['encoding'], str)
assert isinstance(result['bom_length'], int)
assert result['bom_pattern'] is None or isinstance(result['bom_pattern'], bytes)
def test_nonexistent_file_error(self, tmp_path: Path):
"""Test that function raises ValueError for non-existent file"""
test_file = tmp_path / "nonexistent.txt"
with pytest.raises(ValueError) as exc_info:
is_bom_encoded_info(test_file)
assert "Error checking BOM encoding" in str(exc_info.value)
def test_directory_instead_of_file(self, tmp_path: Path):
"""Test that function raises error when given a directory"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
with pytest.raises(ValueError, match="Error checking BOM encoding"):
is_bom_encoded_info(test_dir)
def test_large_file_with_bom(self, tmp_path: Path):
"""Test BOM detection on large file (only first 4 bytes matter)"""
test_file = tmp_path / "large_bom.txt"
# UTF-8 BOM followed by large content
content = b'\xef\xbb\xbf' + b'A' * 100000
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-8'
assert result['encoding'] == 'utf-8'
def test_bom_detection_priority_order(self, tmp_path: Path):
"""Test that BOM patterns are checked in the correct priority order"""
# The function should check longer patterns first to avoid false matches
test_cases = [
(b'\xff\xfe\x00\x00', 'UTF-32 LE'), # 4 bytes
(b'\x00\x00\xfe\xff', 'UTF-32 BE'), # 4 bytes
(b'\xff\xfe', 'UTF-16 LE'), # 2 bytes
(b'\xfe\xff', 'UTF-16 BE'), # 2 bytes
(b'\xef\xbb\xbf', 'UTF-8'), # 3 bytes
]
for i, (bom_bytes, expected_type) in enumerate(test_cases):
test_file = tmp_path / f"priority_test_{i}.txt"
content = bom_bytes + b'Content'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['bom_type'] == expected_type
assert result['bom_pattern'] == bom_bytes
def test_csv_file_with_utf8_bom(self, tmp_path: Path):
"""Test CSV file with UTF-8 BOM (common use case mentioned in docstring)"""
test_file = tmp_path / "data.csv"
content = b'\xef\xbb\xbf"Name","Age","City"\n"John",30,"New York"\n"Jane",25,"Tokyo"'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is True
assert result['bom_type'] == 'UTF-8'
assert result['encoding'] == 'utf-8'
assert result['bom_length'] == 3
def test_csv_file_without_bom(self, tmp_path: Path):
"""Test CSV file without BOM"""
test_file = tmp_path / "data_no_bom.csv"
content = b'"Name","Age","City"\n"John",30,"New York"\n"Jane",25,"Tokyo"'
test_file.write_bytes(content)
result = is_bom_encoded_info(test_file)
assert result['has_bom'] is False
assert result['bom_type'] is None
assert result['encoding'] is None
assert result['bom_length'] == 0
class TestBomEncodingInfo:
"""Test suite for BomEncodingInfo TypedDict"""
def test_typed_dict_structure(self):
"""Test that BomEncodingInfo has correct structure"""
# This is a type check - in actual usage, mypy would validate this
sample_info: BomEncodingInfo = {
'has_bom': True,
'bom_type': 'UTF-8',
'encoding': 'utf-8',
'bom_length': 3,
'bom_pattern': b'\xef\xbb\xbf'
}
assert sample_info['has_bom'] is True
assert sample_info['bom_type'] == 'UTF-8'
assert sample_info['encoding'] == 'utf-8'
assert sample_info['bom_length'] == 3
assert sample_info['bom_pattern'] == b'\xef\xbb\xbf'
def test_typed_dict_none_values(self):
"""Test TypedDict with None values"""
sample_info: BomEncodingInfo = {
'has_bom': False,
'bom_type': None,
'encoding': None,
'bom_length': 0,
'bom_pattern': None
}
assert sample_info['has_bom'] is False
assert sample_info['bom_type'] is None
assert sample_info['encoding'] is None
assert sample_info['bom_length'] == 0
assert sample_info['bom_pattern'] is None
class TestIntegration:
"""Integration tests for BOM encoding detection"""
def test_is_bom_encoded_uses_info_function(self, tmp_path: Path):
"""Test that is_bom_encoded uses is_bom_encoded_info internally"""
test_file = tmp_path / "integration.txt"
content = b'\xef\xbb\xbfIntegration test'
test_file.write_bytes(content)
# Both functions should return consistent results
simple_result = is_bom_encoded(test_file)
detailed_result = is_bom_encoded_info(test_file)
assert simple_result == detailed_result['has_bom']
assert simple_result is True
def test_multiple_file_bom_detection_workflow(self, tmp_path: Path):
"""Test a workflow of detecting BOM across multiple files"""
files = {
'utf8_bom.csv': b'\xef\xbb\xbf"data","value"\n"test",123',
'utf16_le.txt': b'\xff\xfeH\x00e\x00l\x00l\x00o\x00',
'no_bom.txt': b'Plain ASCII text',
'empty.txt': b'',
}
results = {}
detailed_results = {}
for filename, content in files.items():
file_path = tmp_path / filename
file_path.write_bytes(content)
results[filename] = is_bom_encoded(file_path)
detailed_results[filename] = is_bom_encoded_info(file_path)
# Verify results
assert results['utf8_bom.csv'] is True
assert results['utf16_le.txt'] is True
assert results['no_bom.txt'] is False
assert results['empty.txt'] is False
# Verify detailed results match simple results
for filename in files:
assert results[filename] == detailed_results[filename]['has_bom']
# Verify specific encoding details
assert detailed_results['utf8_bom.csv']['encoding'] == 'utf-8'
assert detailed_results['utf16_le.txt']['encoding'] == 'utf-16-le'
assert detailed_results['no_bom.txt']['encoding'] is None
def test_csv_loading_workflow(self, tmp_path: Path):
"""Test BOM detection workflow for CSV loading (main use case)"""
# Create CSV files with and without BOM
csv_with_bom = tmp_path / "data_with_bom.csv"
csv_without_bom = tmp_path / "data_without_bom.csv"
# CSV with UTF-8 BOM
bom_content = b'\xef\xbb\xbf"Name","Age"\n"Alice",30\n"Bob",25'
csv_with_bom.write_bytes(bom_content)
# CSV without BOM
no_bom_content = b'"Name","Age"\n"Charlie",35\n"Diana",28'
csv_without_bom.write_bytes(no_bom_content)
# Simulate CSV loading workflow
files_to_process = [csv_with_bom, csv_without_bom]
processing_info: list[dict[str, str | bool | int]] = []
for csv_file in files_to_process:
bom_info = is_bom_encoded_info(csv_file)
file_info: dict[str, str | bool | int] = {
'file': csv_file.name,
'has_bom': bom_info['has_bom'],
'encoding': bom_info['encoding'] or 'default',
'skip_bytes': bom_info['bom_length']
}
processing_info.append(file_info)
# Verify workflow results
assert len(processing_info) == 2
bom_file_info = next(info for info in processing_info if info['file'] == 'data_with_bom.csv')
no_bom_file_info = next(info for info in processing_info if info['file'] == 'data_without_bom.csv')
assert bom_file_info['has_bom'] is True
assert bom_file_info['encoding'] == 'utf-8'
assert bom_file_info['skip_bytes'] == 3
assert no_bom_file_info['has_bom'] is False
assert no_bom_file_info['encoding'] == 'default'
assert no_bom_file_info['skip_bytes'] == 0
def test_error_handling_consistency(self, tmp_path: Path):
"""Test that both functions handle errors consistently"""
nonexistent_file = tmp_path / "does_not_exist.txt"
# Both functions should raise ValueError for non-existent files
with pytest.raises(ValueError):
is_bom_encoded(nonexistent_file)
with pytest.raises(ValueError):
is_bom_encoded_info(nonexistent_file)
def test_all_supported_bom_types(self, tmp_path: Path):
"""Test detection of all supported BOM types"""
bom_test_cases = [
('utf8', b'\xef\xbb\xbf', 'UTF-8', 'utf-8', 3),
('utf16_le', b'\xff\xfe', 'UTF-16 LE', 'utf-16-le', 2),
('utf16_be', b'\xfe\xff', 'UTF-16 BE', 'utf-16-be', 2),
('utf32_le', b'\xff\xfe\x00\x00', 'UTF-32 LE', 'utf-32-le', 4),
('utf32_be', b'\x00\x00\xfe\xff', 'UTF-32 BE', 'utf-32-be', 4),
]
for name, bom_bytes, expected_type, expected_encoding, expected_length in bom_test_cases:
test_file = tmp_path / f"{name}_test.txt"
content = bom_bytes + b'Test content'
test_file.write_bytes(content)
# Test simple function
assert is_bom_encoded(test_file) is True
# Test detailed function
info = is_bom_encoded_info(test_file)
assert info['has_bom'] is True
assert info['bom_type'] == expected_type
assert info['encoding'] == expected_encoding
assert info['bom_length'] == expected_length
assert info['bom_pattern'] == bom_bytes
# __END__

View File

@@ -0,0 +1,389 @@
"""
PyTest: file_handling/file_crc
"""
import zlib
from pathlib import Path
import pytest
from corelibs.file_handling.file_crc import (
file_crc,
file_name_crc,
)
class TestFileCrc:
"""Test suite for file_crc function"""
def test_file_crc_small_file(self, tmp_path: Path):
"""Test CRC calculation for a small file"""
test_file = tmp_path / "test_small.txt"
content = b"Hello, World!"
test_file.write_bytes(content)
# Calculate expected CRC
expected_crc = f"{zlib.crc32(content) & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
assert isinstance(result, str)
assert len(result) == 8 # CRC32 is 8 hex digits
def test_file_crc_large_file(self, tmp_path: Path):
"""Test CRC calculation for a file larger than buffer size (65536 bytes)"""
test_file = tmp_path / "test_large.bin"
# Create a file larger than the buffer (65536 bytes)
content = b"A" * 100000
test_file.write_bytes(content)
# Calculate expected CRC
expected_crc = f"{zlib.crc32(content) & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
def test_file_crc_empty_file(self, tmp_path: Path):
"""Test CRC calculation for an empty file"""
test_file = tmp_path / "test_empty.txt"
test_file.write_bytes(b"")
# CRC of empty data
expected_crc = f"{zlib.crc32(b"") & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
assert result == "00000000"
def test_file_crc_binary_file(self, tmp_path: Path):
"""Test CRC calculation for a binary file"""
test_file = tmp_path / "test_binary.bin"
content = bytes(range(256)) # All possible byte values
test_file.write_bytes(content)
expected_crc = f"{zlib.crc32(content) & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
def test_file_crc_exact_buffer_size(self, tmp_path: Path):
"""Test CRC calculation for a file exactly the buffer size"""
test_file = tmp_path / "test_exact_buffer.bin"
content = b"X" * 65536
test_file.write_bytes(content)
expected_crc = f"{zlib.crc32(content) & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
def test_file_crc_multiple_buffers(self, tmp_path: Path):
"""Test CRC calculation for a file requiring multiple buffer reads"""
test_file = tmp_path / "test_multi_buffer.bin"
content = b"TestData" * 20000 # ~160KB
test_file.write_bytes(content)
expected_crc = f"{zlib.crc32(content) & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
def test_file_crc_unicode_content(self, tmp_path: Path):
"""Test CRC calculation for a file with unicode content"""
test_file = tmp_path / "test_unicode.txt"
content = "Hello 世界! 🌍".encode('utf-8')
test_file.write_bytes(content)
expected_crc = f"{zlib.crc32(content) & 0xFFFFFFFF:08X}"
result = file_crc(test_file)
assert result == expected_crc
def test_file_crc_deterministic(self, tmp_path: Path):
"""Test that CRC calculation is deterministic"""
test_file = tmp_path / "test_deterministic.txt"
content = b"Deterministic test content"
test_file.write_bytes(content)
result1 = file_crc(test_file)
result2 = file_crc(test_file)
assert result1 == result2
def test_file_crc_different_files(self, tmp_path: Path):
"""Test that different files produce different CRCs"""
file1 = tmp_path / "file1.txt"
file2 = tmp_path / "file2.txt"
file1.write_bytes(b"Content 1")
file2.write_bytes(b"Content 2")
crc1 = file_crc(file1)
crc2 = file_crc(file2)
assert crc1 != crc2
def test_file_crc_same_content_different_names(self, tmp_path: Path):
"""Test that files with same content produce same CRC regardless of name"""
file1 = tmp_path / "name1.txt"
file2 = tmp_path / "name2.txt"
content = b"Same content"
file1.write_bytes(content)
file2.write_bytes(content)
crc1 = file_crc(file1)
crc2 = file_crc(file2)
assert crc1 == crc2
def test_file_crc_nonexistent_file(self, tmp_path: Path):
"""Test that file_crc raises error for non-existent file"""
test_file = tmp_path / "nonexistent.txt"
with pytest.raises(FileNotFoundError):
file_crc(test_file)
def test_file_crc_with_path_object(self, tmp_path: Path):
"""Test file_crc works with Path object"""
test_file = tmp_path / "test_path.txt"
test_file.write_bytes(b"Test with Path")
result = file_crc(test_file)
assert isinstance(result, str)
assert len(result) == 8
class TestFileNameCrc:
"""Test suite for file_name_crc function"""
def test_file_name_crc_simple_filename(self, tmp_path: Path):
"""Test extracting simple filename without parent folder"""
test_file = tmp_path / "testfile.csv"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "testfile.csv"
def test_file_name_crc_with_parent_folder(self, tmp_path: Path):
"""Test extracting filename with parent folder"""
parent = tmp_path / "parent_folder"
parent.mkdir()
test_file = parent / "testfile.csv"
result = file_name_crc(test_file, add_parent_folder=True)
assert result == "parent_folder/testfile.csv"
def test_file_name_crc_nested_path_without_parent(self):
"""Test filename extraction from deeply nested path without parent"""
test_path = Path("/foo/bar/baz/file.csv")
result = file_name_crc(test_path, add_parent_folder=False)
assert result == "file.csv"
def test_file_name_crc_nested_path_with_parent(self):
"""Test filename extraction from deeply nested path with parent"""
test_path = Path("/foo/bar/baz/file.csv")
result = file_name_crc(test_path, add_parent_folder=True)
assert result == "baz/file.csv"
def test_file_name_crc_default_parameter(self, tmp_path: Path):
"""Test that add_parent_folder defaults to False"""
test_file = tmp_path / "subdir" / "testfile.txt"
test_file.parent.mkdir(parents=True)
result = file_name_crc(test_file)
assert result == "testfile.txt"
def test_file_name_crc_different_extensions(self, tmp_path: Path):
"""Test with different file extensions"""
extensions = [".txt", ".csv", ".json", ".xml", ".py"]
for ext in extensions:
test_file = tmp_path / f"testfile{ext}"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == f"testfile{ext}"
def test_file_name_crc_no_extension(self, tmp_path: Path):
"""Test with filename without extension"""
test_file = tmp_path / "testfile"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "testfile"
def test_file_name_crc_multiple_dots(self, tmp_path: Path):
"""Test with filename containing multiple dots"""
test_file = tmp_path / "test.file.name.tar.gz"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "test.file.name.tar.gz"
def test_file_name_crc_with_spaces(self, tmp_path: Path):
"""Test with filename containing spaces"""
test_file = tmp_path / "test file name.txt"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "test file name.txt"
def test_file_name_crc_with_special_chars(self, tmp_path: Path):
"""Test with filename containing special characters"""
test_file = tmp_path / "test_file-name (1).txt"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "test_file-name (1).txt"
def test_file_name_crc_unicode_filename(self, tmp_path: Path):
"""Test with unicode characters in filename"""
test_file = tmp_path / "テストファイル.txt"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "テストファイル.txt"
def test_file_name_crc_unicode_parent(self, tmp_path: Path):
"""Test with unicode characters in parent folder name"""
parent = tmp_path / "親フォルダ"
parent.mkdir()
test_file = parent / "file.txt"
result = file_name_crc(test_file, add_parent_folder=True)
assert result == "親フォルダ/file.txt"
def test_file_name_crc_path_separator(self, tmp_path: Path):
"""Test that result uses forward slash separator"""
parent = tmp_path / "parent"
parent.mkdir()
test_file = parent / "file.txt"
result = file_name_crc(test_file, add_parent_folder=True)
assert "/" in result
assert result == "parent/file.txt"
def test_file_name_crc_return_type(self, tmp_path: Path):
"""Test that return type is always string"""
test_file = tmp_path / "test.txt"
result1 = file_name_crc(test_file, add_parent_folder=False)
result2 = file_name_crc(test_file, add_parent_folder=True)
assert isinstance(result1, str)
assert isinstance(result2, str)
def test_file_name_crc_root_level_file(self):
"""Test with file at root level"""
test_path = Path("/file.txt")
result_without_parent = file_name_crc(test_path, add_parent_folder=False)
assert result_without_parent == "file.txt"
result_with_parent = file_name_crc(test_path, add_parent_folder=True)
# Parent of root-level file would be empty string or root
assert "file.txt" in result_with_parent
def test_file_name_crc_relative_path(self):
"""Test with relative path"""
test_path = Path("folder/subfolder/file.txt")
result = file_name_crc(test_path, add_parent_folder=True)
assert result == "subfolder/file.txt"
def test_file_name_crc_current_dir(self):
"""Test with file in current directory"""
test_path = Path("file.txt")
result = file_name_crc(test_path, add_parent_folder=False)
assert result == "file.txt"
def test_file_name_crc_nonexistent_file(self, tmp_path: Path):
"""Test that file_name_crc works even if file doesn't exist"""
test_file = tmp_path / "parent" / "nonexistent.txt"
# Should work without file existing
result1 = file_name_crc(test_file, add_parent_folder=False)
assert result1 == "nonexistent.txt"
result2 = file_name_crc(test_file, add_parent_folder=True)
assert result2 == "parent/nonexistent.txt"
def test_file_name_crc_explicit_true(self, tmp_path: Path):
"""Test explicitly setting add_parent_folder to True"""
parent = tmp_path / "mydir"
parent.mkdir()
test_file = parent / "myfile.dat"
result = file_name_crc(test_file, add_parent_folder=True)
assert result == "mydir/myfile.dat"
def test_file_name_crc_explicit_false(self, tmp_path: Path):
"""Test explicitly setting add_parent_folder to False"""
parent = tmp_path / "mydir"
parent.mkdir()
test_file = parent / "myfile.dat"
result = file_name_crc(test_file, add_parent_folder=False)
assert result == "myfile.dat"
class TestIntegration:
"""Integration tests combining both functions"""
def test_crc_and_naming_together(self, tmp_path: Path):
"""Test using both functions on the same file"""
parent = tmp_path / "data"
parent.mkdir()
test_file = parent / "testfile.csv"
test_file.write_bytes(b"Sample data for integration test")
# Get CRC
crc = file_crc(test_file)
assert len(crc) == 8
# Get filename
name_simple = file_name_crc(test_file, add_parent_folder=False)
assert name_simple == "testfile.csv"
name_with_parent = file_name_crc(test_file, add_parent_folder=True)
assert name_with_parent == "data/testfile.csv"
def test_multiple_files_crc_comparison(self, tmp_path: Path):
"""Test CRC comparison across multiple files"""
files: dict[str, str] = {}
for i in range(3):
file_path = tmp_path / f"file{i}.txt"
file_path.write_bytes(f"Content {i}".encode())
files[f"file{i}.txt"] = file_crc(file_path)
# All CRCs should be different
assert len(set(files.values())) == 3
def test_workflow_file_identification(self, tmp_path: Path):
"""Test a workflow of identifying files by name and verifying by CRC"""
# Create directory structure
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
# Create same-named files with different content
file1 = dir1 / "data.csv"
file2 = dir2 / "data.csv"
file1.write_bytes(b"Data set 1")
file2.write_bytes(b"Data set 2")
# Get names (should be the same)
name1 = file_name_crc(file1, add_parent_folder=False)
name2 = file_name_crc(file2, add_parent_folder=False)
assert name1 == name2 == "data.csv"
# Get names with parent (should be different)
full_name1 = file_name_crc(file1, add_parent_folder=True)
full_name2 = file_name_crc(file2, add_parent_folder=True)
assert full_name1 == "dir1/data.csv"
assert full_name2 == "dir2/data.csv"
# Get CRCs (should be different)
crc1 = file_crc(file1)
crc2 = file_crc(file2)
assert crc1 != crc2
# __END__

View File

@@ -0,0 +1,522 @@
"""
PyTest: file_handling/file_handling
"""
# pylint: disable=use-implicit-booleaness-not-comparison
from pathlib import Path
from pytest import CaptureFixture
from corelibs.file_handling.file_handling import (
remove_all_in_directory,
)
class TestRemoveAllInDirectory:
"""Test suite for remove_all_in_directory function"""
def test_remove_all_files_in_empty_directory(self, tmp_path: Path):
"""Test removing all files from an empty directory"""
test_dir = tmp_path / "empty_dir"
test_dir.mkdir()
result = remove_all_in_directory(test_dir)
assert result is True
assert test_dir.exists() # Directory itself should still exist
assert list(test_dir.iterdir()) == []
def test_remove_all_files_in_directory(self, tmp_path: Path):
"""Test removing all files from a directory with files"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create test files
(test_dir / "file1.txt").write_text("content 1")
(test_dir / "file2.txt").write_text("content 2")
(test_dir / "file3.csv").write_text("csv,data")
result = remove_all_in_directory(test_dir)
assert result is True
assert test_dir.exists()
assert list(test_dir.iterdir()) == []
def test_remove_all_subdirectories(self, tmp_path: Path):
"""Test removing subdirectories within a directory"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create subdirectories
subdir1 = test_dir / "subdir1"
subdir2 = test_dir / "subdir2"
subdir1.mkdir()
subdir2.mkdir()
# Add files to subdirectories
(subdir1 / "file.txt").write_text("content")
(subdir2 / "file.txt").write_text("content")
result = remove_all_in_directory(test_dir)
assert result is True
assert test_dir.exists()
assert list(test_dir.iterdir()) == []
def test_remove_nested_structure(self, tmp_path: Path):
"""Test removing deeply nested directory structure"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create nested structure
nested = test_dir / "level1" / "level2" / "level3"
nested.mkdir(parents=True)
(nested / "deep_file.txt").write_text("deep content")
(test_dir / "level1" / "mid_file.txt").write_text("mid content")
(test_dir / "top_file.txt").write_text("top content")
result = remove_all_in_directory(test_dir)
assert result is True
assert test_dir.exists()
assert list(test_dir.iterdir()) == []
def test_remove_with_ignore_files_single(self, tmp_path: Path):
"""Test removing files while ignoring specific files"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files
(test_dir / "keep.txt").write_text("keep me")
(test_dir / "remove1.txt").write_text("remove me")
(test_dir / "remove2.txt").write_text("remove me too")
result = remove_all_in_directory(test_dir, ignore_files=["keep.txt"])
assert result is True
assert test_dir.exists()
remaining = list(test_dir.iterdir())
assert len(remaining) == 1
assert remaining[0].name == "keep.txt"
def test_remove_with_ignore_files_multiple(self, tmp_path: Path):
"""Test removing files while ignoring multiple specific files"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files
(test_dir / "keep1.txt").write_text("keep me")
(test_dir / "keep2.log").write_text("keep me too")
(test_dir / "remove.txt").write_text("remove me")
result = remove_all_in_directory(
test_dir,
ignore_files=["keep1.txt", "keep2.log"]
)
assert result is True
assert test_dir.exists()
remaining = {f.name for f in test_dir.iterdir()}
assert remaining == {"keep1.txt", "keep2.log"}
def test_remove_with_ignore_directory(self, tmp_path: Path):
"""Test removing with ignored directory"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create directories
keep_dir = test_dir / "keep_dir"
remove_dir = test_dir / "remove_dir"
keep_dir.mkdir()
remove_dir.mkdir()
(keep_dir / "file.txt").write_text("keep")
(remove_dir / "file.txt").write_text("remove")
result = remove_all_in_directory(test_dir, ignore_files=["keep_dir"])
assert result is True
assert keep_dir.exists()
assert not remove_dir.exists()
def test_remove_with_ignore_nested_files(self, tmp_path: Path):
"""Test that ignore_files matches by name at any level"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files with same name at different levels
(test_dir / "keep.txt").write_text("top level keep")
(test_dir / "remove.txt").write_text("remove")
subdir = test_dir / "subdir"
subdir.mkdir()
(subdir / "file.txt").write_text("nested")
result = remove_all_in_directory(test_dir, ignore_files=["keep.txt"])
assert result is True
# keep.txt should be preserved at top level
assert (test_dir / "keep.txt").exists()
# Other files should be removed
assert not (test_dir / "remove.txt").exists()
# Subdirectory not in ignore list should be removed
assert not subdir.exists()
def test_remove_nonexistent_directory(self, tmp_path: Path):
"""Test removing from a non-existent directory returns False"""
test_dir = tmp_path / "nonexistent"
result = remove_all_in_directory(test_dir)
assert result is False
def test_remove_from_file_not_directory(self, tmp_path: Path):
"""Test that function returns False when given a file instead of directory"""
test_file = tmp_path / "file.txt"
test_file.write_text("content")
result = remove_all_in_directory(test_file)
assert result is False
assert test_file.exists() # File should not be affected
def test_remove_with_verbose_mode(self, tmp_path: Path, capsys: CaptureFixture[str]):
"""Test verbose mode produces output"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files and directories
(test_dir / "file1.txt").write_text("content")
(test_dir / "file2.txt").write_text("content")
subdir = test_dir / "subdir"
subdir.mkdir()
(subdir / "nested.txt").write_text("content")
result = remove_all_in_directory(test_dir, verbose=True)
assert result is True
captured = capsys.readouterr()
assert "Remove old files in: test_dir [" in captured.out
assert "]" in captured.out
assert "." in captured.out # Files are marked with .
assert "/" in captured.out # Directories are marked with /
def test_remove_with_dry_run_mode(self, tmp_path: Path):
"""Test dry run mode doesn't actually remove files"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create test files
file1 = test_dir / "file1.txt"
file2 = test_dir / "file2.txt"
file1.write_text("content 1")
file2.write_text("content 2")
result = remove_all_in_directory(test_dir, dry_run=True)
assert result is True
# Files should still exist
assert file1.exists()
assert file2.exists()
assert len(list(test_dir.iterdir())) == 2
def test_remove_with_dry_run_and_verbose(self, tmp_path: Path, capsys: CaptureFixture[str]):
"""Test dry run with verbose mode shows [DRY RUN] prefix"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "file.txt").write_text("content")
result = remove_all_in_directory(test_dir, dry_run=True, verbose=True)
assert result is True
captured = capsys.readouterr()
assert "[DRY RUN]" in captured.out
def test_remove_mixed_content(self, tmp_path: Path):
"""Test removing mixed files and directories"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create mixed content
(test_dir / "file1.txt").write_text("content")
(test_dir / "file2.csv").write_text("csv")
subdir1 = test_dir / "subdir1"
subdir2 = test_dir / "subdir2"
subdir1.mkdir()
subdir2.mkdir()
(subdir1 / "nested_file.txt").write_text("nested")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_with_none_ignore_files(self, tmp_path: Path):
"""Test that None as ignore_files works correctly"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "file.txt").write_text("content")
result = remove_all_in_directory(test_dir, ignore_files=None)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_with_empty_ignore_list(self, tmp_path: Path):
"""Test that empty ignore_files list works correctly"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "file.txt").write_text("content")
result = remove_all_in_directory(test_dir, ignore_files=[])
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_special_characters_in_filenames(self, tmp_path: Path):
"""Test removing files with special characters in names"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files with special characters
(test_dir / "file with spaces.txt").write_text("content")
(test_dir / "file-with-dashes.txt").write_text("content")
(test_dir / "file_with_underscores.txt").write_text("content")
(test_dir / "file.multiple.dots.txt").write_text("content")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_unicode_filenames(self, tmp_path: Path):
"""Test removing files with unicode characters in names"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files with unicode names
(test_dir / "ファイル.txt").write_text("content")
(test_dir / "文件.txt").write_text("content")
(test_dir / "αρχείο.txt").write_text("content")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_hidden_files(self, tmp_path: Path):
"""Test removing hidden files (dotfiles)"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create hidden files
(test_dir / ".hidden").write_text("content")
(test_dir / ".gitignore").write_text("content")
(test_dir / "normal.txt").write_text("content")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_preserves_ignored_hidden_files(self, tmp_path: Path):
"""Test that ignored hidden files are preserved"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / ".gitkeep").write_text("keep")
(test_dir / "file.txt").write_text("remove")
result = remove_all_in_directory(test_dir, ignore_files=[".gitkeep"])
assert result is True
remaining = list(test_dir.iterdir())
assert len(remaining) == 1
assert remaining[0].name == ".gitkeep"
def test_remove_large_number_of_files(self, tmp_path: Path):
"""Test removing a large number of files"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create 100 files
for i in range(100):
(test_dir / f"file_{i:03d}.txt").write_text(f"content {i}")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_deeply_nested_with_ignore(self, tmp_path: Path):
"""Test removing structure while preserving ignored items
Note: rglob processes files depth-first, so files inside an ignored
directory will be processed (and potentially removed) before the directory
itself is checked. Only items at the same level or that share the same name
as ignored items will be preserved.
"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create structure
level1 = test_dir / "level1"
level1.mkdir()
keep_file = test_dir / "keep.txt"
(level1 / "file.txt").write_text("remove")
keep_file.write_text("keep this file")
(test_dir / "top.txt").write_text("remove")
result = remove_all_in_directory(test_dir, ignore_files=["keep.txt"])
assert result is True
# Check that keep.txt is preserved
assert keep_file.exists()
assert keep_file.read_text() == "keep this file"
# Other items should be removed
assert not (test_dir / "top.txt").exists()
assert not level1.exists()
def test_remove_binary_files(self, tmp_path: Path):
"""Test removing binary files"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create binary files
(test_dir / "binary1.bin").write_bytes(bytes(range(256)))
(test_dir / "binary2.dat").write_bytes(b"\x00\x01\x02\xff")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_symlinks(self, tmp_path: Path):
"""Test removing symbolic links"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create a file and a symlink to it
original = tmp_path / "original.txt"
original.write_text("original content")
symlink = test_dir / "link.txt"
symlink.symlink_to(original)
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
# Original file should still exist
assert original.exists()
def test_remove_with_permissions_variations(self, tmp_path: Path):
"""Test removing files with different permissions"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create files
file1 = test_dir / "readonly.txt"
file2 = test_dir / "normal.txt"
file1.write_text("readonly")
file2.write_text("normal")
# Make file1 read-only
file1.chmod(0o444)
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_default_parameters(self, tmp_path: Path):
"""Test function with only required parameter"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "file.txt").write_text("content")
result = remove_all_in_directory(test_dir)
assert result is True
assert list(test_dir.iterdir()) == []
def test_remove_return_value_true_when_successful(self, tmp_path: Path):
"""Test that function returns True on successful removal"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "file.txt").write_text("content")
result = remove_all_in_directory(test_dir)
assert result is True
assert isinstance(result, bool)
def test_remove_return_value_false_when_not_directory(self, tmp_path: Path):
"""Test that function returns False when path is not a directory"""
test_file = tmp_path / "file.txt"
test_file.write_text("content")
result = remove_all_in_directory(test_file)
assert result is False
assert isinstance(result, bool)
def test_remove_directory_becomes_empty(self, tmp_path: Path):
"""Test that directory is empty after removal"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create various items
(test_dir / "file.txt").write_text("content")
subdir = test_dir / "subdir"
subdir.mkdir()
(subdir / "nested.txt").write_text("nested")
# Verify directory is not empty before
assert len(list(test_dir.iterdir())) > 0
result = remove_all_in_directory(test_dir)
assert result is True
# Verify directory is empty after
assert len(list(test_dir.iterdir())) == 0
assert test_dir.exists()
assert test_dir.is_dir()
class TestIntegration:
"""Integration tests for file_handling module"""
def test_multiple_remove_operations(self, tmp_path: Path):
"""Test multiple consecutive remove operations"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# First batch of files
(test_dir / "batch1_file1.txt").write_text("content")
(test_dir / "batch1_file2.txt").write_text("content")
result1 = remove_all_in_directory(test_dir)
assert result1 is True
assert list(test_dir.iterdir()) == []
# Second batch of files
(test_dir / "batch2_file1.txt").write_text("content")
(test_dir / "batch2_file2.txt").write_text("content")
result2 = remove_all_in_directory(test_dir)
assert result2 is True
assert list(test_dir.iterdir()) == []
def test_remove_then_recreate(self, tmp_path: Path):
"""Test removing files then recreating them"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Create and remove
original_file = test_dir / "file.txt"
original_file.write_text("original")
remove_all_in_directory(test_dir)
assert not original_file.exists()
# Recreate
new_file = test_dir / "file.txt"
new_file.write_text("new content")
assert new_file.exists()
assert new_file.read_text() == "new content"
def test_cleanup_workflow(self, tmp_path: Path):
"""Test a typical cleanup workflow"""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
# Simulate work directory
(test_dir / "temp1.tmp").write_text("temp")
(test_dir / "temp2.tmp").write_text("temp")
(test_dir / "result.txt").write_text("important")
# Clean up temp files, keep result
result = remove_all_in_directory(
test_dir,
ignore_files=["result.txt"]
)
assert result is True
remaining = list(test_dir.iterdir())
assert len(remaining) == 1
assert remaining[0].name == "result.txt"
assert remaining[0].read_text() == "important"
# __END__

View File

@@ -0,0 +1,601 @@
"""
tests for corelibs.iterator_handling.data_search
"""
# pylint: disable=use-implicit-booleaness-not-comparison
from typing import Any
import pytest
from corelibs.iterator_handling.data_search import (
find_in_array_from_list,
key_lookup,
value_lookup,
ArraySearchList
)
class TestFindInArrayFromList:
"""Tests for find_in_array_from_list function"""
def test_basic_single_key_match(self):
"""Test basic search with single key-value pair"""
data = [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
{"name": "Charlie", "age": 35}
]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Bob"}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 1
assert result[0]["name"] == "Bob"
assert result[0]["age"] == 25
def test_multiple_key_match(self):
"""Test search with multiple key-value pairs (AND logic)"""
data = [
{"name": "Alice", "age": 30, "city": "New York"},
{"name": "Bob", "age": 25, "city": "London"},
{"name": "Charlie", "age": 30, "city": "Paris"}
]
search_params: list[ArraySearchList] = [
{"key": "age", "value": 30},
{"key": "city", "value": "New York"}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 1
assert result[0]["name"] == "Alice"
def test_value_list_or_match(self):
"""Test search with list of values (OR logic)"""
data = [
{"name": "Alice", "status": "active"},
{"name": "Bob", "status": "inactive"},
{"name": "Charlie", "status": "pending"}
]
search_params: list[ArraySearchList] = [
{"key": "status", "value": ["active", "pending"]}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["name"] == "Alice"
assert result[1]["name"] == "Charlie"
def test_case_sensitive_true(self):
"""Test case-sensitive search (default behavior)"""
data = [
{"name": "Alice"},
{"name": "alice"},
{"name": "ALICE"}
]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Alice"}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 1
assert result[0]["name"] == "Alice"
def test_case_insensitive_search(self):
"""Test case-insensitive search"""
data = [
{"name": "Alice"},
{"name": "alice"},
{"name": "ALICE"}
]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "alice", "case_sensitive": False}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 3
def test_case_insensitive_with_list_values(self):
"""Test case-insensitive search with list of values"""
data = [
{"status": "ACTIVE"},
{"status": "Pending"},
{"status": "inactive"}
]
search_params: list[ArraySearchList] = [
{"key": "status", "value": ["active", "pending"], "case_sensitive": False}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["status"] == "ACTIVE"
assert result[1]["status"] == "Pending"
def test_return_index_true(self):
"""Test returning results with index"""
data = [
{"name": "Alice"},
{"name": "Bob"},
{"name": "Charlie"}
]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Bob"}
]
result = find_in_array_from_list(data, search_params, return_index=True)
assert len(result) == 1
assert result[0]["index"] == 1
assert result[0]["data"]["name"] == "Bob"
def test_return_index_multiple_results(self):
"""Test returning multiple results with indices"""
data = [
{"status": "active"},
{"status": "inactive"},
{"status": "active"}
]
search_params: list[ArraySearchList] = [
{"key": "status", "value": "active"}
]
result = find_in_array_from_list(data, search_params, return_index=True)
assert len(result) == 2
assert result[0]["index"] == 0
assert result[0]["data"]["status"] == "active"
assert result[1]["index"] == 2
assert result[1]["data"]["status"] == "active"
def test_no_match_returns_empty_list(self):
"""Test that no match returns empty list"""
data = [
{"name": "Alice"},
{"name": "Bob"}
]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Charlie"}
]
result = find_in_array_from_list(data, search_params)
assert result == []
def test_empty_data_returns_empty_list(self):
"""Test that empty data list returns empty list"""
data: list[dict[str, Any]] = []
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Alice"}
]
result = find_in_array_from_list(data, search_params)
assert result == []
def test_missing_key_in_data(self):
"""Test search when key doesn't exist in some data items"""
data = [
{"name": "Alice", "age": 30},
{"name": "Bob"}, # Missing 'age' key
{"name": "Charlie", "age": 30}
]
search_params: list[ArraySearchList] = [
{"key": "age", "value": 30}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["name"] == "Alice"
assert result[1]["name"] == "Charlie"
def test_numeric_values(self):
"""Test search with numeric values"""
data = [
{"id": 1, "score": 95},
{"id": 2, "score": 87},
{"id": 3, "score": 95}
]
search_params: list[ArraySearchList] = [
{"key": "score", "value": 95}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["id"] == 1
assert result[1]["id"] == 3
def test_boolean_values(self):
"""Test search with boolean values"""
data = [
{"name": "Alice", "active": True},
{"name": "Bob", "active": False},
{"name": "Charlie", "active": True}
]
search_params: list[ArraySearchList] = [
{"key": "active", "value": True}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["name"] == "Alice"
assert result[1]["name"] == "Charlie"
def test_float_values(self):
"""Test search with float values"""
data = [
{"name": "Product A", "price": 19.99},
{"name": "Product B", "price": 29.99},
{"name": "Product C", "price": 19.99}
]
search_params: list[ArraySearchList] = [
{"key": "price", "value": 19.99}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["name"] == "Product A"
assert result[1]["name"] == "Product C"
def test_mixed_value_types_in_list(self):
"""Test search with mixed types in value list"""
data = [
{"id": "1", "value": "active"},
{"id": 2, "value": "pending"},
{"id": "3", "value": "active"}
]
search_params: list[ArraySearchList] = [
{"key": "id", "value": ["1", "3"]}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["id"] == "1"
assert result[1]["id"] == "3"
def test_complex_multi_criteria_search(self):
"""Test complex search with multiple criteria"""
data = [
{"name": "Alice", "age": 30, "city": "New York", "status": "active"},
{"name": "Bob", "age": 25, "city": "London", "status": "active"},
{"name": "Charlie", "age": 30, "city": "Paris", "status": "inactive"},
{"name": "David", "age": 30, "city": "New York", "status": "active"}
]
search_params: list[ArraySearchList] = [
{"key": "age", "value": 30},
{"key": "city", "value": "New York"},
{"key": "status", "value": "active"}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["name"] == "Alice"
assert result[1]["name"] == "David"
def test_invalid_search_params_not_list(self):
"""Test that non-list search_params raises ValueError"""
data = [{"name": "Alice"}]
search_params = {"key": "name", "value": "Alice"} # type: ignore
with pytest.raises(ValueError, match="search_params must be a list"):
find_in_array_from_list(data, search_params) # type: ignore
def test_missing_key_in_search_params(self):
"""Test that missing 'key' in search_params raises KeyError"""
data = [{"name": "Alice"}]
search_params: list[dict[str, Any]] = [
{"value": "Alice"} # Missing 'key'
]
with pytest.raises(KeyError, match="Either Key '' or Value 'Alice' is missing or empty"):
find_in_array_from_list(data, search_params) # type: ignore
def test_missing_value_in_search_params(self):
"""Test that missing 'value' in search_params raises KeyError"""
data = [{"name": "Alice"}]
search_params: list[dict[str, Any]] = [
{"key": "name"} # Missing 'value'
]
with pytest.raises(KeyError, match="Either Key 'name' or Value"):
find_in_array_from_list(data, search_params) # type: ignore
def test_empty_key_in_search_params(self):
"""Test that empty 'key' in search_params raises KeyError"""
data = [{"name": "Alice"}]
search_params: list[dict[str, Any]] = [
{"key": "", "value": "Alice"}
]
with pytest.raises(KeyError, match="Either Key '' or Value 'Alice' is missing or empty"):
find_in_array_from_list(data, search_params) # type: ignore
def test_empty_value_in_search_params(self):
"""Test that empty 'value' in search_params raises KeyError"""
data = [{"name": "Alice"}]
search_params: list[dict[str, Any]] = [
{"key": "name", "value": ""}
]
with pytest.raises(KeyError, match="Either Key 'name' or Value '' is missing or empty"):
find_in_array_from_list(data, search_params) # type: ignore
def test_duplicate_key_in_search_params(self):
"""Test that duplicate keys in search_params raises KeyError"""
data = [{"name": "Alice", "age": 30}]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Alice"},
{"key": "name", "value": "Bob"} # Duplicate key
]
with pytest.raises(KeyError, match="Key name already exists in search_params"):
find_in_array_from_list(data, search_params)
def test_partial_match_fails(self):
"""Test that partial match (not all criteria) returns no result"""
data = [
{"name": "Alice", "age": 30, "city": "New York"}
]
search_params: list[ArraySearchList] = [
{"key": "name", "value": "Alice"},
{"key": "age", "value": 25} # Doesn't match
]
result = find_in_array_from_list(data, search_params)
assert result == []
def test_none_value_in_list(self):
"""Test search with None in value list"""
data = [
{"name": "Alice", "nickname": "Ally"},
{"name": "Bob", "nickname": None},
{"name": "Charlie", "nickname": "Chuck"}
]
search_params: list[ArraySearchList] = [
{"key": "nickname", "value": [None, "Chuck"]}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == 2
assert result[0]["name"] == "Bob"
assert result[1]["name"] == "Charlie"
@pytest.mark.parametrize("test_value,expected_count", [
("active", 1),
("inactive", 1),
("pending", 1),
("archived", 0)
])
def test_parametrized_status_search(self, test_value: str, expected_count: int):
"""Parametrized test for different status values"""
data = [
{"id": 1, "status": "active"},
{"id": 2, "status": "inactive"},
{"id": 3, "status": "pending"}
]
search_params: list[ArraySearchList] = [
{"key": "status", "value": test_value}
]
result = find_in_array_from_list(data, search_params)
assert len(result) == expected_count
class TestKeyLookup:
"""Tests for key_lookup function"""
def test_key_exists(self):
"""Test lookup when key exists"""
haystack = {"name": "Alice", "age": "30", "city": "New York"}
result = key_lookup(haystack, "name")
assert result == "Alice"
def test_key_not_exists(self):
"""Test lookup when key doesn't exist returns empty string"""
haystack = {"name": "Alice", "age": "30"}
result = key_lookup(haystack, "city")
assert result == ""
def test_empty_dict(self):
"""Test lookup in empty dictionary"""
haystack: dict[str, str] = {}
result = key_lookup(haystack, "name")
assert result == ""
def test_multiple_lookups(self):
"""Test multiple lookups in same dictionary"""
haystack = {"first": "John", "last": "Doe", "email": "john@example.com"}
assert key_lookup(haystack, "first") == "John"
assert key_lookup(haystack, "last") == "Doe"
assert key_lookup(haystack, "email") == "john@example.com"
assert key_lookup(haystack, "phone") == ""
def test_numeric_string_values(self):
"""Test lookup with numeric string values"""
haystack = {"count": "42", "price": "19.99"}
assert key_lookup(haystack, "count") == "42"
assert key_lookup(haystack, "price") == "19.99"
def test_empty_string_value(self):
"""Test lookup when value is empty string"""
haystack = {"name": "", "city": "New York"}
result = key_lookup(haystack, "name")
assert result == ""
def test_whitespace_value(self):
"""Test lookup when value contains whitespace"""
haystack = {"name": " Alice ", "message": " "}
assert key_lookup(haystack, "name") == " Alice "
assert key_lookup(haystack, "message") == " "
@pytest.mark.parametrize("key,expected", [
("a", "1"),
("b", "2"),
("c", "3"),
("d", "")
])
def test_parametrized_lookup(self, key: str, expected: str):
"""Parametrized test for key lookup"""
haystack = {"a": "1", "b": "2", "c": "3"}
result = key_lookup(haystack, key)
assert result == expected
class TestValueLookup:
"""Tests for value_lookup function"""
def test_value_exists_single(self):
"""Test lookup when value exists once"""
haystack = {"name": "Alice", "username": "alice123", "email": "alice@example.com"}
result = value_lookup(haystack, "Alice")
assert result == "name"
def test_value_not_exists(self):
"""Test lookup when value doesn't exist returns empty string"""
haystack = {"name": "Alice", "username": "alice123"}
result = value_lookup(haystack, "Bob")
assert result == ""
def test_value_exists_multiple_no_raise(self):
"""Test lookup when value exists multiple times, returns first"""
haystack = {"key1": "duplicate", "key2": "unique", "key3": "duplicate"}
result = value_lookup(haystack, "duplicate")
assert result in ["key1", "key3"] # Order may vary in dict
def test_value_exists_multiple_raise_on_many_false(self):
"""Test lookup with multiple matches and raise_on_many=False"""
haystack = {"a": "same", "b": "same", "c": "different"}
result = value_lookup(haystack, "same", raise_on_many=False)
assert result in ["a", "b"]
def test_value_exists_multiple_raise_on_many_true(self):
"""Test lookup with multiple matches and raise_on_many=True raises ValueError"""
haystack = {"a": "same", "b": "same", "c": "different"}
with pytest.raises(ValueError, match="More than one element found with the same name"):
value_lookup(haystack, "same", raise_on_many=True)
def test_value_exists_single_raise_on_many_true(self):
"""Test lookup with single match and raise_on_many=True works fine"""
haystack = {"name": "Alice", "username": "alice123"}
result = value_lookup(haystack, "Alice", raise_on_many=True)
assert result == "name"
def test_empty_dict(self):
"""Test lookup in empty dictionary"""
haystack: dict[str, str] = {}
result = value_lookup(haystack, "Alice")
assert result == ""
def test_empty_dict_raise_on_many(self):
"""Test lookup in empty dictionary with raise_on_many=True"""
haystack: dict[str, str] = {}
result = value_lookup(haystack, "Alice", raise_on_many=True)
assert result == ""
def test_numeric_string_values(self):
"""Test lookup with numeric string values"""
haystack = {"id": "123", "count": "456", "score": "123"}
result = value_lookup(haystack, "456")
assert result == "count"
def test_empty_string_value(self):
"""Test lookup for empty string value"""
haystack = {"name": "", "city": "New York", "country": ""}
result = value_lookup(haystack, "")
assert result in ["name", "country"]
def test_whitespace_value(self):
"""Test lookup for whitespace value"""
haystack = {"a": " spaces ", "b": "normal", "c": " spaces "}
result = value_lookup(haystack, " spaces ")
assert result in ["a", "c"]
def test_case_sensitive_lookup(self):
"""Test that lookup is case-sensitive"""
haystack = {"name": "Alice", "username": "alice", "email": "ALICE"}
assert value_lookup(haystack, "Alice") == "name"
assert value_lookup(haystack, "alice") == "username"
assert value_lookup(haystack, "ALICE") == "email"
assert value_lookup(haystack, "aLiCe") == ""
def test_special_characters(self):
"""Test lookup with special characters"""
haystack = {"key1": "test@example.com", "key2": "test#value", "key3": "test@example.com"}
result = value_lookup(haystack, "test@example.com")
assert result in ["key1", "key3"]
@pytest.mark.parametrize("value,expected_key", [
("value1", "a"),
("value2", "b"),
("value3", "c"),
("nonexistent", "")
])
def test_parametrized_lookup(self, value: str, expected_key: str):
"""Parametrized test for value lookup"""
haystack = {"a": "value1", "b": "value2", "c": "value3"}
result = value_lookup(haystack, value)
assert result == expected_key
def test_duplicate_values_consistent_return(self):
"""Test that lookup with duplicates consistently returns one of the keys"""
haystack = {"x": "dup", "y": "dup", "z": "dup"}
# Should return same key consistently
result1 = value_lookup(haystack, "dup")
result2 = value_lookup(haystack, "dup")
result3 = value_lookup(haystack, "dup")
assert result1 == result2 == result3
assert result1 in ["x", "y", "z"]

View File

@@ -0,0 +1,652 @@
"""
iterator_handling.dict_helper tests
"""
# pylint: disable=use-implicit-booleaness-not-comparison
from typing import Any
import pytest
from corelibs.iterator_handling.dict_helpers import (
delete_keys_from_set,
build_dict,
set_entry,
)
class TestDeleteKeysFromSet:
"""Test cases for delete_keys_from_set function"""
def test_delete_single_key_from_dict(self):
"""Test deleting a single key from a dictionary"""
set_data = {"a": 1, "b": 2, "c": 3}
keys = ["b"]
result = delete_keys_from_set(set_data, keys)
assert result == {"a": 1, "c": 3}
assert "b" not in result
def test_delete_multiple_keys_from_dict(self):
"""Test deleting multiple keys from a dictionary"""
set_data = {"a": 1, "b": 2, "c": 3, "d": 4}
keys = ["b", "d"]
result = delete_keys_from_set(set_data, keys)
assert result == {"a": 1, "c": 3}
assert "b" not in result
assert "d" not in result
def test_delete_all_keys_from_dict(self):
"""Test deleting all keys from a dictionary"""
set_data = {"a": 1, "b": 2}
keys = ["a", "b"]
result = delete_keys_from_set(set_data, keys)
assert result == {}
def test_delete_nonexistent_key(self):
"""Test deleting a key that doesn't exist"""
set_data = {"a": 1, "b": 2}
keys = ["c", "d"]
result = delete_keys_from_set(set_data, keys)
assert result == {"a": 1, "b": 2}
def test_delete_keys_from_nested_dict(self):
"""Test deleting keys from nested dictionaries"""
set_data = {
"a": 1,
"b": {"c": 2, "d": 3, "e": 4},
"f": 5
}
keys = ["d", "f"]
result = delete_keys_from_set(set_data, keys)
assert result == {"a": 1, "b": {"c": 2, "e": 4}}
assert "d" not in result["b"] # type: ignore
assert "f" not in result
def test_delete_keys_from_deeply_nested_dict(self):
"""Test deleting keys from deeply nested structures"""
set_data = {
"a": 1,
"b": {
"c": 2,
"d": {
"e": 3,
"f": 4
}
},
"g": 5
}
keys = ["f", "g"]
result = delete_keys_from_set(set_data, keys)
assert result == {"a": 1, "b": {"c": 2, "d": {"e": 3}}}
assert "g" not in result
def test_delete_keys_from_list(self):
"""Test with list containing dictionaries"""
set_data = [
{"a": 1, "b": 2},
{"c": 3, "d": 4},
{"e": 5, "f": 6}
]
keys = ["b", "d", "f"]
result = delete_keys_from_set(set_data, keys)
assert result == [
{"a": 1},
{"c": 3},
{"e": 5}
]
def test_delete_keys_from_list_with_nested_dicts(self):
"""Test with list containing nested dictionaries"""
set_data = [
{"a": 1, "b": {"c": 2, "d": 3}},
{"e": 4, "f": {"g": 5, "h": 6}}
]
keys = ["d", "h"]
result = delete_keys_from_set(set_data, keys)
assert result == [
{"a": 1, "b": {"c": 2}},
{"e": 4, "f": {"g": 5}}
]
def test_delete_keys_from_dict_with_list_values(self):
"""Test with dictionary containing list values"""
set_data = {
"a": [{"b": 1, "c": 2}, {"d": 3, "e": 4}],
"f": 5
}
keys = ["c", "e"]
result = delete_keys_from_set(set_data, keys)
assert result == {
"a": [{"b": 1}, {"d": 3}],
"f": 5
}
def test_empty_keys_list(self):
"""Test with empty keys list - should return data unchanged"""
set_data = {"a": 1, "b": 2, "c": 3}
keys: list[str] = []
result = delete_keys_from_set(set_data, keys)
assert result == set_data
def test_empty_dict(self):
"""Test with empty dictionary"""
set_data: dict[str, Any] = {}
keys = ["a", "b"]
result = delete_keys_from_set(set_data, keys)
assert result == {}
def test_empty_list(self):
"""Test with empty list"""
set_data: list[Any] = []
keys = ["a", "b"]
result = delete_keys_from_set(set_data, keys)
assert result == []
def test_string_input(self):
"""Test with string input - should convert to list"""
set_data = "hello"
keys = ["a"]
result = delete_keys_from_set(set_data, keys)
assert result == ["hello"]
def test_complex_mixed_structure(self):
"""Test with complex mixed structure"""
set_data = {
"users": [
{
"name": "Alice",
"age": 30,
"password": "secret1",
"profile": {
"email": "alice@example.com",
"password": "secret2"
}
},
{
"name": "Bob",
"age": 25,
"password": "secret3",
"profile": {
"email": "bob@example.com",
"password": "secret4"
}
}
],
"metadata": {
"count": 2,
"password": "admin"
}
}
keys = ["password"]
result = delete_keys_from_set(set_data, keys)
# Check that all password fields are removed
assert "password" not in result["metadata"] # type: ignore
for user in result["users"]: # type: ignore
assert "password" not in user
assert "password" not in user["profile"]
# Check that other fields remain
assert result["users"][0]["name"] == "Alice" # type: ignore
assert result["users"][1]["name"] == "Bob" # type: ignore
assert result["metadata"]["count"] == 2 # type: ignore
def test_dict_with_none_values(self):
"""Test with dictionary containing None values"""
set_data = {"a": 1, "b": None, "c": 3}
keys = ["b"]
result = delete_keys_from_set(set_data, keys)
assert result == {"a": 1, "c": 3}
def test_dict_with_various_value_types(self):
"""Test with dictionary containing various value types"""
set_data = {
"int": 42,
"float": 3.14,
"bool": True,
"str": "hello",
"list": [1, 2, 3],
"dict": {"nested": "value"},
"none": None
}
keys = ["bool", "none"]
result = delete_keys_from_set(set_data, keys)
assert "bool" not in result
assert "none" not in result
assert len(result) == 5
class TestBuildDict:
"""Test cases for build_dict function"""
def test_build_dict_without_ignore_entries(self):
"""Test build_dict without ignore_entries (None)"""
input_dict = {"a": 1, "b": 2, "c": 3}
result = build_dict(input_dict)
assert result == input_dict
assert result is input_dict # Should return same object
def test_build_dict_with_ignore_entries_single(self):
"""Test build_dict with single ignore entry"""
input_dict = {"a": 1, "b": 2, "c": 3}
ignore = ["b"]
result = build_dict(input_dict, ignore)
assert result == {"a": 1, "c": 3}
assert "b" not in result
def test_build_dict_with_ignore_entries_multiple(self):
"""Test build_dict with multiple ignore entries"""
input_dict = {"a": 1, "b": 2, "c": 3, "d": 4}
ignore = ["b", "d"]
result = build_dict(input_dict, ignore)
assert result == {"a": 1, "c": 3}
def test_build_dict_with_nested_ignore(self):
"""Test build_dict with nested structures"""
input_dict = {
"a": 1,
"b": {"c": 2, "d": 3},
"e": 4
}
ignore = ["d", "e"]
result = build_dict(input_dict, ignore)
assert result == {"a": 1, "b": {"c": 2}}
assert "e" not in result
assert "d" not in result["b"] # type: ignore
def test_build_dict_with_empty_ignore_list(self):
"""Test build_dict with empty ignore list"""
input_dict = {"a": 1, "b": 2}
ignore: list[str] = []
result = build_dict(input_dict, ignore)
assert result == input_dict
def test_build_dict_with_nonexistent_ignore_keys(self):
"""Test build_dict with keys that don't exist"""
input_dict = {"a": 1, "b": 2}
ignore = ["c", "d"]
result = build_dict(input_dict, ignore)
assert result == {"a": 1, "b": 2}
def test_build_dict_ignore_all_keys(self):
"""Test build_dict ignoring all keys"""
input_dict = {"a": 1, "b": 2}
ignore = ["a", "b"]
result = build_dict(input_dict, ignore)
assert result == {}
def test_build_dict_with_complex_structure(self):
"""Test build_dict with complex nested structure"""
input_dict = {
"ResponseMetadata": {
"RequestId": "12345",
"HTTPStatusCode": 200,
"RetryAttempts": 0
},
"data": {
"id": 1,
"name": "Test",
"ResponseMetadata": {"internal": "value"}
},
"status": "success"
}
ignore = ["ResponseMetadata", "RetryAttempts"]
result = build_dict(input_dict, ignore)
# ResponseMetadata should be removed at all levels
assert "ResponseMetadata" not in result
assert "ResponseMetadata" not in result["data"] # type: ignore
assert result["data"]["name"] == "Test" # type: ignore
assert result["status"] == "success" # type: ignore
def test_build_dict_with_list_values(self):
"""Test build_dict with lists containing dictionaries"""
input_dict = {
"items": [
{"id": 1, "temp": "remove"},
{"id": 2, "temp": "remove"}
],
"temp": "also_remove"
}
ignore = ["temp"]
result = build_dict(input_dict, ignore)
assert "temp" not in result
assert "temp" not in result["items"][0] # type: ignore
assert "temp" not in result["items"][1] # type: ignore
assert result["items"][0]["id"] == 1 # type: ignore
assert result["items"][1]["id"] == 2 # type: ignore
def test_build_dict_empty_input(self):
"""Test build_dict with empty dictionary"""
input_dict: dict[str, Any] = {}
result = build_dict(input_dict, ["a", "b"])
assert result == {}
def test_build_dict_preserves_type_annotation(self):
"""Test that build_dict preserves proper type"""
input_dict = {"a": 1, "b": [1, 2, 3], "c": {"nested": "value"}}
result = build_dict(input_dict)
assert isinstance(result, dict)
assert isinstance(result["b"], list)
assert isinstance(result["c"], dict)
class TestSetEntry:
"""Test cases for set_entry function"""
def test_set_entry_new_key(self):
"""Test setting a new key in dictionary"""
dict_set: dict[str, Any] = {}
key = "new_key"
value = "new_value"
result = set_entry(dict_set, key, value)
assert result[key] == value
assert len(result) == 1
def test_set_entry_existing_key(self):
"""Test overwriting an existing key"""
dict_set = {"key": "old_value"}
key = "key"
value = "new_value"
result = set_entry(dict_set, key, value)
assert result[key] == value
assert result[key] != "old_value"
def test_set_entry_with_dict_value(self):
"""Test setting a dictionary as value"""
dict_set: dict[str, Any] = {}
key = "config"
value = {"setting1": True, "setting2": "value"}
result = set_entry(dict_set, key, value)
assert result[key] == value
assert isinstance(result[key], dict)
def test_set_entry_with_list_value(self):
"""Test setting a list as value"""
dict_set: dict[str, Any] = {}
key = "items"
value = [1, 2, 3, 4]
result = set_entry(dict_set, key, value)
assert result[key] == value
assert isinstance(result[key], list)
def test_set_entry_with_none_value(self):
"""Test setting None as value"""
dict_set: dict[str, Any] = {}
key = "nullable"
value = None
result = set_entry(dict_set, key, value)
assert result[key] is None
assert key in result
def test_set_entry_with_integer_value(self):
"""Test setting integer value"""
dict_set: dict[str, Any] = {}
key = "count"
value = 42
result = set_entry(dict_set, key, value)
assert result[key] == 42
assert isinstance(result[key], int)
def test_set_entry_with_float_value(self):
"""Test setting float value"""
dict_set: dict[str, Any] = {}
key = "price"
value = 19.99
result = set_entry(dict_set, key, value)
assert result[key] == 19.99
assert isinstance(result[key], float)
def test_set_entry_with_boolean_value(self):
"""Test setting boolean value"""
dict_set: dict[str, Any] = {}
key = "enabled"
value = True
result = set_entry(dict_set, key, value)
assert result[key] is True
assert isinstance(result[key], bool)
def test_set_entry_multiple_times(self):
"""Test setting multiple entries"""
dict_set: dict[str, Any] = {}
set_entry(dict_set, "key1", "value1")
set_entry(dict_set, "key2", "value2")
set_entry(dict_set, "key3", "value3")
assert len(dict_set) == 3
assert dict_set["key1"] == "value1"
assert dict_set["key2"] == "value2"
assert dict_set["key3"] == "value3"
def test_set_entry_overwrites_existing(self):
"""Test that setting an existing key overwrites it"""
dict_set = {"key": {"old": "data"}}
value = {"new": "data"}
result = set_entry(dict_set, "key", value)
assert result["key"] == {"new": "data"}
assert "old" not in result["key"]
def test_set_entry_modifies_original_dict(self):
"""Test that set_entry modifies the original dictionary"""
dict_set: dict[str, Any] = {}
result = set_entry(dict_set, "key", "value")
assert result is dict_set
assert dict_set["key"] == "value"
def test_set_entry_with_empty_string_value(self):
"""Test setting empty string as value"""
dict_set: dict[str, Any] = {}
key = "empty"
value = ""
result = set_entry(dict_set, key, value)
assert result[key] == ""
assert key in result
def test_set_entry_with_complex_nested_structure(self):
"""Test setting complex nested structure"""
dict_set: dict[str, Any] = {}
key = "complex"
value = {
"level1": {
"level2": {
"level3": ["a", "b", "c"]
}
}
}
result = set_entry(dict_set, key, value)
assert result[key]["level1"]["level2"]["level3"] == ["a", "b", "c"]
# Parametrized tests for more comprehensive coverage
class TestParametrized:
"""Parametrized tests for better coverage"""
@pytest.mark.parametrize("set_data,keys,expected", [
({"a": 1, "b": 2}, ["b"], {"a": 1}),
({"a": 1, "b": 2, "c": 3}, ["a", "c"], {"b": 2}),
({"a": 1}, ["a"], {}),
({"a": 1, "b": 2}, ["c"], {"a": 1, "b": 2}),
({}, ["a"], {}),
({"a": {"b": 1, "c": 2}}, ["c"], {"a": {"b": 1}}),
])
def test_delete_keys_parametrized(
self,
set_data: dict[str, Any],
keys: list[str],
expected: dict[str, Any]
):
"""Test delete_keys_from_set with various inputs"""
result = delete_keys_from_set(set_data, keys)
assert result == expected
@pytest.mark.parametrize("input_dict,ignore,expected", [
({"a": 1, "b": 2}, ["b"], {"a": 1}),
({"a": 1, "b": 2}, ["c"], {"a": 1, "b": 2}),
({"a": 1, "b": 2}, [], {"a": 1, "b": 2}),
({"a": 1}, ["a"], {}),
({}, ["a"], {}),
])
def test_build_dict_parametrized(
self,
input_dict: dict[str, Any],
ignore: list[str],
expected: dict[str, Any]
):
"""Test build_dict with various inputs"""
result = build_dict(input_dict, ignore)
assert result == expected
@pytest.mark.parametrize("key,value", [
("string_key", "string_value"),
("int_key", 42),
("float_key", 3.14),
("bool_key", True),
("list_key", [1, 2, 3]),
("dict_key", {"nested": "value"}),
("none_key", None),
("empty_key", ""),
("zero_key", 0),
("false_key", False),
])
def test_set_entry_parametrized(self, key: str, value: Any):
"""Test set_entry with various value types"""
dict_set: dict[str, Any] = {}
result = set_entry(dict_set, key, value)
assert result[key] == value
# Edge cases and integration tests
class TestEdgeCases:
"""Test edge cases and special scenarios"""
def test_delete_keys_preserves_modification(self):
"""Test that original dict is modified"""
set_data = {"a": 1, "b": 2, "c": 3}
keys = ["b"]
result = delete_keys_from_set(set_data, keys)
# The function modifies the original dict
assert result is set_data
assert "b" not in set_data
def test_build_dict_with_aws_typedef_scenario(self):
"""Test build_dict mimicking AWS TypedDict usage"""
# Simulating AWS response with ResponseMetadata
aws_response: dict[str, Any] = {
"Items": [
{"id": "1", "name": "Item1"},
{"id": "2", "name": "Item2"}
],
"Count": 2,
"ScannedCount": 2,
"ResponseMetadata": {
"RequestId": "abc123",
"HTTPStatusCode": 200,
"HTTPHeaders": {},
"RetryAttempts": 0
}
}
result = build_dict(aws_response, ["ResponseMetadata"])
assert "ResponseMetadata" not in result
assert result["Count"] == 2 # type: ignore
assert len(result["Items"]) == 2 # type: ignore
def test_set_entry_idempotency(self):
"""Test that calling set_entry multiple times with same value is idempotent"""
dict_set: dict[str, Any] = {}
value = "test_value"
result1 = set_entry(dict_set, "key", value)
result2 = set_entry(dict_set, "key", value)
result3 = set_entry(dict_set, "key", value)
assert result1 is result2 is result3
assert result1["key"] == value
assert len(result1) == 1
def test_delete_keys_with_circular_reference_protection(self):
"""Test that function handles normal cases without circular issues"""
# Python dicts can't have true circular references easily
# but we can test deep nesting
set_data = {
"level1": {
"level2": {
"level3": {
"level4": {
"data": "value",
"remove": "this"
}
}
}
}
}
keys = ["remove"]
result = delete_keys_from_set(set_data, keys)
assert "remove" not in result["level1"]["level2"]["level3"]["level4"] # type: ignore
assert result["level1"]["level2"]["level3"]["level4"]["data"] == "value" # type: ignore
def test_build_dict_none_ignore_vs_empty_ignore(self):
"""Test difference between None and empty list for ignore_entries"""
input_dict = {"a": 1, "b": 2}
result_none = build_dict(input_dict, None)
result_empty = build_dict(input_dict, [])
assert result_none == input_dict
assert result_empty == input_dict
# With None, it returns the same object
assert result_none is input_dict
# With empty list, it goes through delete_keys_from_set
assert result_empty is input_dict
# Integration tests
class TestIntegration:
"""Integration tests combining multiple functions"""
def test_build_dict_then_set_entry(self):
"""Test using build_dict followed by set_entry"""
original = {
"a": 1,
"b": 2,
"remove_me": "gone"
}
cleaned = build_dict(original, ["remove_me"])
result = set_entry(cleaned, "c", 3)
assert result == {"a": 1, "b": 2, "c": 3}
assert "remove_me" not in result
def test_delete_keys_then_set_entry(self):
"""Test using delete_keys_from_set followed by set_entry"""
data = {"a": 1, "b": 2, "c": 3}
cleaned = delete_keys_from_set(data, ["b"])
result = set_entry(cleaned, "d", 4) # type: ignore
assert result == {"a": 1, "c": 3, "d": 4}
def test_multiple_operations_chain(self):
"""Test chaining multiple operations"""
data = {
"user": {
"name": "Alice",
"password": "secret",
"email": "alice@example.com"
},
"metadata": {
"created": "2024-01-01",
"password": "admin"
}
}
# Remove passwords
cleaned = build_dict(data, ["password"])
# Add new field
result = set_entry(cleaned, "processed", True)
assert "password" not in result["user"] # type: ignore
assert "password" not in result["metadata"] # type: ignore
assert result["processed"] is True # type: ignore
assert result["user"]["name"] == "Alice" # type: ignore
# __END__

View File

@@ -0,0 +1,291 @@
"""
tests for corelibs.iterator_handling.dict_helpers
"""
from typing import Any
import pytest
from corelibs.iterator_handling.dict_mask 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,361 @@
"""
tests for corelibs.iterator_handling.fingerprint
"""
from typing import Any
import pytest
from corelibs.iterator_handling.fingerprint import dict_hash_frozen, dict_hash_crc
class TestDictHashFrozen:
"""Tests for dict_hash_frozen function"""
def test_dict_hash_frozen_simple_dict(self):
"""Test hashing a simple dictionary"""
data = {"key1": "value1", "key2": "value2"}
result = dict_hash_frozen(data)
assert isinstance(result, int)
assert result != 0
def test_dict_hash_frozen_consistency(self):
"""Test that same dict produces same hash"""
data = {"name": "John", "age": 30, "city": "Tokyo"}
hash1 = dict_hash_frozen(data)
hash2 = dict_hash_frozen(data)
assert hash1 == hash2
def test_dict_hash_frozen_order_independence(self):
"""Test that dict order doesn't affect hash"""
data1 = {"a": 1, "b": 2, "c": 3}
data2 = {"c": 3, "a": 1, "b": 2}
hash1 = dict_hash_frozen(data1)
hash2 = dict_hash_frozen(data2)
assert hash1 == hash2
def test_dict_hash_frozen_empty_dict(self):
"""Test hashing an empty dictionary"""
data: dict[Any, Any] = {}
result = dict_hash_frozen(data)
assert isinstance(result, int)
def test_dict_hash_frozen_different_dicts(self):
"""Test that different dicts produce different hashes"""
data1 = {"key1": "value1"}
data2 = {"key2": "value2"}
hash1 = dict_hash_frozen(data1)
hash2 = dict_hash_frozen(data2)
assert hash1 != hash2
def test_dict_hash_frozen_various_types(self):
"""Test hashing dict with various value types"""
data = {
"string": "value",
"int": 42,
"float": 3.14,
"bool": True,
"none": None
}
result = dict_hash_frozen(data)
assert isinstance(result, int)
def test_dict_hash_frozen_numeric_keys(self):
"""Test hashing dict with numeric keys"""
data = {1: "one", 2: "two", 3: "three"}
result = dict_hash_frozen(data)
assert isinstance(result, int)
def test_dict_hash_frozen_tuple_values(self):
"""Test hashing dict with tuple values"""
data = {"coord1": (1, 2), "coord2": (3, 4)}
result = dict_hash_frozen(data)
assert isinstance(result, int)
def test_dict_hash_frozen_value_change_changes_hash(self):
"""Test that changing a value changes the hash"""
data1 = {"key": "value1"}
data2 = {"key": "value2"}
hash1 = dict_hash_frozen(data1)
hash2 = dict_hash_frozen(data2)
assert hash1 != hash2
class TestDictHashCrc:
"""Tests for dict_hash_crc function"""
def test_dict_hash_crc_simple_dict(self):
"""Test hashing a simple dictionary"""
data = {"key1": "value1", "key2": "value2"}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64 # SHA256 produces 64 hex characters
def test_dict_hash_crc_simple_list(self):
"""Test hashing a simple list"""
data = ["item1", "item2", "item3"]
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_consistency_dict(self):
"""Test that same dict produces same hash"""
data = {"name": "John", "age": 30, "city": "Tokyo"}
hash1 = dict_hash_crc(data)
hash2 = dict_hash_crc(data)
assert hash1 == hash2
def test_dict_hash_crc_consistency_list(self):
"""Test that same list produces same hash"""
data = [1, 2, 3, 4, 5]
hash1 = dict_hash_crc(data)
hash2 = dict_hash_crc(data)
assert hash1 == hash2
def test_dict_hash_crc_order_independence_dict(self):
"""Test that dict order doesn't affect hash (sort_keys=True)"""
data1 = {"a": 1, "b": 2, "c": 3}
data2 = {"c": 3, "a": 1, "b": 2}
hash1 = dict_hash_crc(data1)
hash2 = dict_hash_crc(data2)
assert hash1 == hash2
def test_dict_hash_crc_order_dependence_list(self):
"""Test that list order affects hash"""
data1 = [1, 2, 3]
data2 = [3, 2, 1]
hash1 = dict_hash_crc(data1)
hash2 = dict_hash_crc(data2)
assert hash1 != hash2
def test_dict_hash_crc_empty_dict(self):
"""Test hashing an empty dictionary"""
data: dict[Any, Any] = {}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_empty_list(self):
"""Test hashing an empty list"""
data: list[Any] = []
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_different_dicts(self):
"""Test that different dicts produce different hashes"""
data1 = {"key1": "value1"}
data2 = {"key2": "value2"}
hash1 = dict_hash_crc(data1)
hash2 = dict_hash_crc(data2)
assert hash1 != hash2
def test_dict_hash_crc_different_lists(self):
"""Test that different lists produce different hashes"""
data1 = ["item1", "item2"]
data2 = ["item3", "item4"]
hash1 = dict_hash_crc(data1)
hash2 = dict_hash_crc(data2)
assert hash1 != hash2
def test_dict_hash_crc_nested_dict(self):
"""Test hashing nested dictionaries"""
data = {
"user": {
"name": "John",
"address": {
"city": "Tokyo",
"country": "Japan"
}
}
}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_nested_list(self):
"""Test hashing nested lists"""
data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_mixed_nested(self):
"""Test hashing mixed nested structures"""
data = {
"items": [1, 2, 3],
"meta": {
"count": 3,
"tags": ["a", "b", "c"]
}
}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_various_types_dict(self):
"""Test hashing dict with various value types"""
data = {
"string": "value",
"int": 42,
"float": 3.14,
"bool": True,
"none": None,
"list": [1, 2, 3],
"nested_dict": {"inner": "value"}
}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_various_types_list(self):
"""Test hashing list with various value types"""
data = ["string", 42, 3.14, True, None, [1, 2], {"key": "value"}]
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_value_change_changes_hash(self):
"""Test that changing a value changes the hash"""
data1 = {"key": "value1"}
data2 = {"key": "value2"}
hash1 = dict_hash_crc(data1)
hash2 = dict_hash_crc(data2)
assert hash1 != hash2
def test_dict_hash_crc_hex_format(self):
"""Test that hash is in hexadecimal format"""
data = {"test": "data"}
result = dict_hash_crc(data)
# All characters should be valid hex
assert all(c in "0123456789abcdef" for c in result)
def test_dict_hash_crc_unicode_handling(self):
"""Test hashing dict with unicode characters"""
data = {
"japanese": "日本語",
"emoji": "🎉",
"chinese": "中文"
}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
def test_dict_hash_crc_special_characters(self):
"""Test hashing dict with special characters"""
data = {
"quotes": "\"quoted\"",
"newline": "line1\nline2",
"tab": "col1\tcol2",
"backslash": "path\\to\\file"
}
result = dict_hash_crc(data)
assert isinstance(result, str)
assert len(result) == 64
class TestComparisonBetweenHashFunctions:
"""Tests comparing dict_hash_frozen and dict_hash_crc"""
def test_both_functions_are_deterministic(self):
"""Test that both functions produce consistent results"""
data = {"a": 1, "b": 2, "c": 3}
frozen_hash1 = dict_hash_frozen(data)
frozen_hash2 = dict_hash_frozen(data)
crc_hash1 = dict_hash_crc(data)
crc_hash2 = dict_hash_crc(data)
assert frozen_hash1 == frozen_hash2
assert crc_hash1 == crc_hash2
def test_both_functions_handle_empty_dict(self):
"""Test that both functions can hash empty dict"""
data: dict[Any, Any] = {}
frozen_result = dict_hash_frozen(data)
crc_result = dict_hash_crc(data)
assert isinstance(frozen_result, int)
assert isinstance(crc_result, str)
def test_both_functions_detect_changes(self):
"""Test that both functions detect value changes"""
data1 = {"key": "value1"}
data2 = {"key": "value2"}
frozen_hash1 = dict_hash_frozen(data1)
frozen_hash2 = dict_hash_frozen(data2)
crc_hash1 = dict_hash_crc(data1)
crc_hash2 = dict_hash_crc(data2)
assert frozen_hash1 != frozen_hash2
assert crc_hash1 != crc_hash2
def test_both_functions_handle_order_independence(self):
"""Test that both functions are order-independent for dicts"""
data1 = {"x": 10, "y": 20, "z": 30}
data2 = {"z": 30, "x": 10, "y": 20}
frozen_hash1 = dict_hash_frozen(data1)
frozen_hash2 = dict_hash_frozen(data2)
crc_hash1 = dict_hash_crc(data1)
crc_hash2 = dict_hash_crc(data2)
assert frozen_hash1 == frozen_hash2
assert crc_hash1 == crc_hash2
@pytest.mark.parametrize("data,expected_type,expected_length", [
({"key": "value"}, str, 64),
([1, 2, 3], str, 64),
({"nested": {"key": "value"}}, str, 64),
([[1, 2], [3, 4]], str, 64),
({}, str, 64),
([], str, 64),
])
def test_dict_hash_crc_parametrized(data: dict[Any, Any] | list[Any], expected_type: type, expected_length: int):
"""Parametrized test for dict_hash_crc with various inputs"""
result = dict_hash_crc(data)
assert isinstance(result, expected_type)
assert len(result) == expected_length
@pytest.mark.parametrize("data", [
{"key": "value"},
{"a": 1, "b": 2},
{"x": 10, "y": 20, "z": 30},
{},
])
def test_dict_hash_frozen_parametrized(data: dict[Any, Any]):
"""Parametrized test for dict_hash_frozen with various inputs"""
result = dict_hash_frozen(data)
assert isinstance(result, int)

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[int]()),
([], [1, 2, 3], set[int]()),
([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,3 @@
"""
tests for json_handling module
"""

Some files were not shown because too many files have changed in this diff Show More