mx8fs 1.0.0.1__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.
mx8fs-1.0.0.1/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.3
2
+ Name: mx8fs
3
+ Version: 1.0.0.1
4
+ Summary: MX8 Filing system
5
+ License: MIT
6
+ Author: Tom Weiss
7
+ Author-email: tom@mx8labs.com
8
+ Requires-Python: >=3.10
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: boto3 (>=1.26.137)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # MX8 File system
19
+
20
+ This library provides environment agnostic file system access across local and AWS, including:
21
+ - File / IO
22
+ - List / Glob
23
+ - Locking
24
+ - Caching
25
+ - Comparing Dictionaries
26
+
27
+ # Pre-commit hooks
28
+
29
+ We use precommit to run formatting checks, so whenever you clone a project run:
30
+
31
+ ```bash
32
+ pre-commit install
33
+ ```
34
+
35
+ Before you do anything else.
36
+
37
+ You can run this at any time using:
38
+
39
+ ```bash
40
+ pre-commit run --all-files
41
+ ```
42
+
43
+ ## Setting up the development environment
44
+
45
+ You can install the full dev requirements by running [setup.sh](setup.sh) to
46
+ 1. Install the current repo and the python lib
47
+ 1. Run the pre-commit hooks on all files
48
+
49
+ The project should open reasonable well in vs.code and includes three [launch configurations](.vscode/launch.json) for running unit tests and the debug server.
50
+
51
+ ## Code conventions and structure
52
+
53
+ We use python type hinting with pylance and flake8 for linting. Unit tests are created to 100% branch coverage.
54
+
55
+ The code is structured as follows:
56
+
57
+ - The [mx8fs](mx8fs) folder contains the full library.
58
+ - Tests are stored in the [tests](test) folder and run using pytest.
59
+
60
+ * The github actions are in [main.yam](.github/workflows/main.yml)
61
+
62
+ ## License
63
+
64
+ Copyright © 2025 MX8 Labs
65
+
66
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
67
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation
68
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
69
+ and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
70
+
71
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
72
+ of the Software.
73
+
74
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
75
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
76
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
77
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
78
+
@@ -0,0 +1,60 @@
1
+ # MX8 File system
2
+
3
+ This library provides environment agnostic file system access across local and AWS, including:
4
+ - File / IO
5
+ - List / Glob
6
+ - Locking
7
+ - Caching
8
+ - Comparing Dictionaries
9
+
10
+ # Pre-commit hooks
11
+
12
+ We use precommit to run formatting checks, so whenever you clone a project run:
13
+
14
+ ```bash
15
+ pre-commit install
16
+ ```
17
+
18
+ Before you do anything else.
19
+
20
+ You can run this at any time using:
21
+
22
+ ```bash
23
+ pre-commit run --all-files
24
+ ```
25
+
26
+ ## Setting up the development environment
27
+
28
+ You can install the full dev requirements by running [setup.sh](setup.sh) to
29
+ 1. Install the current repo and the python lib
30
+ 1. Run the pre-commit hooks on all files
31
+
32
+ The project should open reasonable well in vs.code and includes three [launch configurations](.vscode/launch.json) for running unit tests and the debug server.
33
+
34
+ ## Code conventions and structure
35
+
36
+ We use python type hinting with pylance and flake8 for linting. Unit tests are created to 100% branch coverage.
37
+
38
+ The code is structured as follows:
39
+
40
+ - The [mx8fs](mx8fs) folder contains the full library.
41
+ - Tests are stored in the [tests](test) folder and run using pytest.
42
+
43
+ * The github actions are in [main.yam](.github/workflows/main.yml)
44
+
45
+ ## License
46
+
47
+ Copyright © 2025 MX8 Labs
48
+
49
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
50
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation
51
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
52
+ and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
53
+
54
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
55
+ of the Software.
56
+
57
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
58
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
59
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
60
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,47 @@
1
+ """
2
+ MX8 - Common utilities for MX8 projects
3
+
4
+ Copyright (c) 2023 MX8 Inc, all rights reserved.
5
+
6
+ This software is confidential and proprietary information of MX8.
7
+ You shall not disclose such Confidential Information and shall use it only
8
+ in accordance with the terms of the agreement you entered into with MX8.
9
+ """
10
+
11
+ from .file_io import (
12
+ BinaryFileHandler,
13
+ read_file,
14
+ write_file,
15
+ list_files,
16
+ copy_file,
17
+ move_file,
18
+ delete_file,
19
+ file_exists,
20
+ get_public_url,
21
+ most_recent_timestamp,
22
+ )
23
+ from .cache import cache_to_disk, cache_to_disk_binary, get_cache_filename
24
+ from .lock import FileLock, Waiter
25
+ from .storage import JsonFileStorage, json_file_storage_factory
26
+ from .comparer import ResultsComparer
27
+
28
+ __all__ = [
29
+ "BinaryFileHandler",
30
+ "cache_to_disk_binary",
31
+ "cache_to_disk",
32
+ "copy_file",
33
+ "delete_file",
34
+ "file_exists",
35
+ "get_public_url",
36
+ "FileLock",
37
+ "get_cache_filename",
38
+ "json_file_storage_factory",
39
+ "JsonFileStorage",
40
+ "move_file",
41
+ "list_files",
42
+ "read_file",
43
+ "ResultsComparer",
44
+ "Waiter",
45
+ "write_file",
46
+ "most_recent_timestamp",
47
+ ]
@@ -0,0 +1,204 @@
1
+ """
2
+ Cache decorator for the MX8 AI API.
3
+
4
+ Copyright (c) 2023-2025 MX8 Inc, all rights reserved.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
7
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation
8
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
9
+ and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
12
+ of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
15
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
16
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
17
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+ """
19
+
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import pickle
24
+ from json.decoder import JSONDecodeError
25
+ from time import time
26
+ from typing import Any, Callable, Dict, List, Optional, Tuple
27
+
28
+ from .file_io import BinaryFileHandler, read_file, write_file
29
+
30
+
31
+ def get_cache_filename(path: str, name: str, extension: str, expiration_seconds: int = 0, **kwargs: Dict) -> str:
32
+ """Create an optionally time expiring cache filename using hashed parameters."""
33
+
34
+ # Create hashes based on the name and parameter hashes
35
+ args = kwargs.pop("extra_args", ())
36
+ param_hash = hashlib.sha256(pickle.dumps((args, kwargs))).hexdigest()
37
+
38
+ # If the cache is set to expire, add the current epoch rounded down
39
+ # to the nearest expiration_seconds to the hash
40
+ if expiration_seconds > 0:
41
+ epoch = int(int(time()))
42
+ time_hash = "_" + str(epoch - epoch % expiration_seconds)
43
+ else:
44
+ time_hash = ""
45
+
46
+ # Create the filename for the cache
47
+ return f"{path}/{name}_{param_hash}{time_hash}.cache.{extension}"
48
+
49
+
50
+ def _get_clean_kwargs(kwargs: Dict, ignore_kwargs: Optional[List[str]]) -> Dict:
51
+ """Remove ignored kwargs from the kwargs dict"""
52
+
53
+ clean_kwargs = kwargs.copy()
54
+ for ignore in ignore_kwargs or []:
55
+ clean_kwargs.pop(ignore, None)
56
+
57
+ return clean_kwargs
58
+
59
+
60
+ def _do_logging(
61
+ log_group: str,
62
+ result: Any,
63
+ args: Tuple,
64
+ kwargs: Dict,
65
+ filename: str,
66
+ func: Callable,
67
+ expiration_seconds: int,
68
+ ) -> None:
69
+ """Log cache hit"""
70
+
71
+ try:
72
+ result = json.loads(result)
73
+ except (JSONDecodeError, TypeError):
74
+ pass
75
+
76
+ if log_group:
77
+ try:
78
+ logging.getLogger(log_group).debug(
79
+ "Cache hit",
80
+ extra={
81
+ "cache_result": result,
82
+ "cache_args": args,
83
+ "cache_kwargs": kwargs,
84
+ "cache_filename": filename,
85
+ "cache_function": func.__name__,
86
+ "cache_expiration_seconds": expiration_seconds,
87
+ },
88
+ )
89
+ except TypeError: # pragma: no cover
90
+ # If we get a type error, case the dangerous types to strings
91
+ logging.getLogger(log_group).debug(
92
+ "Cache hit",
93
+ extra={
94
+ "cache_result": str(result),
95
+ "cache_args": args,
96
+ "cache_kwargs": str(kwargs),
97
+ "cache_filename": filename,
98
+ "cache_function": func.__name__,
99
+ "cache_expiration_seconds": expiration_seconds,
100
+ },
101
+ )
102
+
103
+
104
+ def cache_to_disk_binary(
105
+ path: str,
106
+ expiration_seconds: int = 0,
107
+ log_group: str = "",
108
+ ignore_kwargs: Optional[List[str]] = None,
109
+ ) -> Callable[..., Callable[..., Any]]:
110
+ """Cache decorator for any MX8 functions.
111
+
112
+ This decorator will cache the result of the function to disk, and return
113
+ the cached result on subsequent calls. This is useful for caching the
114
+ results of expensive operations, such as calling an AI API
115
+
116
+ Parameters:
117
+ path: The path to the cache directory
118
+ expiration_seconds: The number of seconds before the cache expires
119
+ log_group: The log group to log cache hits to
120
+ ignore_kwargs: A list of kwargs to ignore when creating the cache key
121
+ """
122
+
123
+ def decorator(func: Callable) -> Callable[..., Any]:
124
+ def wrapper(*args: Tuple, **kwargs: Dict) -> Any:
125
+ clean_kwargs = _get_clean_kwargs(kwargs, ignore_kwargs)
126
+
127
+ filename = get_cache_filename(
128
+ path,
129
+ func.__name__,
130
+ "pickle",
131
+ expiration_seconds,
132
+ extra_args=args, # type: ignore
133
+ **clean_kwargs,
134
+ )
135
+
136
+ try:
137
+ # Try to read the cached result from disk
138
+ with BinaryFileHandler(filename) as file_handler:
139
+ result = pickle.load(file_handler)
140
+
141
+ _do_logging(log_group, result, args, clean_kwargs, filename, func, expiration_seconds)
142
+
143
+ except FileNotFoundError:
144
+ # Cache miss, execute the function and save the result to disk
145
+ result = func(*args, **kwargs)
146
+ with BinaryFileHandler(filename, "wb") as file_handler:
147
+ pickle.dump(result, file_handler)
148
+
149
+ return result
150
+
151
+ return wrapper
152
+
153
+ return decorator
154
+
155
+
156
+ def cache_to_disk(
157
+ path: str,
158
+ expiration_seconds: int = 0,
159
+ log_group: str = "",
160
+ ignore_kwargs: Optional[List[str]] = None,
161
+ ) -> Callable[..., Callable[..., str | Any]]:
162
+ """Cache decorator for any MX8 functions.
163
+
164
+ This decorator will cache the result of the function to disk, and return
165
+ the cached result on subsequent calls. This is useful for caching the
166
+ results of expensive operations, such as calling an AI API
167
+
168
+ Parameters:
169
+ path: The path to the cache directory
170
+ expiration_seconds: The number of seconds before the cache expires
171
+ log_group: The log group to log cache hits to
172
+ ignore_kwargs: A list of kwargs to ignore when creating the cache key
173
+ """
174
+
175
+ def decorator(func: Callable) -> Callable[..., str | Any]:
176
+ def wrapper(*args: Tuple, **kwargs: Dict) -> str | Any:
177
+
178
+ clean_kwargs = _get_clean_kwargs(kwargs, ignore_kwargs)
179
+
180
+ filename = get_cache_filename(
181
+ path,
182
+ func.__name__,
183
+ "txt",
184
+ expiration_seconds,
185
+ extra_args=args, # type: ignore
186
+ **clean_kwargs,
187
+ )
188
+
189
+ try:
190
+ # Try to read the cached result from disk
191
+ result = read_file(filename)
192
+
193
+ _do_logging(log_group, result, args, clean_kwargs, filename, func, expiration_seconds)
194
+
195
+ except FileNotFoundError:
196
+ # Cache miss, execute the function and save the result to disk
197
+ result = func(*args, **kwargs)
198
+ write_file(filename, result)
199
+
200
+ return result
201
+
202
+ return wrapper
203
+
204
+ return decorator
@@ -0,0 +1,195 @@
1
+ """
2
+ Support functions for testing
3
+
4
+ Copyright (c) 2023-2025 MX8 Inc, all rights reserved.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
7
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation
8
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
9
+ and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
12
+ of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
15
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
16
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
17
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import shutil
23
+ from difflib import ndiff
24
+ from logging import getLogger
25
+ from tempfile import NamedTemporaryFile
26
+ from typing import Any, Dict, List, Optional
27
+
28
+
29
+ from mx8fs import read_file, write_file
30
+
31
+ logger = getLogger("mx8.comparer")
32
+
33
+
34
+ def get_diff(a: str, b: str) -> str:
35
+ return "\n".join(d for d in ndiff(a.splitlines(), b.splitlines()) if not d.startswith(" "))
36
+
37
+
38
+ class Differences:
39
+ def __init__(self) -> None:
40
+ self._differences: List[Dict[str, str]] = []
41
+
42
+ def __repr__(self) -> str:
43
+ return json.dumps(self._differences, indent=4)
44
+
45
+ def __eq__(self, value: object) -> bool:
46
+ return self._differences == value
47
+
48
+ def __bool__(self) -> bool:
49
+ return bool(self._differences)
50
+
51
+ def __len__(self) -> int:
52
+ return len(self._differences)
53
+
54
+ def append(self, differences: Dict[str, str]) -> None:
55
+ self._differences.append(differences)
56
+
57
+ def clear(self) -> None:
58
+ self._differences.clear()
59
+
60
+ @property
61
+ def keys(self) -> List[str]:
62
+ return [list(d.keys())[0] for d in self._differences]
63
+
64
+
65
+ class ResultsComparer:
66
+ def __init__(
67
+ self,
68
+ ignore_keys: Optional[List[str]],
69
+ create_test_data: bool = False,
70
+ ) -> None:
71
+ self._ignore_keys = ignore_keys if ignore_keys else []
72
+ self._create_test_data = create_test_data
73
+ self._differences = Differences()
74
+
75
+ def _log_differences(self, key: str, correct: str, test: str) -> None:
76
+ """Log the differences between two strings"""
77
+ if correct != test:
78
+ self._differences.append({key: get_diff(correct, test)})
79
+
80
+ def _compare_dicts(self, correct: Any, test: Any, recursive: bool = False, root_key: str = "root") -> None:
81
+ """
82
+ Compare two dictionaries recursively, ignoring elements with the given key
83
+ """
84
+
85
+ if not recursive:
86
+ logger.debug(
87
+ {
88
+ "message": "Comparing dictionaries",
89
+ "dict1": correct,
90
+ "dict2": test,
91
+ "ignore_keys": self._ignore_keys,
92
+ }
93
+ )
94
+
95
+ if isinstance(correct, list) and isinstance(test, list):
96
+ if len(correct) != len(test):
97
+ self._log_differences(root_key, json.dumps(correct), json.dumps(test))
98
+ return
99
+
100
+ for i, the_dict in enumerate(correct):
101
+ self._compare_dicts(the_dict, test[i], recursive=True, root_key=f"{root_key}[{i}]")
102
+
103
+ return
104
+
105
+ # Check if both inputs are dictionaries
106
+ if not isinstance(correct, dict) or not isinstance(test, dict):
107
+ self._log_differences(root_key, json.dumps(correct), json.dumps(test))
108
+ return
109
+
110
+ # Get the set of keys for each dictionary
111
+ correct_keys = set(correct.keys())
112
+ test_keys = set(test.keys())
113
+
114
+ # Check if the keys are the same
115
+ if correct_keys != test_keys:
116
+ self._log_differences(root_key, json.dumps(correct), json.dumps(test))
117
+ else:
118
+ # Recursively compare the values for each key
119
+ for key in correct_keys:
120
+ if key not in self._ignore_keys:
121
+ self._compare_dicts(correct[key], test[key], recursive=True, root_key=f"{root_key}/{key}")
122
+
123
+ def compare_dicts(self, correct: Any, test: Any) -> Differences:
124
+ """Compare two dictionaries"""
125
+ self._compare_dicts(correct, test)
126
+ return self._differences
127
+
128
+ def get_text_differences(self, test: str, correct: str) -> Differences:
129
+ """Compare a test file with a correct file"""
130
+ if self._create_test_data:
131
+ # make the directory if it doesn't exist
132
+ os.makedirs(os.path.dirname(correct), exist_ok=True)
133
+
134
+ # copy the test file to the correct file
135
+ shutil.copyfile(test, correct)
136
+
137
+ differences = Differences()
138
+
139
+ if diff := get_diff(read_file(correct), read_file(test)):
140
+ differences.append({"file": diff})
141
+
142
+ return differences
143
+
144
+ def get_dict_differences(self, test: str, correct: str) -> Differences:
145
+ """Compare a test file with a correct file"""
146
+
147
+ # Load the test file
148
+ test_dict = json.loads(read_file(test))
149
+
150
+ self._differences.clear()
151
+ if self._create_test_data:
152
+ try:
153
+ correct_dict = json.loads(read_file(correct))
154
+ self._compare_dicts(correct_dict, test_dict)
155
+ assert self._differences == [], "The files should be identical"
156
+ except (FileNotFoundError, AssertionError):
157
+ # Save the test file as the correct file
158
+ os.makedirs(os.path.dirname(correct), exist_ok=True)
159
+ write_file(correct, json.dumps(test_dict, indent=4, ensure_ascii=False).strip())
160
+ self._differences.clear()
161
+ else:
162
+ correct_dict = json.loads(read_file(correct))
163
+ self._compare_dicts(correct_dict, test_dict)
164
+
165
+ return self._differences
166
+
167
+ def get_api_response_differences(
168
+ self,
169
+ response: Any,
170
+ correct_file: str,
171
+ ) -> Differences:
172
+ """Check the response from the reporting API and return the differences"""
173
+
174
+ file_name = os.path.basename(correct_file)
175
+
176
+ # Write the response to a temporary file
177
+ try:
178
+ result = json.dumps(response.json(), indent=4, ensure_ascii=False)
179
+ compare_func = self.get_dict_differences
180
+ except json.JSONDecodeError:
181
+ compare_func = self.get_text_differences
182
+ result = response.text
183
+
184
+ with NamedTemporaryFile(mode="wt", delete=False, prefix=file_name) as temp_file:
185
+ temp_file.write(result.strip())
186
+ temp_file.flush()
187
+ temp_file_name = temp_file.name
188
+
189
+ # Compare the response to the correct file
190
+ mismatches = compare_func(temp_file_name, correct_file)
191
+
192
+ # Clean up the temporary file
193
+ os.remove(temp_file_name)
194
+
195
+ return mismatches
@@ -0,0 +1,243 @@
1
+ """
2
+ AWS file IO functions
3
+
4
+ Copyright (c) 2023-2025 MX8 Inc, all rights reserved.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
7
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation
8
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
9
+ and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
12
+ of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
15
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
16
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
17
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+ """
19
+
20
+ import os
21
+ from datetime import datetime
22
+ from glob import glob
23
+ from io import BytesIO
24
+ from shutil import copyfile
25
+ from typing import IO, Any, Dict, List, Literal, Tuple, cast
26
+
27
+ import boto3
28
+ from botocore.config import Config
29
+
30
+ boto_config = Config(
31
+ max_pool_connections=int(os.getenv("BOTO_MAX_CONNECTIONS", 50)),
32
+ connect_timeout=float(os.getenv("BOTO_CONNECT_TIMEOUT", 5.0)),
33
+ read_timeout=float(os.getenv("BOTO_READ_TIMEOUT", 840.0)), # 1 minute less than the lambda timeout
34
+ retries={
35
+ "total_max_attempts": int(os.getenv("BOTO_MAX_RETRIES", 10)),
36
+ "mode": cast(Literal["legacy", "standard", "adaptive"], os.getenv("BOTO_RETRY_MODE", "adaptive")),
37
+ },
38
+ )
39
+
40
+ s3_client = boto3.client(
41
+ service_name="s3",
42
+ config=boto_config,
43
+ )
44
+
45
+
46
+ def get_bucket_key(path: str) -> Tuple[str, str]:
47
+ """Get the bucket and key from a S3 path"""
48
+ path = path.replace("s3://", "")
49
+ bucket, key = path.split("/", 1)
50
+ return bucket, key
51
+
52
+
53
+ def file_exists(file: str) -> bool:
54
+ """Check if a file exists on S3 or local storage"""
55
+ if file.startswith("s3://"):
56
+ bucket, key = get_bucket_key(file)
57
+ try:
58
+ s3_client.head_object(Bucket=bucket, Key=key)
59
+ return True
60
+ except s3_client.exceptions.ClientError:
61
+ return False
62
+
63
+ return os.path.exists(file)
64
+
65
+
66
+ def read_file(file: str) -> str:
67
+ """Read a file from S3 or local storage with UTF-8 encoding"""
68
+ if file.startswith("s3://"):
69
+ bucket, key = get_bucket_key(file)
70
+ try:
71
+ return str(s3_client.get_object(Bucket=bucket, Key=key)["Body"].read().decode("utf-8"))
72
+ except s3_client.exceptions.NoSuchKey as exc:
73
+ raise FileNotFoundError(f"File {file} not found") from exc
74
+ else:
75
+ with open(file, mode="r", encoding="UTF-8") as file_io:
76
+ return file_io.read()
77
+
78
+
79
+ def write_file(file: str, data: str) -> None:
80
+ """Write a file to S3 or local storage with UTF-8 encoding"""
81
+ if file.startswith("s3://"):
82
+ bucket, key = get_bucket_key(file)
83
+ s3_client.put_object(Bucket=bucket, Key=key, Body=data.encode("UTF-8"))
84
+ else:
85
+ os.makedirs(os.path.dirname(file), exist_ok=True)
86
+ with open(file, mode="w", encoding="UTF-8") as file_io:
87
+ file_io.write(data)
88
+
89
+
90
+ def delete_file(file: str) -> None:
91
+ """Delete a file from S3 or local storage"""
92
+ if file.startswith("s3://"):
93
+ bucket, key = get_bucket_key(file)
94
+ s3_client.delete_object(Bucket=bucket, Key=key)
95
+ else:
96
+ try:
97
+ os.remove(file)
98
+ except FileNotFoundError:
99
+ # Ignore if the file does not exist for S3 consistency
100
+ pass
101
+
102
+
103
+ def copy_file(src: str, dst: str) -> None:
104
+ """Copy a file from S3 or local storage"""
105
+ if src.startswith("s3://"):
106
+ src_bucket, src_key = get_bucket_key(src)
107
+ dst_bucket, dst_key = get_bucket_key(dst)
108
+
109
+ try:
110
+ s3_client.copy_object(
111
+ Bucket=dst_bucket,
112
+ Key=dst_key,
113
+ CopySource={"Bucket": src_bucket, "Key": src_key},
114
+ )
115
+ except s3_client.exceptions.NoSuchKey as exc:
116
+ raise FileNotFoundError(f"File {src} not found") from exc
117
+ else:
118
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
119
+ copyfile(src, dst)
120
+
121
+
122
+ def move_file(src: str, dst: str) -> None:
123
+ """Move a file from S3 or local storage"""
124
+ copy_file(src, dst)
125
+ delete_file(src)
126
+
127
+
128
+ def list_files(root_path: str, file_type: str, prefix: str = "") -> List[str]:
129
+ """Returns a list of files from S3 or local storage with the relevant suffix and optional prefix.
130
+
131
+ The prefix signficantly improves performance for S3 by reducing the number of objects listed.
132
+ """
133
+ if root_path.startswith("s3://"):
134
+ bucket, key = get_bucket_key(root_path)
135
+ key = key + "/" if not key.endswith("/") else key
136
+
137
+ paginator = s3_client.get_paginator("list_objects_v2")
138
+ files = []
139
+
140
+ for page in paginator.paginate(Bucket=bucket, Prefix=key + prefix, PaginationConfig={"PageSize": 10_000}):
141
+ if "Contents" in page:
142
+ files.extend(
143
+ [
144
+ obj["Key"].removeprefix(key).removesuffix(f".{file_type}")
145
+ for obj in page["Contents"]
146
+ if obj["Key"].endswith(file_type)
147
+ ]
148
+ )
149
+
150
+ return files
151
+ return [os.path.split(f)[1][: -len(file_type) - 1] for f in glob(os.path.join(root_path, f"{prefix}*.{file_type}"))]
152
+
153
+
154
+ def most_recent_timestamp(root_path: str, file_type: str) -> float:
155
+ """Returns the most recent timestamp from S3 or local storage with the suffix"""
156
+ if root_path.startswith("s3://"):
157
+ bucket, key = get_bucket_key(root_path)
158
+ boto_response = s3_client.list_objects_v2(Bucket=bucket, Prefix=key, Delimiter="/")
159
+ if "Contents" not in boto_response:
160
+ return 0
161
+
162
+ return max(
163
+ [obj["LastModified"] for obj in boto_response["Contents"] if obj["Key"].endswith(file_type)],
164
+ default=datetime(1970, 1, 1),
165
+ ).timestamp()
166
+
167
+ return max(
168
+ [os.path.getmtime(f) for f in glob(os.path.join(root_path, f"*.{file_type}"))],
169
+ default=0,
170
+ )
171
+
172
+
173
+ def get_public_url(file: str, expires_in: int = 3600) -> str:
174
+ """Get a signed URL for a file on S3"""
175
+
176
+ if file.startswith("s3://"):
177
+ bucket, key = get_bucket_key(file)
178
+ presigned_url = s3_client.generate_presigned_url(
179
+ ClientMethod="get_object",
180
+ Params={"Bucket": bucket, "Key": key},
181
+ ExpiresIn=expires_in,
182
+ )
183
+
184
+ return str(presigned_url)
185
+
186
+ return file
187
+
188
+
189
+ class BinaryFileHandler:
190
+ """File handler for S3 or local storage"""
191
+
192
+ _buffer: IO[Any]
193
+
194
+ def __init__(self, path: str, mode: str = "rb", content_type: str | None = None):
195
+ """
196
+ Creates the class, emulating the file object.
197
+
198
+ For S3, returns a BytesIO object for writing, and downloads the file
199
+ For local storage, returns a file object
200
+ """
201
+
202
+ if mode not in ["rb", "wb"]:
203
+ raise NotImplementedError(f"mode {mode} is not supported")
204
+
205
+ self.path = path
206
+ self.mode = mode
207
+ self.content_type = content_type
208
+ self.is_s3 = path.startswith("s3://")
209
+
210
+ if self.is_s3:
211
+ self._buffer = BytesIO()
212
+ else:
213
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
214
+ self._buffer = open( # pylint: disable=consider-using-with
215
+ self.path, self.mode, encoding="UTF-8" if self.mode == "w" else None
216
+ )
217
+
218
+ def __enter__(self) -> BytesIO | IO:
219
+ """Read from S3 and open the stream"""
220
+ if self.is_s3:
221
+ bucket, key = get_bucket_key(self.path)
222
+ if self.mode == "rb":
223
+ # Download the file from S3 to the stream
224
+ try:
225
+ s3_client.download_fileobj(Bucket=bucket, Key=key, Fileobj=self._buffer)
226
+ except s3_client.exceptions.ClientError as exc:
227
+ raise FileNotFoundError(f"File {self.path} not found") from exc
228
+ self._buffer.seek(0)
229
+
230
+ return self._buffer
231
+
232
+ def __exit__(self, *_: List[Any], **__: Dict[str, Any]) -> None:
233
+ """Write to S3 or local storage and close the stream"""
234
+ if self.is_s3 and self.mode == "wb":
235
+ self._buffer.seek(0)
236
+ bucket, key = get_bucket_key(self.path)
237
+ s3_client.upload_fileobj(
238
+ Fileobj=self._buffer,
239
+ Bucket=bucket,
240
+ Key=key,
241
+ ExtraArgs=({"ContentType": self.content_type} if self.content_type else None),
242
+ )
243
+ self._buffer.close()
@@ -0,0 +1,235 @@
1
+ """
2
+ Locks a file across multiple process and clients.
3
+
4
+ Based on DoggoLock (https://bitbucket.org/deductive/newtools/src/master/newtools/doggo/lock.py)
5
+
6
+ Copyright (c) 2012-2025, Deductive Limited
7
+ All rights reserved.
8
+
9
+ Redistribution and use in source and binary forms, with or without modification,
10
+ are permitted provided that the following conditions are met:
11
+
12
+ * Redistributions of source code must retain the above copyright notice,
13
+ this list of conditions and the following disclaimer.
14
+ * Redistributions in binary form must reproduce the above copyright notice,
15
+ this list of conditions and the following disclaimer in the documentation
16
+ and/or other materials provided with the distribution.
17
+ * Neither the name of the Deductive Limited nor the names of
18
+ its contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
23
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
25
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
26
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
28
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+
32
+ This version
33
+
34
+ Copyright (c) 2024-2025 MX8 Inc, all rights reserved.
35
+
36
+ """
37
+
38
+ import logging
39
+ import string
40
+ from datetime import datetime, timedelta
41
+ from functools import cached_property
42
+ from random import choice
43
+ from time import sleep, time
44
+ from typing import Any, Dict, List
45
+
46
+ from mx8fs import delete_file, list_files, write_file
47
+
48
+ logger = logging.getLogger("mx8.lock")
49
+
50
+ TIME_FORMAT = "%Y%m%d%H%M%S"
51
+
52
+
53
+ class Waiter:
54
+ """
55
+
56
+ A generic wait and timeout class.
57
+
58
+ Raises TimeoutError() if the task doesn't occur in the period.
59
+
60
+ ```python
61
+
62
+ waiter = Waiter()
63
+ waiter.start_timeout()
64
+ while not check_my_condition():
65
+ waiter.check_timeout()
66
+ ```
67
+ """
68
+
69
+ def __init__(self, wait_period: float, time_out_seconds: float):
70
+ """
71
+ :param wait_period: the period to wait for between iterations
72
+ :param time_out_seconds: the time after which a TimeoutError is raised
73
+ """
74
+ self.time_out_seconds = time_out_seconds
75
+ self.wait_period = wait_period
76
+ self._timeout: float | None = None
77
+
78
+ def __enter__(self) -> "Waiter":
79
+ """
80
+ Starts the timer
81
+ """
82
+ self.start_timeout()
83
+ return self
84
+
85
+ def __exit__(self, *_: Any) -> None:
86
+ """
87
+ Stops the timer
88
+ """
89
+ self._timeout = None
90
+
91
+ def wait(self, number_times: int = 1) -> None:
92
+ """
93
+ Waits for the defined period
94
+ """
95
+ sleep(self.wait_period * number_times)
96
+
97
+ def start_timeout(self) -> None:
98
+ """
99
+ Starts a time out
100
+ """
101
+ self._timeout = time() + self.time_out_seconds
102
+
103
+ def timed_out(self) -> bool:
104
+ """
105
+ Checks for a time out
106
+
107
+ :return: true if the timer has timed out, otherwise false
108
+ """
109
+ if self._timeout is not None:
110
+ return time() > self._timeout
111
+ else:
112
+ raise ValueError("Someone has tried to call timed_out() before calling start_timeout()")
113
+
114
+ def check_timeout(self) -> None:
115
+ """
116
+ Waits, and raises an exception if the timer has timed out
117
+ """
118
+ self.wait()
119
+ if self.timed_out():
120
+ raise TimeoutError("Timed out waiting for completion")
121
+
122
+
123
+ class FileLock:
124
+ """
125
+ Implements locking using an additional file on the file system.
126
+
127
+ For file systems that are only eventually consistent, use a longer
128
+ wait_period to wait for consistency when multiple clients are reading at the same time.
129
+
130
+ Locks a file across multiple process and clients using an additional file on the file system.
131
+
132
+ The lock file has the following format:
133
+ "{file}.{timestamp}.{random}.lock"
134
+
135
+ where:
136
+
137
+ * file - is the file being locked
138
+ * timestamp - is the timestamp the lock was requested in the format %Y%m%d%H%M%S
139
+ * random - is a random set of letters
140
+
141
+ On creating the lock we
142
+
143
+ 1. Check to see if anyone already has a lock
144
+ 2. If they don't, attempt to create a lock and wait for wait_period.
145
+ 3. If two processes have attempted to get the lock then the one with the earlier lock gets it.
146
+ 4. When the lock is released the lock class deletes the lock and other processes can proceed
147
+
148
+ Locks only last for maximum_age seconds, and any request will time out after time_out_seconds
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ file: str,
154
+ wait_period: float = 0.1,
155
+ time_out_seconds: int = 840, # 1 minute less than the lambda timeout
156
+ maximum_age: int = 900, # 15 minutes, the maximum time a lambda can run
157
+ ):
158
+ """
159
+ Initializes the lock
160
+
161
+ :param file: the file to lock
162
+ :param wait_period: the period to wait before confirming file lock
163
+ :param time_out_seconds: the time out to stop waiting after
164
+ :param maximum_age: the maximum age of lock files to respect
165
+ """
166
+ self.file = file
167
+ self.waiter = Waiter(wait_period, time_out_seconds)
168
+ self.maximum_age = timedelta(seconds=maximum_age)
169
+
170
+ @cached_property
171
+ def _lock_file(self) -> str:
172
+ """Get the lock file name."""
173
+ timestamp = datetime.now().strftime(TIME_FORMAT)
174
+ random_key = "".join(choice(string.ascii_lowercase) for _ in range(12)) # NOSONAR
175
+
176
+ return f"{self.file}.{timestamp}.{random_key}.lock"
177
+
178
+ def __enter__(self) -> "FileLock":
179
+ """Acquire the lock on the file. This will wait until the lock is available."""
180
+
181
+ logger.debug("Getting lock on %s", self.file)
182
+
183
+ # If the file is locked then wait for it to be unlocked
184
+ self.waiter.start_timeout()
185
+ while len(self._get_lock_files()) > 0:
186
+ self.waiter.check_timeout()
187
+
188
+ # create a lock file
189
+ write_file(self._lock_file, "locked")
190
+
191
+ # Check an wait in case another process is trying to get the lock
192
+ self.waiter.wait()
193
+ while (
194
+ len(lock_files := self._get_lock_files()) > 1 and lock_files[0] != self._lock_file
195
+ ): # pragma: no cover - coverage is not collected for multi-process tests
196
+ try:
197
+ self.waiter.check_timeout()
198
+ except TimeoutError as ex:
199
+ delete_file(self._lock_file)
200
+ raise ex
201
+
202
+ logger.debug("Acquired lock on %s", self.file)
203
+ return self
204
+
205
+ def __exit__(self, *_: List[Any], **__: Dict[str, Any]) -> None:
206
+ """Release the lock on the file."""
207
+ delete_file(self._lock_file)
208
+ logger.debug("Released lock on %s", self.file)
209
+
210
+ def _get_lock_files(self) -> List[str]:
211
+ """Get all the lock files for the current file."""
212
+ path = "/".join(self.file.split("/")[:-1])
213
+ prefix = self.file.split("/")[-1]
214
+
215
+ # Get all the lock files in the same directory
216
+ files = [f"{path}/{file}.lock" for file in list_files(path, "lock", prefix)]
217
+
218
+ # Return the sorted current lock files for the current file
219
+ return sorted(file for file in files if self._lock_is_current(file))
220
+
221
+ def _lock_is_current(self, lock_file: str) -> bool:
222
+ """Check if the lock file is current."""
223
+
224
+ # If the lock file is not for the current file then it is not current
225
+ if self.file not in lock_file:
226
+ return False
227
+
228
+ # If we cannot parse the timestamp then it is not current
229
+ try:
230
+ timestamp = datetime.strptime(lock_file.split(".")[-3], TIME_FORMAT)
231
+ except (IndexError, ValueError):
232
+ return False
233
+
234
+ # If we are less than the maximum age then it is current
235
+ return datetime.now() < timestamp + self.maximum_age
@@ -0,0 +1,144 @@
1
+ """
2
+ Generic file storage class.
3
+
4
+ Copyright (c) 2023-2025 MX8 Inc, all rights reserved.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
7
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation
8
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
9
+ and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
12
+ of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
15
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
16
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
17
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+ """
19
+
20
+ import os
21
+ import random
22
+ import string
23
+ from typing import Any, Callable, List, Optional, Union
24
+
25
+ from mx8fs import delete_file, file_exists, list_files, read_file, write_file
26
+
27
+
28
+ class JsonFileStorage:
29
+ """A storage class for JSON serializable pydantic models."""
30
+
31
+ _extension: str
32
+ _key_field: str
33
+ _randomizer: Callable[[], None] = random.seed
34
+
35
+ def __init__(self, base_path: str, randomizer: Optional[Callable[[], None]] = None) -> None:
36
+ self.base_path = base_path
37
+ self._randomizer = randomizer or self._randomizer
38
+
39
+ if "AWS_LAMBDA_FUNCTION_NAME" in os.environ and self._randomizer == random.seed:
40
+ raise ValueError("Cannot use random.seed as a randomizer in AWS Lambda environment")
41
+
42
+ self.randomizer = randomizer or random.seed
43
+
44
+ @staticmethod
45
+ def _json_to_model(json: str) -> Any: # pragma: no cover
46
+ raise NotImplementedError()
47
+
48
+ @staticmethod
49
+ def _dict_to_model(json: dict) -> Any: # pragma: no cover
50
+ raise NotImplementedError()
51
+
52
+ @staticmethod
53
+ def _model_to_json(content: Any) -> str: # pragma: no cover
54
+ raise NotImplementedError()
55
+
56
+ def _get_unique_key(self, key_length: int = 8) -> str:
57
+ """Create a eight letter unique key. This gives us nearly 3 trillion possibities"""
58
+
59
+ self.randomizer()
60
+
61
+ # Generate a random key
62
+ key: str = "".join(random.choices(string.ascii_uppercase + string.digits, k=key_length)) # NOSONAR
63
+
64
+ # If the key already exists, try again
65
+ if file_exists(self._get_path(key)):
66
+ return self._get_unique_key(key_length)
67
+
68
+ return key
69
+
70
+ def list(self) -> List[str]:
71
+ """List files in storage."""
72
+
73
+ return list_files(self.base_path, self._extension)
74
+
75
+ def read(self, key: str) -> Any:
76
+ """Read a file from storage."""
77
+
78
+ return self._json_to_model(read_file(self._get_path(key)))
79
+
80
+ def write(self, content: Any, key: Union[str, None] = None) -> Any:
81
+ """Write a file to storage."""
82
+ return self.write_dict(content.model_dump(), key)
83
+
84
+ def write_dict(self, content: dict, key: Union[str, None] = None) -> Any:
85
+ """Write a file to storage."""
86
+
87
+ # If no key is provided, generate a unique key
88
+ key = key or content.get(self._key_field, None)
89
+ if not key:
90
+ key = self._get_unique_key()
91
+
92
+ # Add the key to the content
93
+ content[self._key_field] = key
94
+ content_out = self._dict_to_model(content)
95
+
96
+ # Now write the file
97
+ return self.update(content_out)
98
+
99
+ def update(self, content: Any) -> Any:
100
+ """Update a file in storage."""
101
+
102
+ write_file(
103
+ self._get_path(getattr(content, self._key_field)),
104
+ self._model_to_json(content),
105
+ )
106
+ return content
107
+
108
+ def delete(self, key: str) -> None:
109
+ """Delete a file from storage."""
110
+
111
+ delete_file(self._get_path(key))
112
+
113
+ def _get_path(self, key: str) -> str:
114
+ """Get the path for a file."""
115
+ return os.path.join(self.base_path, f"{key}.{self._extension}")
116
+
117
+
118
+ def json_file_storage_factory(extension: str, model: Any, key_field: str = "key") -> type[JsonFileStorage]:
119
+ """Create a file storage class."""
120
+
121
+ cls: type[JsonFileStorage] = type(f"{model.__class__}Storage", (JsonFileStorage,), {})
122
+
123
+ def _json_to_model(json: str) -> Any:
124
+ """Convert a JSON object to a model."""
125
+ return model.model_validate_json(json)
126
+
127
+ def _dict_to_model(json: dict) -> Any:
128
+ """Convert a dictionary to a model."""
129
+ return model(**json)
130
+
131
+ def _model_to_json(content: Any) -> str:
132
+ """Convert a model to a JSON object."""
133
+ if not isinstance(content, model): # pragma: no cover
134
+ raise ValueError(f"Expected {model}, got {type(content)}")
135
+
136
+ return str(content.model_dump_json())
137
+
138
+ setattr(cls, "_json_to_model", staticmethod(_json_to_model))
139
+ setattr(cls, "_dict_to_model", staticmethod(_dict_to_model))
140
+ setattr(cls, "_model_to_json", staticmethod(_model_to_json))
141
+ setattr(cls, "_extension", extension)
142
+ setattr(cls, "_key_field", key_field)
143
+
144
+ return cls
@@ -0,0 +1,81 @@
1
+ [tool.poetry]
2
+ name = "mx8fs"
3
+ version = "1.0.0.1"
4
+ description = "MX8 Filing system"
5
+ authors = ["Tom Weiss <tom@mx8labs.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ packages = [{ include = "mx8fs" }]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.10"
12
+ boto3 = ">=1.26.137"
13
+
14
+ [tool.poetry.group.dev.dependencies]
15
+ coverage = ">=7.4.4"
16
+ pydantic = ">=2.5.2"
17
+
18
+ pytest-cov = ">=5.0.0"
19
+ pytest = ">=8.1.1"
20
+ pre-commit = ">=3.7.0"
21
+ black = ">=24.4.0"
22
+ mypy = ">=1.10.1"
23
+ isort = ">=5.13.2"
24
+ boto3-stubs = {version = ">=1.34.134", extras = ["s3", "s3control"]}
25
+ pytest-mypy = ">=0.10.3"
26
+ flake8-pyproject = ">=1.2.3"
27
+ flake8 = ">=7.1.1"
28
+ autoflake = ">=2.3.1"
29
+ httpx = ">=0.20.0"
30
+
31
+ [tool.mypy]
32
+ python_version = "3.10"
33
+ check_untyped_defs = true
34
+ ignore_missing_imports = true
35
+ disallow_untyped_defs = true
36
+ warn_return_any = true
37
+ exclude = "(test_data|.venv|.cdk.out)"
38
+
39
+ [tool.pytest.ini_options]
40
+ norecursedirs = "tests/test_data"
41
+ addopts = [
42
+ "--cov=mx8fs",
43
+ "--cov-fail-under=100",
44
+ "--cov-branch",
45
+ "--cov-config=.coveragerc",
46
+ "--color=yes",
47
+ "--cov-report=lcov:coverage/lcov.info",
48
+ "--cov-report=term-missing:skip-covered",
49
+ "--disable-pytest-warnings",
50
+ "--durations=50",
51
+ "--verbose",
52
+ "--capture=no",
53
+ "--showlocals",
54
+ "--tb=short",
55
+ ]
56
+ asyncio_default_fixture_loop_scope = "function"
57
+
58
+ [tool.black]
59
+ line-length = 120
60
+ target-version = ['py310']
61
+ include = '\.pyi?$'
62
+ force-exclude = "test_data|.venv"
63
+
64
+ [tool.flake8]
65
+ exclude = "test_data|.venv"
66
+ max-line-length = 120
67
+ max-complexity = 18
68
+ ignore = ["E203", "W503"]
69
+
70
+ [tool.pylint]
71
+ extension-pkg-whitelist = "pydantic"
72
+ ignore = "test_data|.venv"
73
+ max-line-length = 120
74
+
75
+ [tool.isort]
76
+ profile = "black"
77
+ line_length = 120
78
+
79
+ [build-system]
80
+ requires = ["poetry-core>=1.0.0"]
81
+ build-backend = "poetry.core.masonry.api"