edq-utils 0.1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- edq/__init__.py +5 -0
- edq/cli/__init__.py +0 -0
- edq/cli/config/__init__.py +3 -0
- edq/cli/config/list.py +69 -0
- edq/cli/http/__init__.py +3 -0
- edq/cli/http/exchange-server.py +71 -0
- edq/cli/http/send-exchange.py +45 -0
- edq/cli/http/verify-exchanges.py +38 -0
- edq/cli/testing/__init__.py +3 -0
- edq/cli/testing/cli-test.py +49 -0
- edq/cli/version.py +28 -0
- edq/core/__init__.py +0 -0
- edq/core/argparser.py +137 -0
- edq/core/argparser_test.py +124 -0
- edq/core/config.py +268 -0
- edq/core/config_test.py +1038 -0
- edq/core/log.py +101 -0
- edq/core/version.py +6 -0
- edq/procedure/__init__.py +0 -0
- edq/procedure/verify_exchanges.py +85 -0
- edq/py.typed +0 -0
- edq/testing/__init__.py +3 -0
- edq/testing/asserts.py +65 -0
- edq/testing/cli.py +360 -0
- edq/testing/cli_test.py +15 -0
- edq/testing/httpserver.py +578 -0
- edq/testing/httpserver_test.py +424 -0
- edq/testing/run.py +142 -0
- edq/testing/testdata/cli/data/configs/empty/edq-config.json +1 -0
- edq/testing/testdata/cli/data/configs/simple-1/edq-config.json +4 -0
- edq/testing/testdata/cli/data/configs/simple-2/edq-config.json +3 -0
- edq/testing/testdata/cli/data/configs/value-number/edq-config.json +3 -0
- edq/testing/testdata/cli/tests/config/list/config_list_base.txt +16 -0
- edq/testing/testdata/cli/tests/config/list/config_list_config_value_number.txt +10 -0
- edq/testing/testdata/cli/tests/config/list/config_list_ignore_config.txt +14 -0
- edq/testing/testdata/cli/tests/config/list/config_list_no_config.txt +8 -0
- edq/testing/testdata/cli/tests/config/list/config_list_show_origin.txt +13 -0
- edq/testing/testdata/cli/tests/config/list/config_list_skip_header.txt +10 -0
- edq/testing/testdata/cli/tests/help_base.txt +9 -0
- edq/testing/testdata/cli/tests/platform_skip.txt +5 -0
- edq/testing/testdata/cli/tests/version_base.txt +6 -0
- edq/testing/testdata/http/exchanges/simple.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
- edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
- edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
- edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
- edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
- edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
- edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
- edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
- edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
- edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
- edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
- edq/testing/testdata/http/files/a.txt +1 -0
- edq/testing/testdata/http/files/tiny.png +0 -0
- edq/testing/unittest.py +88 -0
- edq/util/__init__.py +3 -0
- edq/util/dirent.py +340 -0
- edq/util/dirent_test.py +979 -0
- edq/util/encoding.py +18 -0
- edq/util/hash.py +41 -0
- edq/util/hash_test.py +89 -0
- edq/util/json.py +180 -0
- edq/util/json_test.py +228 -0
- edq/util/net.py +1008 -0
- edq/util/parse.py +33 -0
- edq/util/pyimport.py +94 -0
- edq/util/pyimport_test.py +119 -0
- edq/util/reflection.py +32 -0
- edq/util/time.py +75 -0
- edq/util/time_test.py +107 -0
- edq_utils-0.1.9.dist-info/METADATA +164 -0
- edq_utils-0.1.9.dist-info/RECORD +83 -0
- edq_utils-0.1.9.dist-info/WHEEL +5 -0
- edq_utils-0.1.9.dist-info/licenses/LICENSE +21 -0
- edq_utils-0.1.9.dist-info/top_level.txt +1 -0
edq/util/parse.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
BOOL_TRUE_STRINGS: typing.Set[str] = {
|
|
4
|
+
'true', 't',
|
|
5
|
+
'yes', 'y',
|
|
6
|
+
'1',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
BOOL_FALSE_STRINGS: typing.Set[str] = {
|
|
10
|
+
'false', 'f',
|
|
11
|
+
'no', 'n',
|
|
12
|
+
'0',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def boolean(raw_text: typing.Union[str, bool]) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Parse a boolean from a string using common string representations for true/false.
|
|
18
|
+
This function assumes the entire string is the boolean (not just a part of it).
|
|
19
|
+
If the string is not true or false, then raise an exception.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
if (isinstance(raw_text, bool)):
|
|
23
|
+
return raw_text
|
|
24
|
+
|
|
25
|
+
text = str(raw_text).lower().strip()
|
|
26
|
+
|
|
27
|
+
if (text in BOOL_TRUE_STRINGS):
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
if (text in BOOL_FALSE_STRINGS):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
raise ValueError(f"Could not convert text to boolean: '{raw_text}'.")
|
edq/util/pyimport.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import os
|
|
4
|
+
import typing
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
import edq.util.dirent
|
|
8
|
+
|
|
9
|
+
_import_cache: typing.Dict[str, typing.Any] = {}
|
|
10
|
+
""" A cache to help avoid importing a module multiple times. """
|
|
11
|
+
|
|
12
|
+
def import_path(raw_path: str, cache: bool = True, module_name: typing.Union[str, None] = None) -> typing.Any:
|
|
13
|
+
"""
|
|
14
|
+
Import a module from a file.
|
|
15
|
+
If cache is false, then the module will not be fetched or stored in this module's cache.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
path = os.path.abspath(raw_path)
|
|
19
|
+
cache_key = f"PATH::{path}"
|
|
20
|
+
|
|
21
|
+
# Check the cache before importing.
|
|
22
|
+
if (cache):
|
|
23
|
+
module = _import_cache.get(cache_key, None)
|
|
24
|
+
if (module is not None):
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
if (not edq.util.dirent.exists(path)):
|
|
28
|
+
raise ValueError(f"Module path does not exist: '{raw_path}'.")
|
|
29
|
+
|
|
30
|
+
if (not os.path.isfile(path)):
|
|
31
|
+
raise ValueError(f"Module path is not a file: '{raw_path}'.")
|
|
32
|
+
|
|
33
|
+
if (module_name is None):
|
|
34
|
+
module_name = str(uuid.uuid4()).replace('-', '')
|
|
35
|
+
|
|
36
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
37
|
+
if ((spec is None) or (spec.loader is None)):
|
|
38
|
+
raise ValueError(f"Failed to load module specification for path: '{raw_path}'.")
|
|
39
|
+
|
|
40
|
+
module = importlib.util.module_from_spec(spec)
|
|
41
|
+
spec.loader.exec_module(module)
|
|
42
|
+
|
|
43
|
+
# Store the module in the cache.
|
|
44
|
+
if (cache):
|
|
45
|
+
_import_cache[cache_key] = module
|
|
46
|
+
|
|
47
|
+
return module
|
|
48
|
+
|
|
49
|
+
def import_name(module_name: str, cache: bool = True) -> typing.Any:
|
|
50
|
+
"""
|
|
51
|
+
Import a module from a name.
|
|
52
|
+
The module must already be in the system's path (sys.path).
|
|
53
|
+
If cache is false, then the module will not be fetched or stored in this module's cache.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
cache_key = f"NAME::{module_name}"
|
|
57
|
+
|
|
58
|
+
# Check the cache before importing.
|
|
59
|
+
if (cache):
|
|
60
|
+
module = _import_cache.get(cache_key, None)
|
|
61
|
+
if (module is not None):
|
|
62
|
+
return module
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
module = importlib.import_module(module_name)
|
|
66
|
+
except ImportError as ex:
|
|
67
|
+
raise ValueError(f"Unable to locate module '{module_name}'.") from ex
|
|
68
|
+
|
|
69
|
+
# Store the module in the cache.
|
|
70
|
+
if (cache):
|
|
71
|
+
_import_cache[cache_key] = module
|
|
72
|
+
|
|
73
|
+
return module
|
|
74
|
+
|
|
75
|
+
def fetch(name: str) -> typing.Any:
|
|
76
|
+
"""
|
|
77
|
+
Fetch an entity inside of a module.
|
|
78
|
+
Note that the target is not a module, but an attribute/object inside of the module.
|
|
79
|
+
The provided name should be fully qualified.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
parts = name.strip().rsplit('.', 1)
|
|
83
|
+
if (len(parts) != 2):
|
|
84
|
+
raise ValueError(f"Target name of fetch must be fully qualified, got '{name}'.")
|
|
85
|
+
|
|
86
|
+
module_name = parts[0]
|
|
87
|
+
short_name = parts[1]
|
|
88
|
+
|
|
89
|
+
module = import_name(module_name)
|
|
90
|
+
|
|
91
|
+
if (not hasattr(module, short_name)):
|
|
92
|
+
raise ValueError(f"Module '{module_name}' does not have attribute '{short_name}'.")
|
|
93
|
+
|
|
94
|
+
return getattr(module, short_name)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import edq.testing.unittest
|
|
4
|
+
import edq.util.pyimport
|
|
5
|
+
|
|
6
|
+
THIS_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
|
|
7
|
+
PACKAGE_ROOT_DIR = os.path.join(THIS_DIR, '..')
|
|
8
|
+
|
|
9
|
+
class TestPyImport(edq.testing.unittest.BaseTest):
|
|
10
|
+
""" Test Python importing operations. """
|
|
11
|
+
|
|
12
|
+
def test_import_path_base(self):
|
|
13
|
+
""" Test importing a module from a path. """
|
|
14
|
+
|
|
15
|
+
# [(relative path, error substring), ...]
|
|
16
|
+
# All paths are relative to the package root.
|
|
17
|
+
test_cases = [
|
|
18
|
+
# Standard Module
|
|
19
|
+
(os.path.join('util', 'pyimport.py'), None),
|
|
20
|
+
|
|
21
|
+
# Errors
|
|
22
|
+
('ZZZ', 'Module path does not exist'),
|
|
23
|
+
('util', 'Module path is not a file'),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
for (i, test_case) in enumerate(test_cases):
|
|
27
|
+
(relpath, error_substring) = test_case
|
|
28
|
+
|
|
29
|
+
with self.subTest(msg = f"Case {i} ('{relpath}'):"):
|
|
30
|
+
path = os.path.join(PACKAGE_ROOT_DIR, relpath)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
module = edq.util.pyimport.import_path(path)
|
|
34
|
+
except Exception as ex:
|
|
35
|
+
error_string = self.format_error_string(ex)
|
|
36
|
+
if (error_substring is None):
|
|
37
|
+
self.fail(f"Unexpected error: '{error_string}'.")
|
|
38
|
+
|
|
39
|
+
self.assertIn(error_substring, error_string, 'Error is not as expected.')
|
|
40
|
+
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
if (error_substring is not None):
|
|
44
|
+
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
45
|
+
|
|
46
|
+
self.assertIsNotNone(module)
|
|
47
|
+
|
|
48
|
+
def test_import_name_base(self):
|
|
49
|
+
""" Test importing a module from a name. """
|
|
50
|
+
|
|
51
|
+
# [(name, error substring), ...]
|
|
52
|
+
test_cases = [
|
|
53
|
+
# Standard Module
|
|
54
|
+
('edq.util.pyimport', None),
|
|
55
|
+
|
|
56
|
+
# Package (__init__.py)
|
|
57
|
+
('edq.util', None),
|
|
58
|
+
|
|
59
|
+
# Errors
|
|
60
|
+
('', 'Empty module name'),
|
|
61
|
+
('edq.util.ZZZ', 'Unable to locate module'),
|
|
62
|
+
('edq.util.pyimport.ZZZ', 'Unable to locate module'),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
for (i, test_case) in enumerate(test_cases):
|
|
66
|
+
(name, error_substring) = test_case
|
|
67
|
+
|
|
68
|
+
with self.subTest(msg = f"Case {i} ('{name}'):"):
|
|
69
|
+
try:
|
|
70
|
+
module = edq.util.pyimport.import_name(name)
|
|
71
|
+
except Exception as ex:
|
|
72
|
+
error_string = self.format_error_string(ex)
|
|
73
|
+
if (error_substring is None):
|
|
74
|
+
self.fail(f"Unexpected error: '{error_string}'.")
|
|
75
|
+
|
|
76
|
+
self.assertIn(error_substring, error_string, 'Error is not as expected.')
|
|
77
|
+
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if (error_substring is not None):
|
|
81
|
+
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
82
|
+
|
|
83
|
+
self.assertIsNotNone(module)
|
|
84
|
+
|
|
85
|
+
def test_fetch_base(self):
|
|
86
|
+
""" Test fetching an attribute from a module. """
|
|
87
|
+
|
|
88
|
+
# [(name, error substring), ...]
|
|
89
|
+
test_cases = [
|
|
90
|
+
# Standard Module
|
|
91
|
+
('edq.util.pyimport.fetch', None),
|
|
92
|
+
|
|
93
|
+
# Errors
|
|
94
|
+
('', 'Target name of fetch must be fully qualified'),
|
|
95
|
+
('edq', 'Target name of fetch must be fully qualified'),
|
|
96
|
+
('ZZZ.aaa', 'Unable to locate module'),
|
|
97
|
+
('edq.ZZZ.aaa', 'Unable to locate module'),
|
|
98
|
+
('edq.util.pyimport.ZZZ', 'does not have attribute'),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for (i, test_case) in enumerate(test_cases):
|
|
102
|
+
(name, error_substring) = test_case
|
|
103
|
+
|
|
104
|
+
with self.subTest(msg = f"Case {i} ('{name}'):"):
|
|
105
|
+
try:
|
|
106
|
+
target = edq.util.pyimport.fetch(name)
|
|
107
|
+
except Exception as ex:
|
|
108
|
+
error_string = self.format_error_string(ex)
|
|
109
|
+
if (error_substring is None):
|
|
110
|
+
self.fail(f"Unexpected error: '{error_string}'.")
|
|
111
|
+
|
|
112
|
+
self.assertIn(error_substring, error_string, 'Error is not as expected.')
|
|
113
|
+
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if (error_substring is not None):
|
|
117
|
+
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
118
|
+
|
|
119
|
+
self.assertIsNotNone(target)
|
edq/util/reflection.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
def get_qualified_name(target: typing.Union[type, object, str]) -> str:
|
|
4
|
+
"""
|
|
5
|
+
Try to get a qualified name for a type (or for the type of an object).
|
|
6
|
+
Names will not always come out clean.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Get the type for this target.
|
|
10
|
+
if (isinstance(target, type)):
|
|
11
|
+
target_class = target
|
|
12
|
+
elif (callable(target)):
|
|
13
|
+
target_class = typing.cast(type, target)
|
|
14
|
+
else:
|
|
15
|
+
target_class = type(target)
|
|
16
|
+
|
|
17
|
+
# Check for various name components.
|
|
18
|
+
parts = []
|
|
19
|
+
|
|
20
|
+
if (hasattr(target_class, '__module__')):
|
|
21
|
+
parts.append(str(getattr(target_class, '__module__')))
|
|
22
|
+
|
|
23
|
+
if (hasattr(target_class, '__qualname__')):
|
|
24
|
+
parts.append(str(getattr(target_class, '__qualname__')))
|
|
25
|
+
elif (hasattr(target_class, '__name__')):
|
|
26
|
+
parts.append(str(getattr(target_class, '__name__')))
|
|
27
|
+
|
|
28
|
+
# Fall back to just the string reprsentation.
|
|
29
|
+
if (len(parts) == 0):
|
|
30
|
+
return str(target_class)
|
|
31
|
+
|
|
32
|
+
return '.'.join(parts)
|
edq/util/time.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
PRETTY_SHORT_FORMAT: str = '%Y-%m-%d %H:%M'
|
|
5
|
+
|
|
6
|
+
class Duration(int):
|
|
7
|
+
"""
|
|
8
|
+
A Duration represents some length in time in milliseconds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def to_secs(self) -> float:
|
|
12
|
+
""" Convert the duration to float seconds. """
|
|
13
|
+
|
|
14
|
+
return self / 1000.0
|
|
15
|
+
|
|
16
|
+
def to_msecs(self) -> int:
|
|
17
|
+
""" Convert the duration to integer milliseconds. """
|
|
18
|
+
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
class Timestamp(int):
|
|
22
|
+
"""
|
|
23
|
+
A Timestamp represent a moment in time (sometimes called "datetimes").
|
|
24
|
+
Timestamps are internally represented by the number of milliseconds since the
|
|
25
|
+
(Unix Epoch)[https://en.wikipedia.org/wiki/Unix_time].
|
|
26
|
+
This is sometimes referred to as "Unix Time".
|
|
27
|
+
Since Unix Time is in UTC, timestamps do not need to carry timestamp information with them.
|
|
28
|
+
|
|
29
|
+
Note that timestamps are just integers with some decoration,
|
|
30
|
+
so they respond to all normal int functionality.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def sub(self, other: 'Timestamp') -> Duration:
|
|
34
|
+
""" Return a new duration that is the difference of this and the given duration. """
|
|
35
|
+
|
|
36
|
+
return Duration(self - other)
|
|
37
|
+
|
|
38
|
+
def to_pytime(self, timezone: datetime.timezone = datetime.timezone.utc) -> datetime.datetime:
|
|
39
|
+
""" Convert this timestamp to a Python datetime in the given timezone (UTC by default). """
|
|
40
|
+
|
|
41
|
+
return datetime.datetime.fromtimestamp(self / 1000, timezone)
|
|
42
|
+
|
|
43
|
+
def to_local_pytime(self) -> datetime.datetime:
|
|
44
|
+
""" Convert this timestamp to a Python datetime in the system timezone. """
|
|
45
|
+
|
|
46
|
+
local_timezone = datetime.datetime.now().astimezone().tzinfo
|
|
47
|
+
if ((local_timezone is None) or (not isinstance(local_timezone, datetime.timezone))):
|
|
48
|
+
raise ValueError("Could not discover local timezone.")
|
|
49
|
+
|
|
50
|
+
return self.to_pytime(timezone = local_timezone)
|
|
51
|
+
|
|
52
|
+
def pretty(self, short: bool = False, timezone: datetime.timezone = datetime.timezone.utc) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Get a "pretty" string representation of this timestamp.
|
|
55
|
+
There is no guarantee that this representation can be parsed back to its original form.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
pytime = self.to_pytime(timezone = timezone)
|
|
59
|
+
|
|
60
|
+
if (short):
|
|
61
|
+
return pytime.strftime(PRETTY_SHORT_FORMAT)
|
|
62
|
+
|
|
63
|
+
return pytime.isoformat(timespec = 'milliseconds')
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def from_pytime(pytime: datetime.datetime) -> 'Timestamp':
|
|
67
|
+
""" Convert a Python datetime to a timestamp. """
|
|
68
|
+
|
|
69
|
+
return Timestamp(int(pytime.timestamp() * 1000))
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def now() -> 'Timestamp':
|
|
73
|
+
""" Get a Timestamp that represents the current moment. """
|
|
74
|
+
|
|
75
|
+
return Timestamp(time.time() * 1000)
|
edq/util/time_test.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import datetime
|
|
3
|
+
|
|
4
|
+
import edq.testing.unittest
|
|
5
|
+
import edq.util.time
|
|
6
|
+
|
|
7
|
+
TIMEZONE_UTC: datetime.timezone = datetime.timezone.utc
|
|
8
|
+
TIMEZONE_PST: datetime.timezone = datetime.timezone(datetime.timedelta(hours = -7), name = 'PST')
|
|
9
|
+
TIMEZONE_CEST: datetime.timezone = datetime.timezone(datetime.timedelta(hours = 2), name = 'CEST')
|
|
10
|
+
|
|
11
|
+
class TestTime(edq.testing.unittest.BaseTest):
|
|
12
|
+
""" Test time-based operations. """
|
|
13
|
+
|
|
14
|
+
def test_timestamp_now(self):
|
|
15
|
+
""" Test getting a timestamp for the current moment. """
|
|
16
|
+
|
|
17
|
+
start = edq.util.time.Timestamp.now()
|
|
18
|
+
time.sleep(0.01)
|
|
19
|
+
middle = edq.util.time.Timestamp.now()
|
|
20
|
+
time.sleep(0.01)
|
|
21
|
+
end = edq.util.time.Timestamp.now()
|
|
22
|
+
|
|
23
|
+
self.assertLessEqual(start, middle)
|
|
24
|
+
self.assertLessEqual(middle, end)
|
|
25
|
+
|
|
26
|
+
def test_timestamp_pytime_conversion(self):
|
|
27
|
+
""" Test converting between timestamps and Python datetimes. """
|
|
28
|
+
|
|
29
|
+
# [(timestamp, python time), ...]
|
|
30
|
+
test_cases = [
|
|
31
|
+
(edq.util.time.Timestamp(0), datetime.datetime(1970, 1, 1, 0, 0, 0, 0, TIMEZONE_UTC)),
|
|
32
|
+
|
|
33
|
+
(edq.util.time.Timestamp(1755139534019), datetime.datetime(2025, 8, 14, 2, 45, 34, 19000, TIMEZONE_UTC)),
|
|
34
|
+
(edq.util.time.Timestamp(1755139534019), datetime.datetime(2025, 8, 13, 19, 45, 34, 19000, TIMEZONE_PST)),
|
|
35
|
+
(edq.util.time.Timestamp(1755139534019), datetime.datetime(2025, 8, 14, 4, 45, 34, 19000, TIMEZONE_CEST)),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
for (i, test_case) in enumerate(test_cases):
|
|
39
|
+
(timestamp, pytime) = test_case
|
|
40
|
+
|
|
41
|
+
with self.subTest(msg = f"Case {i} ('{timestamp}' == '{pytime}'):"):
|
|
42
|
+
convert_pytime = timestamp.to_pytime(pytime.tzinfo)
|
|
43
|
+
self.assertEqual(pytime, convert_pytime, 'pytime')
|
|
44
|
+
|
|
45
|
+
convert_timestamp = edq.util.time.Timestamp.from_pytime(pytime)
|
|
46
|
+
self.assertEqual(timestamp, convert_timestamp, 'timestamp')
|
|
47
|
+
|
|
48
|
+
# Check other time zones.
|
|
49
|
+
# Use string comparisons to ensure the timezone is compared (and not just the UTC time).
|
|
50
|
+
timezones = [
|
|
51
|
+
TIMEZONE_UTC,
|
|
52
|
+
TIMEZONE_PST,
|
|
53
|
+
TIMEZONE_CEST,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for timezone in timezones:
|
|
57
|
+
pytime_pst = pytime.astimezone(timezone).isoformat(timespec = 'milliseconds')
|
|
58
|
+
convert_pytime_pst = timestamp.to_pytime(timezone).isoformat(timespec = 'milliseconds')
|
|
59
|
+
self.assertEqual(pytime_pst, convert_pytime_pst, f"pytime {timezone}")
|
|
60
|
+
|
|
61
|
+
def test_timestamp_pretty(self):
|
|
62
|
+
""" Test the "pretty" representations of timestamps. """
|
|
63
|
+
|
|
64
|
+
# [(timestamp, timezone, pretty short, pretty long), ...]
|
|
65
|
+
test_cases = [
|
|
66
|
+
(edq.util.time.Timestamp(0), TIMEZONE_UTC, "1970-01-01 00:00", "1970-01-01T00:00:00.000+00:00"),
|
|
67
|
+
|
|
68
|
+
(edq.util.time.Timestamp(1755139534019), TIMEZONE_UTC, "2025-08-14 02:45", "2025-08-14T02:45:34.019+00:00"),
|
|
69
|
+
(edq.util.time.Timestamp(1755139534019), TIMEZONE_PST, "2025-08-13 19:45", "2025-08-13T19:45:34.019-07:00"),
|
|
70
|
+
(edq.util.time.Timestamp(1755139534019), TIMEZONE_CEST, "2025-08-14 04:45", "2025-08-14T04:45:34.019+02:00"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (i, test_case) in enumerate(test_cases):
|
|
74
|
+
(timestamp, timezone, expected_pretty_short, expected_pretty_long) = test_case
|
|
75
|
+
|
|
76
|
+
with self.subTest(msg = f"Case {i} ('{timestamp}'):"):
|
|
77
|
+
actual_pretty_short = timestamp.pretty(short = True, timezone = timezone)
|
|
78
|
+
actual_pretty_long = timestamp.pretty(short = False, timezone = timezone)
|
|
79
|
+
|
|
80
|
+
self.assertEqual(expected_pretty_short, actual_pretty_short, 'short')
|
|
81
|
+
self.assertEqual(expected_pretty_long, actual_pretty_long, 'long')
|
|
82
|
+
|
|
83
|
+
def test_timestamp_sub(self):
|
|
84
|
+
""" Test subtracting timestamps. """
|
|
85
|
+
|
|
86
|
+
# [(a, b, expected), ...]
|
|
87
|
+
# All values in this structure will be in ints and converted later.
|
|
88
|
+
test_cases = [
|
|
89
|
+
(0, 0, 0),
|
|
90
|
+
(0, 1, -1),
|
|
91
|
+
(1, 0, 1),
|
|
92
|
+
|
|
93
|
+
(100, 100, 0),
|
|
94
|
+
(100, 101, -1),
|
|
95
|
+
(101, 100, 1),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
for (i, test_case) in enumerate(test_cases):
|
|
99
|
+
(raw_a, raw_b, raw_expected) = test_case
|
|
100
|
+
|
|
101
|
+
with self.subTest(msg = f"Case {i} ({raw_a} - {raw_b}):"):
|
|
102
|
+
a = edq.util.time.Timestamp(raw_a)
|
|
103
|
+
b = edq.util.time.Timestamp(raw_b)
|
|
104
|
+
expected = edq.util.time.Duration(raw_expected)
|
|
105
|
+
|
|
106
|
+
actual = a - b
|
|
107
|
+
self.assertEqual(expected, actual)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edq-utils
|
|
3
|
+
Version: 0.1.9
|
|
4
|
+
Summary: Common utilities used by EduLinq Python projects.
|
|
5
|
+
Author-email: Eriq Augustine <eriq@edulinq.org>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 EduLinq
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/edulinq/python-utils
|
|
29
|
+
Project-URL: Repository, https://github.com/edulinq/python-utils
|
|
30
|
+
Keywords: education,utilities
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
34
|
+
Requires-Python: >=3.8
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: json5>=0.9.14
|
|
38
|
+
Requires-Dist: platformdirs
|
|
39
|
+
Requires-Dist: requests
|
|
40
|
+
Requires-Dist: requests-toolbelt
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: mypy>=1.14.1; extra == "dev"
|
|
43
|
+
Requires-Dist: pdoc>=14.7.0; extra == "dev"
|
|
44
|
+
Requires-Dist: pylint; extra == "dev"
|
|
45
|
+
Requires-Dist: twine; extra == "dev"
|
|
46
|
+
Requires-Dist: vermin; extra == "dev"
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
# EduLinq Python Utilities
|
|
50
|
+
|
|
51
|
+
Common utilities used by EduLinq Python projects.
|
|
52
|
+
|
|
53
|
+
Links:
|
|
54
|
+
- [API Reference](https://edulinq.github.io/python-utils)
|
|
55
|
+
- [Installation / Requirements](#installation--requirements)
|
|
56
|
+
- [Configuration System](#configuration-system)
|
|
57
|
+
- [Configuration Sources](#configuration-sources)
|
|
58
|
+
|
|
59
|
+
## Installation / Requirements
|
|
60
|
+
|
|
61
|
+
This project requires [Python](https://www.python.org/) >= 3.8.
|
|
62
|
+
|
|
63
|
+
The project can be installed from PyPi with:
|
|
64
|
+
```
|
|
65
|
+
pip3 install edq-utils
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Standard Python requirements are listed in `pyproject.toml`.
|
|
69
|
+
The project and Python dependencies can be installed from source with:
|
|
70
|
+
```
|
|
71
|
+
pip3 install .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Configuration System
|
|
75
|
+
|
|
76
|
+
This project provides a configuration system that supplies options (e.g., username, password) to a command-line interface (CLI) tool.
|
|
77
|
+
The configuration system follows a tiered order, allowing options to be specified and overridden from both files and command-line options.
|
|
78
|
+
|
|
79
|
+
### Configuration Sources
|
|
80
|
+
|
|
81
|
+
In addition to CLI options, the configuration system loads options from [JSON](https://en.wikipedia.org/wiki/JSON) files located across multiple directories.
|
|
82
|
+
By default, configuration files are named `edq-config.json`.
|
|
83
|
+
This value is customizable, but this document will assume the default is used.
|
|
84
|
+
|
|
85
|
+
For example, a configuration file containing the `user` and `pass` options might look like this:
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"user": "alice",
|
|
89
|
+
"pass": "password123"
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The table below summarizes the configuration sources in the order they are evaluated.
|
|
94
|
+
Values from earlier sources can be overwritten by values from later sources.
|
|
95
|
+
|
|
96
|
+
| Source | Description |
|
|
97
|
+
| :----- | :---------- |
|
|
98
|
+
| Global | Loaded from a file in a user-specific location, which is platform-dependent. |
|
|
99
|
+
| Local | Loaded from a file in the current or nearest ancestor directory. |
|
|
100
|
+
| CLI File | Loaded from one or more explicitly provided configuration files through the CLI. |
|
|
101
|
+
| CLI | Loaded from the command line. |
|
|
102
|
+
|
|
103
|
+
The system produces an error if a global or local configuration file is unreadable (but not missing), or if a CLI-specified file is unreadable or missing.
|
|
104
|
+
|
|
105
|
+
#### Global Configuration
|
|
106
|
+
|
|
107
|
+
Global configuration are options that are user specific and stick with the user between projects, these are well suited for options like login credentials.
|
|
108
|
+
The global configuration file defaults to `<platform-specific user configuration location>/edq-config.json`.
|
|
109
|
+
The configuration location is chosen according to the [XDG standard](https://en.wikipedia.org/wiki/Freedesktop.org#Base_Directory_Specification) (implemented by [platformdirs](https://github.com/tox-dev/platformdirs)).
|
|
110
|
+
Below are examples of user-specific configuration file paths for different operating systems:
|
|
111
|
+
- Linux -- `/home/<user>/.config/edq-config.json`
|
|
112
|
+
- Mac -- `/Users/<user>/Library/Application Support/edq-config.json`
|
|
113
|
+
- Windows -- `C:\Users\<user>\AppData\Local\edq-config.json`
|
|
114
|
+
|
|
115
|
+
The default global configuration location can be changed by passing a path to `--config-global` through the command line.
|
|
116
|
+
|
|
117
|
+
Below is an example command for specifying a global configuration path from the CLI:
|
|
118
|
+
```sh
|
|
119
|
+
python3 -m edq.cli.config.list --config-global ~/.config/custom-config.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### Local Configuration
|
|
123
|
+
|
|
124
|
+
Local configuration are options that are specific to a project or directory, like a project's build directory.
|
|
125
|
+
Local configuration files are searched in multiple locations, the first file found is used.
|
|
126
|
+
The local config search order is:
|
|
127
|
+
1. `edq-config.json` in the current directory.
|
|
128
|
+
2. A legacy file in the current directory (only if a legacy file is preconfigured).
|
|
129
|
+
3. `edq-config.json` in any ancestor directory on the path to root (including root itself).
|
|
130
|
+
|
|
131
|
+
#### CLI-Specified Config Files
|
|
132
|
+
|
|
133
|
+
CLI config files are options specified on the command line via a file.
|
|
134
|
+
These are useful for a common set of options you don’t need every time, such as login credentials for different user.
|
|
135
|
+
Any files passed via `--config-file` will be loaded in the order they appear on the command line.
|
|
136
|
+
Options from later files override options from previous files.
|
|
137
|
+
|
|
138
|
+
Below is an example of a CLI specified configuration paths:
|
|
139
|
+
```sh
|
|
140
|
+
python3 -m edq.cli.config.list --config-file ./edq-config.json --config-file ~/.secrets/edq-config.json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### CLI Configuration
|
|
144
|
+
|
|
145
|
+
CLI configurations are options specified directly on the command line, these are useful for quick option overrides without editing config files.
|
|
146
|
+
Configuration options are passed to the command line by the `--config` flag in this format `<key>=<value>`.
|
|
147
|
+
The provided values overrides the values from configuration files.
|
|
148
|
+
Configuration options are structured as key value pairs and keys cannot contain the "=" character.
|
|
149
|
+
|
|
150
|
+
Below is an example of specifying a configuration option directly from the CLI:
|
|
151
|
+
```sh
|
|
152
|
+
python3 -m edq.cli.config.list --config user=alice --config pass=password123
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### CLI Config Options
|
|
156
|
+
|
|
157
|
+
The table below lists common configuration CLI options available for CLI tools using this library.
|
|
158
|
+
|
|
159
|
+
| CLI Option | Description |
|
|
160
|
+
| :-------------- | :---------- |
|
|
161
|
+
|`--config-global` | Override the global config file location. |
|
|
162
|
+
|`--config-file` | Load configuration options from a CLI specified file. |
|
|
163
|
+
| `--config` | Provide additional options to a CLI command. |
|
|
164
|
+
| `--help` | Display standard help text and the default global configuration file path for the current platform. |
|