edq-utils 0.0.1__py3-none-any.whl → 0.0.2__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.
Potentially problematic release.
This version of edq-utils might be problematic. Click here for more details.
- edq/__init__.py +1 -1
- edq/testing/run.py +91 -0
- edq/testing/unittest.py +47 -0
- edq/util/dirent.py +194 -44
- edq/util/dirent_test.py +706 -25
- edq/util/json.py +80 -0
- edq/util/json_test.py +121 -0
- edq/util/pyimport.py +73 -0
- edq/util/pyimport_test.py +83 -0
- edq/util/reflection.py +32 -0
- edq/util/testdata/dirent-operations/symlink_file_empty +0 -0
- edq/util/time.py +75 -0
- edq/util/time_test.py +107 -0
- {edq_utils-0.0.1.dist-info → edq_utils-0.0.2.dist-info}/METADATA +2 -1
- edq_utils-0.0.2.dist-info/RECORD +26 -0
- edq_utils-0.0.1.dist-info/RECORD +0 -16
- /edq/util/testdata/dirent-operations/{symlinklink_a.txt → symlink_a.txt} +0 -0
- /edq/util/testdata/dirent-operations/{symlinklink_dir_1 → symlink_dir_1}/b.txt +0 -0
- /edq/util/testdata/dirent-operations/{symlinklink_dir_1 → symlink_dir_1}/dir_2/c.txt +0 -0
- {edq_utils-0.0.1.dist-info → edq_utils-0.0.2.dist-info}/WHEEL +0 -0
- {edq_utils-0.0.1.dist-info → edq_utils-0.0.2.dist-info}/licenses/LICENSE +0 -0
- {edq_utils-0.0.1.dist-info → edq_utils-0.0.2.dist-info}/top_level.txt +0 -0
edq/util/json.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file standardizes how we write and read JSON.
|
|
3
|
+
Specifically, we try to be flexible when reading (using JSON5),
|
|
4
|
+
and strict when writing (using vanilla JSON).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
import json5
|
|
11
|
+
|
|
12
|
+
import edq.util.dirent
|
|
13
|
+
|
|
14
|
+
def load(file_obj: typing.TextIO, strict: bool = False, **kwargs) -> typing.Dict[str, typing.Any]:
|
|
15
|
+
"""
|
|
16
|
+
Load a file object/handler as JSON.
|
|
17
|
+
If strict is set, then use standard Python JSON,
|
|
18
|
+
otherwise use JSON5.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
if (strict):
|
|
22
|
+
return json.load(file_obj, **kwargs)
|
|
23
|
+
|
|
24
|
+
return json5.load(file_obj, **kwargs)
|
|
25
|
+
|
|
26
|
+
def loads(text: str, strict: bool = False, **kwargs) -> typing.Dict[str, typing.Any]:
|
|
27
|
+
"""
|
|
28
|
+
Load a string as JSON.
|
|
29
|
+
If strict is set, then use standard Python JSON,
|
|
30
|
+
otherwise use JSON5.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
if (strict):
|
|
34
|
+
return json.loads(text, **kwargs)
|
|
35
|
+
|
|
36
|
+
return json5.loads(text, **kwargs)
|
|
37
|
+
|
|
38
|
+
def load_path(
|
|
39
|
+
path: str,
|
|
40
|
+
strict: bool = False,
|
|
41
|
+
encoding: str = edq.util.dirent.DEFAULT_ENCODING,
|
|
42
|
+
**kwargs) -> typing.Dict[str, typing.Any]:
|
|
43
|
+
"""
|
|
44
|
+
Load a file path as JSON.
|
|
45
|
+
If strict is set, then use standard Python JSON,
|
|
46
|
+
otherwise use JSON5.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
with open(path, 'r', encoding = encoding) as file:
|
|
51
|
+
return load(file, strict = strict, **kwargs)
|
|
52
|
+
except Exception as ex:
|
|
53
|
+
raise ValueError(f"Failed to read JSON file '{path}'.") from ex
|
|
54
|
+
|
|
55
|
+
def dump(
|
|
56
|
+
data: typing.Any,
|
|
57
|
+
file_obj: typing.TextIO,
|
|
58
|
+
sort_keys: bool = True,
|
|
59
|
+
**kwargs) -> None:
|
|
60
|
+
""" Dump an object as a JSON file object. """
|
|
61
|
+
|
|
62
|
+
json.dump(data, file_obj, sort_keys = sort_keys, **kwargs)
|
|
63
|
+
|
|
64
|
+
def dumps(
|
|
65
|
+
data: typing.Any,
|
|
66
|
+
sort_keys: bool = True,
|
|
67
|
+
**kwargs) -> str:
|
|
68
|
+
""" Dump an object as a JSON string. """
|
|
69
|
+
|
|
70
|
+
return json.dumps(data, sort_keys = sort_keys, **kwargs)
|
|
71
|
+
|
|
72
|
+
def dump_path(
|
|
73
|
+
data: typing.Any,
|
|
74
|
+
path: str,
|
|
75
|
+
encoding: str = edq.util.dirent.DEFAULT_ENCODING,
|
|
76
|
+
**kwargs) -> None:
|
|
77
|
+
""" Dump an object as a JSON file. """
|
|
78
|
+
|
|
79
|
+
with open(path, 'w', encoding = encoding) as file:
|
|
80
|
+
dump(data, file, **kwargs)
|
edq/util/json_test.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import edq.testing.unittest
|
|
4
|
+
import edq.util.dirent
|
|
5
|
+
import edq.util.json
|
|
6
|
+
import edq.util.reflection
|
|
7
|
+
|
|
8
|
+
class TestJSON(edq.testing.unittest.BaseTest):
|
|
9
|
+
""" Test JSON utils. """
|
|
10
|
+
|
|
11
|
+
def test_loading_dumping_base(self):
|
|
12
|
+
"""
|
|
13
|
+
Test the family of JSON loading and dumping functions.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# [(string, dict, strict?, error_substring), ...]
|
|
17
|
+
test_cases = [
|
|
18
|
+
# Base
|
|
19
|
+
(
|
|
20
|
+
'{"a": 1}',
|
|
21
|
+
{"a": 1},
|
|
22
|
+
False,
|
|
23
|
+
None,
|
|
24
|
+
),
|
|
25
|
+
|
|
26
|
+
# Trivial - Strict
|
|
27
|
+
(
|
|
28
|
+
'{"a": 1}',
|
|
29
|
+
{"a": 1},
|
|
30
|
+
True,
|
|
31
|
+
None,
|
|
32
|
+
),
|
|
33
|
+
|
|
34
|
+
# JSON5
|
|
35
|
+
(
|
|
36
|
+
'{"a": 1,}',
|
|
37
|
+
{"a": 1},
|
|
38
|
+
False,
|
|
39
|
+
None,
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
# JSON5 - Strict
|
|
43
|
+
(
|
|
44
|
+
'{"a": 1,}',
|
|
45
|
+
{"a": 1},
|
|
46
|
+
True,
|
|
47
|
+
'JSONDecodeError',
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# [(function, name), ...]
|
|
52
|
+
test_methods = [
|
|
53
|
+
(self._subtest_loads_dumps, 'subtest_loads_dumps'),
|
|
54
|
+
(self._subtest_load_dump, 'subtest_load_dump'),
|
|
55
|
+
(self._subtest_load_dump_path, 'subtest_load_dump_path'),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
for (test_method, test_method_name) in test_methods:
|
|
59
|
+
for (i, test_case) in enumerate(test_cases):
|
|
60
|
+
(text_content, dict_content, strict, error_substring) = test_case
|
|
61
|
+
|
|
62
|
+
with self.subTest(msg = f"Subtest {test_method_name}, Case {i} ('{text_content}'):"):
|
|
63
|
+
try:
|
|
64
|
+
test_method(text_content, dict_content, strict)
|
|
65
|
+
except AssertionError:
|
|
66
|
+
# The subttest failed an assertion.
|
|
67
|
+
raise
|
|
68
|
+
except Exception as ex:
|
|
69
|
+
error_string = self.format_error_string(ex)
|
|
70
|
+
if (error_substring is None):
|
|
71
|
+
self.fail(f"Unexpected error: '{error_string}'.")
|
|
72
|
+
|
|
73
|
+
self.assertIn(error_substring, error_string, 'Error is not as expected.')
|
|
74
|
+
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if (error_substring is not None):
|
|
78
|
+
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
79
|
+
|
|
80
|
+
def _subtest_loads_dumps(self, text_content, dict_content, strict):
|
|
81
|
+
actual_dict = edq.util.json.loads(text_content, strict = strict)
|
|
82
|
+
actual_text = edq.util.json.dumps(dict_content)
|
|
83
|
+
double_conversion_dict = edq.util.json.dumps(actual_dict)
|
|
84
|
+
|
|
85
|
+
self.assertDictEqual(dict_content, actual_dict)
|
|
86
|
+
self.assertEqual(actual_text, double_conversion_dict)
|
|
87
|
+
|
|
88
|
+
def _subtest_load_dump(self, text_content, dict_content, strict):
|
|
89
|
+
temp_dir = edq.util.dirent.get_temp_dir(prefix = 'edq_test_json_')
|
|
90
|
+
|
|
91
|
+
path_text = os.path.join(temp_dir, 'test-text.json')
|
|
92
|
+
path_dict = os.path.join(temp_dir, 'test-dict.json')
|
|
93
|
+
|
|
94
|
+
edq.util.dirent.write_file(path_text, text_content)
|
|
95
|
+
|
|
96
|
+
with open(path_text, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file:
|
|
97
|
+
text_load = edq.util.json.load(file, strict = strict)
|
|
98
|
+
|
|
99
|
+
with open(path_dict, 'w', encoding = edq.util.dirent.DEFAULT_ENCODING) as file:
|
|
100
|
+
edq.util.json.dump(dict_content, file)
|
|
101
|
+
|
|
102
|
+
with open(path_dict, 'r', encoding = edq.util.dirent.DEFAULT_ENCODING) as file:
|
|
103
|
+
dict_load = edq.util.json.load(file, strict = strict)
|
|
104
|
+
|
|
105
|
+
self.assertDictEqual(dict_content, text_load)
|
|
106
|
+
self.assertDictEqual(dict_load, text_load)
|
|
107
|
+
|
|
108
|
+
def _subtest_load_dump_path(self, text_content, dict_content, strict):
|
|
109
|
+
temp_dir = edq.util.dirent.get_temp_dir(prefix = 'edq_test_json_path_')
|
|
110
|
+
|
|
111
|
+
path_text = os.path.join(temp_dir, 'test-text.json')
|
|
112
|
+
path_dict = os.path.join(temp_dir, 'test-dict.json')
|
|
113
|
+
|
|
114
|
+
edq.util.dirent.write_file(path_text, text_content)
|
|
115
|
+
text_load = edq.util.json.load_path(path_text, strict = strict)
|
|
116
|
+
|
|
117
|
+
edq.util.json.dump_path(dict_content, path_dict)
|
|
118
|
+
dict_load = edq.util.json.load_path(path_dict, strict = strict)
|
|
119
|
+
|
|
120
|
+
self.assertDictEqual(dict_content, text_load)
|
|
121
|
+
self.assertDictEqual(dict_load, text_load)
|
edq/util/pyimport.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
|
|
@@ -0,0 +1,83 @@
|
|
|
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)
|
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)
|
|
File without changes
|
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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edq-utils
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: Common utilities used by EduLinq Python projects.
|
|
5
5
|
Author-email: Eriq Augustine <eriq@edulinq.org>
|
|
6
6
|
License: MIT License
|
|
@@ -34,6 +34,7 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
34
34
|
Requires-Python: >=3.8
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
36
36
|
License-File: LICENSE
|
|
37
|
+
Requires-Dist: json5>=0.9.14
|
|
37
38
|
Provides-Extra: dev
|
|
38
39
|
Requires-Dist: mypy>=1.14.1; extra == "dev"
|
|
39
40
|
Requires-Dist: pylint; extra == "dev"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
edq/__init__.py,sha256=miSsqPy2PFJ18TW9kocQJLcv4HFpkfQhcTzgAO-GTVE,22
|
|
2
|
+
edq/testing/run.py,sha256=pibKFJDHCtGoKyuN7i95ZGjwuXV5jL-XPy4W-l14X2Q,3150
|
|
3
|
+
edq/testing/unittest.py,sha256=WIcc5psTM-e6NQbQ3ajBjSRRRHBqvMuKOvK_pZMcTlk,1341
|
|
4
|
+
edq/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
edq/util/dirent.py,sha256=LR-ePtQZKEWNfZkyJ-zxFdcggQKYZ8fT379mcBYeepw,9412
|
|
6
|
+
edq/util/dirent_test.py,sha256=FSRqhp18S4-7J9XroLxPUoJMKPBDpp3PfPKraM_7atw,30037
|
|
7
|
+
edq/util/json.py,sha256=YRUrQvLvwDTQBcURGk6nGJLd9m6TB_jdgdrYPLElcyk,2132
|
|
8
|
+
edq/util/json_test.py,sha256=a0my-tdo1NoPv8wWwxk2pIw56CS7QyOrODkGx2-GO1c,4273
|
|
9
|
+
edq/util/pyimport.py,sha256=M1j58vg4b6gTg92Cz5-bns3eQCCIMKDApBclP-iR620,2198
|
|
10
|
+
edq/util/pyimport_test.py,sha256=wuTR5pzVZanWDA2FuVc-Pxyo_GwkGGfFf_qyK6LNQRs,2851
|
|
11
|
+
edq/util/reflection.py,sha256=jPcW6h0fwSDYh04O5rUxlgoF7HK6fVQ2mq7DD9qPrEg,972
|
|
12
|
+
edq/util/time.py,sha256=anoNM_KniARLombv2BnsoHuCzDqMKiDdIzV7RUe2ZOk,2648
|
|
13
|
+
edq/util/time_test.py,sha256=iQZwzVTVQQ4TdXrLb9MUMCYlKrIe8qyF-hiC9YLTaMo,4610
|
|
14
|
+
edq/util/testdata/dirent-operations/a.txt,sha256=h0KPxSKAPTEGXnvOPPA_5HUJZjHl4Hu9eg_eYMTPJcc,2
|
|
15
|
+
edq/util/testdata/dirent-operations/file_empty,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
edq/util/testdata/dirent-operations/symlink_a.txt,sha256=h0KPxSKAPTEGXnvOPPA_5HUJZjHl4Hu9eg_eYMTPJcc,2
|
|
17
|
+
edq/util/testdata/dirent-operations/symlink_file_empty,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
edq/util/testdata/dirent-operations/dir_1/b.txt,sha256=AmOCmYm2_ZVPcrqvL8ZLwuLwHWktTecphuqAj26ZgT8,2
|
|
19
|
+
edq/util/testdata/dirent-operations/dir_1/dir_2/c.txt,sha256=o6XnFfDMV0pzw_m-u2vCTzL_1bZ7OHJEwskJ2neaFHg,2
|
|
20
|
+
edq/util/testdata/dirent-operations/symlink_dir_1/b.txt,sha256=AmOCmYm2_ZVPcrqvL8ZLwuLwHWktTecphuqAj26ZgT8,2
|
|
21
|
+
edq/util/testdata/dirent-operations/symlink_dir_1/dir_2/c.txt,sha256=o6XnFfDMV0pzw_m-u2vCTzL_1bZ7OHJEwskJ2neaFHg,2
|
|
22
|
+
edq_utils-0.0.2.dist-info/licenses/LICENSE,sha256=MS4iYEl4rOxMoprZuc86iYVoyk4YgaVoMt7WmGvVF8w,1064
|
|
23
|
+
edq_utils-0.0.2.dist-info/METADATA,sha256=WczGh7XL8QbJ0OLaLPE0WyHA65YucW8UAv0LG6TN7s8,2427
|
|
24
|
+
edq_utils-0.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
25
|
+
edq_utils-0.0.2.dist-info/top_level.txt,sha256=znBHSj6tgXtcMKrUVtovLli5fIEJCb7d-BMxTLRK4zk,4
|
|
26
|
+
edq_utils-0.0.2.dist-info/RECORD,,
|
edq_utils-0.0.1.dist-info/RECORD
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
edq/__init__.py,sha256=DsAHdxLC16H2VjdFOU5tBx2xT9VnNQ-XbTS24fRCa_w,22
|
|
2
|
-
edq/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
edq/util/dirent.py,sha256=Dr4OtB9TvJmiGLrjdiSmsh5Fk8G49jfklh4wdOx13ro,4436
|
|
4
|
-
edq/util/dirent_test.py,sha256=FBMHEmcrfR7VILBzgy1cMWrFEp9wNQ5RNbL5vOdezT4,5998
|
|
5
|
-
edq/util/testdata/dirent-operations/a.txt,sha256=h0KPxSKAPTEGXnvOPPA_5HUJZjHl4Hu9eg_eYMTPJcc,2
|
|
6
|
-
edq/util/testdata/dirent-operations/file_empty,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
edq/util/testdata/dirent-operations/symlinklink_a.txt,sha256=h0KPxSKAPTEGXnvOPPA_5HUJZjHl4Hu9eg_eYMTPJcc,2
|
|
8
|
-
edq/util/testdata/dirent-operations/dir_1/b.txt,sha256=AmOCmYm2_ZVPcrqvL8ZLwuLwHWktTecphuqAj26ZgT8,2
|
|
9
|
-
edq/util/testdata/dirent-operations/dir_1/dir_2/c.txt,sha256=o6XnFfDMV0pzw_m-u2vCTzL_1bZ7OHJEwskJ2neaFHg,2
|
|
10
|
-
edq/util/testdata/dirent-operations/symlinklink_dir_1/b.txt,sha256=AmOCmYm2_ZVPcrqvL8ZLwuLwHWktTecphuqAj26ZgT8,2
|
|
11
|
-
edq/util/testdata/dirent-operations/symlinklink_dir_1/dir_2/c.txt,sha256=o6XnFfDMV0pzw_m-u2vCTzL_1bZ7OHJEwskJ2neaFHg,2
|
|
12
|
-
edq_utils-0.0.1.dist-info/licenses/LICENSE,sha256=MS4iYEl4rOxMoprZuc86iYVoyk4YgaVoMt7WmGvVF8w,1064
|
|
13
|
-
edq_utils-0.0.1.dist-info/METADATA,sha256=Q99RoHQkHjWZEkP-gEdtEaEPE1xoauWVtUXWsE3OlmM,2398
|
|
14
|
-
edq_utils-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
-
edq_utils-0.0.1.dist-info/top_level.txt,sha256=znBHSj6tgXtcMKrUVtovLli5fIEJCb7d-BMxTLRK4zk,4
|
|
16
|
-
edq_utils-0.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|