edq-utils 0.0.2__tar.gz → 0.0.3__tar.gz

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.

Files changed (37) hide show
  1. {edq_utils-0.0.2/edq_utils.egg-info → edq_utils-0.0.3}/PKG-INFO +2 -1
  2. edq_utils-0.0.3/edq/__init__.py +5 -0
  3. edq_utils-0.0.3/edq/testing/__init__.py +3 -0
  4. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/testing/run.py +34 -13
  5. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/testing/unittest.py +10 -3
  6. edq_utils-0.0.3/edq/util/__init__.py +3 -0
  7. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/dirent.py +35 -1
  8. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/dirent_test.py +120 -3
  9. edq_utils-0.0.3/edq/util/json.py +163 -0
  10. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/json_test.py +109 -2
  11. {edq_utils-0.0.2 → edq_utils-0.0.3/edq_utils.egg-info}/PKG-INFO +2 -1
  12. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq_utils.egg-info/SOURCES.txt +2 -0
  13. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq_utils.egg-info/requires.txt +1 -0
  14. {edq_utils-0.0.2 → edq_utils-0.0.3}/requirements-dev.txt +1 -0
  15. edq_utils-0.0.2/edq/__init__.py +0 -1
  16. edq_utils-0.0.2/edq/util/json.py +0 -80
  17. {edq_utils-0.0.2 → edq_utils-0.0.3}/LICENSE +0 -0
  18. {edq_utils-0.0.2 → edq_utils-0.0.3}/README.md +0 -0
  19. /edq_utils-0.0.2/edq/util/__init__.py → /edq_utils-0.0.3/edq/py.typed +0 -0
  20. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/pyimport.py +0 -0
  21. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/pyimport_test.py +0 -0
  22. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/reflection.py +0 -0
  23. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/a.txt +0 -0
  24. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/dir_1/b.txt +0 -0
  25. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/dir_1/dir_2/c.txt +0 -0
  26. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/file_empty +0 -0
  27. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/symlink_a.txt +0 -0
  28. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/symlink_dir_1/b.txt +0 -0
  29. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/symlink_dir_1/dir_2/c.txt +0 -0
  30. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/testdata/dirent-operations/symlink_file_empty +0 -0
  31. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/time.py +0 -0
  32. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq/util/time_test.py +0 -0
  33. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq_utils.egg-info/dependency_links.txt +0 -0
  34. {edq_utils-0.0.2 → edq_utils-0.0.3}/edq_utils.egg-info/top_level.txt +0 -0
  35. {edq_utils-0.0.2 → edq_utils-0.0.3}/pyproject.toml +0 -0
  36. {edq_utils-0.0.2 → edq_utils-0.0.3}/requirements.txt +0 -0
  37. {edq_utils-0.0.2 → edq_utils-0.0.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edq-utils
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Common utilities used by EduLinq Python projects.
5
5
  Author-email: Eriq Augustine <eriq@edulinq.org>
6
6
  License: MIT License
@@ -37,6 +37,7 @@ License-File: LICENSE
37
37
  Requires-Dist: json5>=0.9.14
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: mypy>=1.14.1; extra == "dev"
40
+ Requires-Dist: pdoc>=14.7.0; extra == "dev"
40
41
  Requires-Dist: pylint; extra == "dev"
41
42
  Requires-Dist: twine; extra == "dev"
42
43
  Requires-Dist: vermin; extra == "dev"
@@ -0,0 +1,5 @@
1
+ """
2
+ General Python tools used by several EduLinq projects.
3
+ """
4
+
5
+ __version__ = '0.0.3'
@@ -0,0 +1,3 @@
1
+ """
2
+ Testing infrastructure.
3
+ """
@@ -11,10 +11,6 @@ import sys
11
11
  import typing
12
12
  import unittest
13
13
 
14
- THIS_DIR: str = os.path.join(os.path.dirname(os.path.realpath(__file__)))
15
- BASE_PACKAGE_DIR: str = os.path.join(THIS_DIR, '..')
16
- PROJECT_ROOT_DIR: str = os.path.join(BASE_PACKAGE_DIR, '..')
17
-
18
14
  DEFAULT_TEST_FILENAME_PATTERN: str = '*_test.py'
19
15
 
20
16
  def _collect_tests(suite: typing.Union[unittest.TestCase, unittest.suite.TestSuite]) -> typing.List[unittest.TestCase]:
@@ -41,13 +37,22 @@ def run(args: argparse.Namespace) -> int:
41
37
  Will raise if tests fail to load (e.g. syntax errors) and a suggested exit code otherwise.
42
38
  """
43
39
 
44
- # Start in the project's root and add it in the path.
45
- os.chdir(PROJECT_ROOT_DIR)
46
- sys.path.append(PROJECT_ROOT_DIR)
40
+ if (args.work_dir is not None):
41
+ os.chdir(args.work_dir)
42
+
43
+ if (args.path_additions is not None):
44
+ for path in args.path_additions:
45
+ sys.path.append(path)
46
+
47
+ if (args.test_dirs is None):
48
+ args.test_dirs = ['.']
47
49
 
48
50
  runner = unittest.TextTestRunner(verbosity = 3)
49
- discovered_suite = unittest.TestLoader().discover(BASE_PACKAGE_DIR, pattern = args.filename_pattern)
50
- test_cases = _collect_tests(discovered_suite)
51
+ test_cases = []
52
+
53
+ for test_dir in args.test_dirs:
54
+ discovered_suite = unittest.TestLoader().discover(test_dir, pattern = args.filename_pattern)
55
+ test_cases += _collect_tests(discovered_suite)
51
56
 
52
57
  tests = unittest.suite.TestSuite()
53
58
 
@@ -64,27 +69,43 @@ def run(args: argparse.Namespace) -> int:
64
69
  faults = len(result.errors) + len(result.failures)
65
70
 
66
71
  if (not result.wasSuccessful()):
67
- # This value will be used as an exit status, so don't larger than a byte.
72
+ # This value will be used as an exit status, so it should not be larger than a byte.
68
73
  # (Some higher values are used specially, so just keep it at a round number.)
69
74
  return max(1, min(faults, 100))
70
75
 
71
76
  return 0
72
77
 
73
78
  def main() -> int:
79
+ """ Parse the CLI arguments and run tests. """
80
+
74
81
  args = _get_parser().parse_args()
75
82
  return run(args)
76
83
 
77
84
  def _get_parser() -> argparse.ArgumentParser:
85
+ """ Build a parser for CLI arguments. """
86
+
78
87
  parser = argparse.ArgumentParser(description = 'Run unit tests discovered in this project.')
79
88
 
80
- parser.add_argument('pattern',
81
- action = 'store', type = str, default = None, nargs = '?',
82
- help = 'If supplied, only tests with names matching this pattern will be run. This pattern is used directly in re.search().')
89
+ parser.add_argument('--work-dir', dest = 'work_dir',
90
+ action = 'store', type = str, default = os.getcwd(),
91
+ help = 'Set the working directory when running tests, defaults to the current working directory (%(default)s).')
92
+
93
+ parser.add_argument('--tests-dir', dest = 'test_dirs',
94
+ action = 'append',
95
+ help = 'Discover tests from these directories. Defaults to the current directory.')
96
+
97
+ parser.add_argument('--add-path', dest = 'path_additions',
98
+ action = 'append',
99
+ help = 'If supplied, add this path the sys.path before running tests.')
83
100
 
84
101
  parser.add_argument('--filename-pattern', dest = 'filename_pattern',
85
102
  action = 'store', type = str, default = DEFAULT_TEST_FILENAME_PATTERN,
86
103
  help = 'The pattern to use to find test files (default: %(default)s).')
87
104
 
105
+ parser.add_argument('pattern',
106
+ action = 'store', type = str, default = None, nargs = '?',
107
+ help = 'If supplied, only tests with names matching this pattern will be run. This pattern is used directly in re.search().')
108
+
88
109
  return parser
89
110
 
90
111
  if __name__ == '__main__':
@@ -1,4 +1,3 @@
1
- import json
2
1
  import typing
3
2
  import unittest
4
3
 
@@ -15,13 +14,21 @@ class BaseTest(unittest.TestCase):
15
14
  maxDiff = None
16
15
  """ Don't limit the size of diffs. """
17
16
 
18
- def assertJSONDictEqual(self, a: dict, b: dict) -> None:
17
+ def assertJSONDictEqual(self, a: dict, b: dict) -> None: # pylint: disable=invalid-name
18
+ """
19
+ Call assertDictEqual(), but supply a message containing the full JSON representation of the arguments.
20
+ """
21
+
19
22
  a_json = edq.util.json.dumps(a, indent = 4)
20
23
  b_json = edq.util.json.dumps(b, indent = 4)
21
24
 
22
25
  super().assertDictEqual(a, b, FORMAT_STR % (a_json, b_json))
23
26
 
24
- def assertJSONListEqual(self, a: list, b: list) -> None:
27
+ def assertJSONListEqual(self, a: list, b: list) -> None: # pylint: disable=invalid-name
28
+ """
29
+ Call assertListEqual(), but supply a message containing the full JSON representation of the arguments.
30
+ """
31
+
25
32
  a_json = edq.util.json.dumps(a, indent = 4)
26
33
  b_json = edq.util.json.dumps(b, indent = 4)
27
34
 
@@ -0,0 +1,3 @@
1
+ """
2
+ Low-level utilities.
3
+ """
@@ -13,6 +13,7 @@ import atexit
13
13
  import os
14
14
  import shutil
15
15
  import tempfile
16
+ import typing
16
17
  import uuid
17
18
 
18
19
  DEFAULT_ENCODING: str = 'utf-8'
@@ -247,7 +248,7 @@ def read_file(raw_path: str, strip: bool = True, encoding: str = DEFAULT_ENCODIN
247
248
  return contents
248
249
 
249
250
  def write_file(
250
- raw_path: str, contents: str,
251
+ raw_path: str, contents: typing.Union[str, None],
251
252
  strip: bool = True, newline: bool = True,
252
253
  encoding: str = DEFAULT_ENCODING,
253
254
  no_clobber = False) -> None:
@@ -276,6 +277,39 @@ def write_file(
276
277
  with open(path, 'w', encoding = encoding) as file:
277
278
  file.write(contents)
278
279
 
280
+ def read_file_bytes(raw_path: str) -> bytes:
281
+ """ Read the contents of a file as bytes. """
282
+
283
+ path = os.path.abspath(raw_path)
284
+
285
+ if (not exists(path)):
286
+ raise ValueError(f"Source of read bytes does not exist: '{raw_path}'.")
287
+
288
+ with open(path, 'rb') as file:
289
+ return file.read()
290
+
291
+ def write_file_bytes(
292
+ raw_path: str, contents: typing.Union[bytes, None],
293
+ no_clobber = False) -> None:
294
+ """
295
+ Write the contents of a file as bytes.
296
+ If clobbering, any existing dirent will be removed before write.
297
+ """
298
+
299
+ path = os.path.abspath(raw_path)
300
+
301
+ if (exists(path)):
302
+ if (no_clobber):
303
+ raise ValueError(f"Destination of write bytes already exists: '{raw_path}'.")
304
+
305
+ remove(path)
306
+
307
+ if (contents is None):
308
+ contents = b''
309
+
310
+ with open(path, 'wb') as file:
311
+ file.write(contents)
312
+
279
313
  def contains_path(parent: str, child: str) -> bool:
280
314
  """
281
315
  Check if the parent path contains the child path.
@@ -92,6 +92,124 @@ class TestDirent(edq.testing.unittest.BaseTest):
92
92
  actual = edq.util.dirent.contains_path(parent, child)
93
93
  self.assertEqual(expected, actual)
94
94
 
95
+ def test_read_write_file_bytes_base(self):
96
+ """ Test reading and writing a file as bytes. """
97
+
98
+ # [(path, write kwargs, read kwargs, write contents, expected contents, error substring), ...]
99
+ # All conent should be strings that will be encoded.
100
+ test_cases = [
101
+ # Base
102
+ (
103
+ "test.txt",
104
+ {},
105
+ {},
106
+ "test",
107
+ "test",
108
+ None,
109
+ ),
110
+
111
+ # Empty Write
112
+ (
113
+ "test.txt",
114
+ {},
115
+ {},
116
+ "",
117
+ "",
118
+ None,
119
+ ),
120
+
121
+ # None Write
122
+ (
123
+ "test.txt",
124
+ {},
125
+ {},
126
+ None,
127
+ "",
128
+ None,
129
+ ),
130
+
131
+ # Clobber
132
+ (
133
+ "a.txt",
134
+ {},
135
+ {},
136
+ "test",
137
+ "test",
138
+ None,
139
+ ),
140
+ (
141
+ "dir_1",
142
+ {},
143
+ {},
144
+ "test",
145
+ "test",
146
+ None,
147
+ ),
148
+ (
149
+ "symlink_a.txt",
150
+ {},
151
+ {},
152
+ "test",
153
+ "test",
154
+ None,
155
+ ),
156
+
157
+ # No Clobber
158
+ (
159
+ "a.txt",
160
+ {'no_clobber': True},
161
+ {},
162
+ "test",
163
+ "test",
164
+ 'already exists',
165
+ ),
166
+ (
167
+ "dir_1",
168
+ {'no_clobber': True},
169
+ {},
170
+ "test",
171
+ "test",
172
+ 'already exists',
173
+ ),
174
+ (
175
+ "symlink_a.txt",
176
+ {'no_clobber': True},
177
+ {},
178
+ "test",
179
+ "test",
180
+ 'already exists',
181
+ ),
182
+ ]
183
+
184
+ for (i, test_case) in enumerate(test_cases):
185
+ (path, write_options, read_options, write_contents, expected_contents, error_substring) = test_case
186
+
187
+ with self.subTest(msg = f"Case {i} ('{path}'):"):
188
+ temp_dir = self._prep_temp_dir()
189
+ path = os.path.join(temp_dir, path)
190
+
191
+ if (write_contents is not None):
192
+ write_contents = bytes(write_contents, edq.util.dirent.DEFAULT_ENCODING)
193
+
194
+ expected_contents = bytes(expected_contents, edq.util.dirent.DEFAULT_ENCODING)
195
+
196
+ try:
197
+ edq.util.dirent.write_file_bytes(path, write_contents, **write_options)
198
+ actual_contents = edq.util.dirent.read_file_bytes(path, **read_options)
199
+ except Exception as ex:
200
+ error_string = self.format_error_string(ex)
201
+ if (error_substring is None):
202
+ self.fail(f"Unexpected error: '{error_string}'.")
203
+
204
+ self.assertIn(error_substring, error_string, 'Error is not as expected.')
205
+
206
+ continue
207
+
208
+ if (error_substring is not None):
209
+ self.fail(f"Did not get expected error: '{error_substring}'.")
210
+
211
+ self.assertEqual(expected_contents, actual_contents)
212
+
95
213
  def test_read_write_file_base(self):
96
214
  """ Test reading and writing a file. """
97
215
 
@@ -157,7 +275,7 @@ class TestDirent(edq.testing.unittest.BaseTest):
157
275
  None,
158
276
  ),
159
277
 
160
- # None
278
+ # None Write
161
279
  (
162
280
  "test.txt",
163
281
  {'newline': False},
@@ -225,7 +343,6 @@ class TestDirent(edq.testing.unittest.BaseTest):
225
343
 
226
344
  with self.subTest(msg = f"Case {i} ('{path}'):"):
227
345
  temp_dir = self._prep_temp_dir()
228
-
229
346
  path = os.path.join(temp_dir, path)
230
347
 
231
348
  try:
@@ -243,7 +360,7 @@ class TestDirent(edq.testing.unittest.BaseTest):
243
360
  if (error_substring is not None):
244
361
  self.fail(f"Did not get expected error: '{error_substring}'.")
245
362
 
246
- self.assertEqual(expected_contents, actual_contents)
363
+ self.assertEqual(expected_contents, actual_contents)
247
364
 
248
365
  def test_copy_contents_base(self):
249
366
  """
@@ -0,0 +1,163 @@
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 abc
8
+ import enum
9
+ import json
10
+ import typing
11
+
12
+ import json5
13
+
14
+ import edq.util.dirent
15
+
16
+ class DictConverter(abc.ABC):
17
+ """
18
+ A base class for class that can represent (serialize) and reconstruct (deserialize) themselves as/from a dict.
19
+ The intention is that the dict can then be cleanly converted to/from JSON.
20
+ """
21
+
22
+ @abc.abstractmethod
23
+ def to_dict(self) -> typing.Dict[str, typing.Any]:
24
+ """
25
+ Return a dict that can be used to represent this object.
26
+ If the dict is passed to from_dict(), an identical object should be reconstructed.
27
+ """
28
+
29
+ @classmethod
30
+ @abc.abstractmethod
31
+ # Note that `typing.Self` is returned, but that is introduced in Python 3.12.
32
+ def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
33
+ """
34
+ Return an instance of this subclass created using the given dict.
35
+ If the dict came from to_dict(), the returned object should be identical to the original.
36
+ """
37
+
38
+ def __eq__(self, other: object) -> bool:
39
+ """
40
+ Check for equality.
41
+
42
+ This check uses to_dict() and compares the results.
43
+ This may not be complete or efficient depending on the child class.
44
+ """
45
+
46
+ # Note the hard type check (done so we can keep this method general).
47
+ if (type(self) != type(other)): # pylint: disable=unidiomatic-typecheck
48
+ return False
49
+
50
+ return self.to_dict() == other.to_dict() # type: ignore[attr-defined]
51
+
52
+ def __str__(self) -> str:
53
+ return dumps(self)
54
+
55
+ def __repr__(self) -> str:
56
+ return dumps(self)
57
+
58
+ def _custom_handle(value: typing.Any) -> typing.Union[typing.Dict[str, typing.Any], str]:
59
+ """
60
+ Handle objects that are not JSON serializable by default,
61
+ e.g., calling vars() on an object.
62
+ """
63
+
64
+ if (isinstance(value, DictConverter)):
65
+ return value.to_dict()
66
+
67
+ if (isinstance(value, enum.Enum)):
68
+ return str(value)
69
+
70
+ if (hasattr(value, '__dict__')):
71
+ return vars(value)
72
+
73
+ raise ValueError(f"Could not JSON serialize object: '{value}'.")
74
+
75
+ def load(file_obj: typing.TextIO, strict: bool = False, **kwargs) -> typing.Dict[str, typing.Any]:
76
+ """
77
+ Load a file object/handler as JSON.
78
+ If strict is set, then use standard Python JSON,
79
+ otherwise use JSON5.
80
+ """
81
+
82
+ if (strict):
83
+ return json.load(file_obj, **kwargs)
84
+
85
+ return json5.load(file_obj, **kwargs)
86
+
87
+ def loads(text: str, strict: bool = False, **kwargs) -> typing.Dict[str, typing.Any]:
88
+ """
89
+ Load a string as JSON.
90
+ If strict is set, then use standard Python JSON,
91
+ otherwise use JSON5.
92
+ """
93
+
94
+ if (strict):
95
+ return json.loads(text, **kwargs)
96
+
97
+ return json5.loads(text, **kwargs)
98
+
99
+ def load_path(
100
+ path: str,
101
+ strict: bool = False,
102
+ encoding: str = edq.util.dirent.DEFAULT_ENCODING,
103
+ **kwargs) -> typing.Dict[str, typing.Any]:
104
+ """
105
+ Load a file path as JSON.
106
+ If strict is set, then use standard Python JSON,
107
+ otherwise use JSON5.
108
+ """
109
+
110
+ try:
111
+ with open(path, 'r', encoding = encoding) as file:
112
+ return load(file, strict = strict, **kwargs)
113
+ except Exception as ex:
114
+ raise ValueError(f"Failed to read JSON file '{path}'.") from ex
115
+
116
+ def loads_object(text: str, cls: typing.Type[DictConverter], **kwargs) -> DictConverter:
117
+ """ Load a JSON string into an object (which is a subclass of DictConverter). """
118
+
119
+ data = loads(text, **kwargs)
120
+ if (not isinstance(data, dict)):
121
+ raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
122
+
123
+ return cls.from_dict(data)
124
+
125
+ def load_object_path(path: str, cls: typing.Type[DictConverter], **kwargs) -> DictConverter:
126
+ """ Load a JSON file into an object (which is a subclass of DictConverter). """
127
+
128
+ data = load_path(path, **kwargs)
129
+ if (not isinstance(data, dict)):
130
+ raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
131
+
132
+ return cls.from_dict(data)
133
+
134
+ def dump(
135
+ data: typing.Any,
136
+ file_obj: typing.TextIO,
137
+ default: typing.Union[typing.Callable, None] = _custom_handle,
138
+ sort_keys: bool = True,
139
+ **kwargs) -> None:
140
+ """ Dump an object as a JSON file object. """
141
+
142
+ json.dump(data, file_obj, default = default, sort_keys = sort_keys, **kwargs)
143
+
144
+ def dumps(
145
+ data: typing.Any,
146
+ default: typing.Union[typing.Callable, None] = _custom_handle,
147
+ sort_keys: bool = True,
148
+ **kwargs) -> str:
149
+ """ Dump an object as a JSON string. """
150
+
151
+ return json.dumps(data, default = default, sort_keys = sort_keys, **kwargs)
152
+
153
+ def dump_path(
154
+ data: typing.Any,
155
+ path: str,
156
+ default: typing.Union[typing.Callable, None] = _custom_handle,
157
+ sort_keys: bool = True,
158
+ encoding: str = edq.util.dirent.DEFAULT_ENCODING,
159
+ **kwargs) -> None:
160
+ """ Dump an object as a JSON file. """
161
+
162
+ with open(path, 'w', encoding = encoding) as file:
163
+ dump(data, file, default = default, sort_keys = sort_keys, **kwargs)
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import typing
2
3
 
3
4
  import edq.testing.unittest
4
5
  import edq.util.dirent
@@ -80,10 +81,10 @@ class TestJSON(edq.testing.unittest.BaseTest):
80
81
  def _subtest_loads_dumps(self, text_content, dict_content, strict):
81
82
  actual_dict = edq.util.json.loads(text_content, strict = strict)
82
83
  actual_text = edq.util.json.dumps(dict_content)
83
- double_conversion_dict = edq.util.json.dumps(actual_dict)
84
+ double_conversion_text = edq.util.json.dumps(actual_dict)
84
85
 
85
86
  self.assertDictEqual(dict_content, actual_dict)
86
- self.assertEqual(actual_text, double_conversion_dict)
87
+ self.assertEqual(actual_text, double_conversion_text)
87
88
 
88
89
  def _subtest_load_dump(self, text_content, dict_content, strict):
89
90
  temp_dir = edq.util.dirent.get_temp_dir(prefix = 'edq_test_json_')
@@ -119,3 +120,109 @@ class TestJSON(edq.testing.unittest.BaseTest):
119
120
 
120
121
  self.assertDictEqual(dict_content, text_load)
121
122
  self.assertDictEqual(dict_load, text_load)
123
+
124
+ def test_object_base(self):
125
+ """
126
+ Test loading and dumping JSON objects
127
+ """
128
+
129
+ # [(string, object, error_substring), ...]
130
+ test_cases = [
131
+ # Base
132
+ (
133
+ '{"a": 1, "b": "b"}',
134
+ _TestConverter(1, "b"),
135
+ None,
136
+ ),
137
+
138
+ # Missing Key
139
+ (
140
+ '{"a": 1}',
141
+ _TestConverter(1, None),
142
+ None,
143
+ ),
144
+
145
+ # Empty
146
+ (
147
+ '{}',
148
+ _TestConverter(None, None),
149
+ None,
150
+ ),
151
+
152
+ # Extra Key
153
+ (
154
+ '{"a": 1, "b": "b", "c": 0}',
155
+ _TestConverter(1, "b"),
156
+ None,
157
+ ),
158
+
159
+ # List
160
+ (
161
+ '[{"a": 1, "b": "b"}]',
162
+ _TestConverter(1, "b"),
163
+ 'not a dict',
164
+ ),
165
+ ]
166
+
167
+ # [(function, name), ...]
168
+ test_methods = [
169
+ (self._subtest_loads_object, 'subtest_loads_object'),
170
+ (self._subtest_load_object_path, 'subtest_load_object_path'),
171
+ ]
172
+
173
+ for (test_method, test_method_name) in test_methods:
174
+ for (i, test_case) in enumerate(test_cases):
175
+ (text_content, object_content, error_substring) = test_case
176
+
177
+ with self.subTest(msg = f"Subtest {test_method_name}, Case {i} ('{text_content}'):"):
178
+ try:
179
+ test_method(text_content, object_content)
180
+ except AssertionError:
181
+ # The subttest failed an assertion.
182
+ raise
183
+ except Exception as ex:
184
+ error_string = self.format_error_string(ex)
185
+ if (error_substring is None):
186
+ self.fail(f"Unexpected error: '{error_string}'.")
187
+
188
+ self.assertIn(error_substring, error_string, 'Error is not as expected.')
189
+
190
+ continue
191
+
192
+ if (error_substring is not None):
193
+ self.fail(f"Did not get expected error: '{error_substring}'.")
194
+
195
+ def _subtest_loads_object(self, text_content, object_content):
196
+ actual_object = edq.util.json.loads_object(text_content, _TestConverter)
197
+ actual_text = edq.util.json.dumps(object_content)
198
+ double_conversion_text = edq.util.json.dumps(actual_object)
199
+
200
+ self.assertEqual(object_content, actual_object)
201
+ self.assertEqual(actual_text, double_conversion_text)
202
+
203
+ def _subtest_load_object_path(self, text_content, object_content):
204
+ temp_dir = edq.util.dirent.get_temp_dir(prefix = 'edq_test_json_object_path_')
205
+
206
+ path_text = os.path.join(temp_dir, 'test-text.json')
207
+ path_object = os.path.join(temp_dir, 'test-object.json')
208
+
209
+ edq.util.dirent.write_file(path_text, text_content)
210
+ text_load = edq.util.json.load_object_path(path_text, _TestConverter)
211
+
212
+ edq.util.json.dump_path(object_content, path_object)
213
+ object_load = edq.util.json.load_object_path(path_object, _TestConverter)
214
+
215
+ self.assertEqual(object_content, text_load)
216
+ self.assertEqual(object_load, text_load)
217
+
218
+ class _TestConverter(edq.util.json.DictConverter):
219
+ def __init__(self, a: typing.Union[int, None] = None, b: typing.Union[str, None] = None, **kwargs) -> None:
220
+ self.a: typing.Union[int, None] = a
221
+ self.b: typing.Union[str, None] = b
222
+
223
+ def to_dict(self) -> typing.Dict[str, typing.Any]:
224
+ return vars(self)
225
+
226
+ @classmethod
227
+ def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
228
+ return _TestConverter(**data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edq-utils
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Common utilities used by EduLinq Python projects.
5
5
  Author-email: Eriq Augustine <eriq@edulinq.org>
6
6
  License: MIT License
@@ -37,6 +37,7 @@ License-File: LICENSE
37
37
  Requires-Dist: json5>=0.9.14
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: mypy>=1.14.1; extra == "dev"
40
+ Requires-Dist: pdoc>=14.7.0; extra == "dev"
40
41
  Requires-Dist: pylint; extra == "dev"
41
42
  Requires-Dist: twine; extra == "dev"
42
43
  Requires-Dist: vermin; extra == "dev"
@@ -4,6 +4,8 @@ pyproject.toml
4
4
  requirements-dev.txt
5
5
  requirements.txt
6
6
  edq/__init__.py
7
+ edq/py.typed
8
+ edq/testing/__init__.py
7
9
  edq/testing/run.py
8
10
  edq/testing/unittest.py
9
11
  edq/util/__init__.py
@@ -2,6 +2,7 @@ json5>=0.9.14
2
2
 
3
3
  [dev]
4
4
  mypy>=1.14.1
5
+ pdoc>=14.7.0
5
6
  pylint
6
7
  twine
7
8
  vermin
@@ -1,4 +1,5 @@
1
1
  mypy>=1.14.1
2
+ pdoc>=14.7.0
2
3
  pylint
3
4
  twine
4
5
  vermin
@@ -1 +0,0 @@
1
- __version__ = '0.0.2'
@@ -1,80 +0,0 @@
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)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes