mx8fs 1.0.0.6__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.6/PKG-INFO +74 -0
- mx8fs-1.0.0.6/README.md +55 -0
- mx8fs-1.0.0.6/mx8fs/__init__.py +47 -0
- mx8fs-1.0.0.6/mx8fs/cache.py +195 -0
- mx8fs-1.0.0.6/mx8fs/comparer.py +186 -0
- mx8fs-1.0.0.6/mx8fs/file_io.py +247 -0
- mx8fs-1.0.0.6/mx8fs/lock.py +235 -0
- mx8fs-1.0.0.6/mx8fs/storage.py +133 -0
- mx8fs-1.0.0.6/pyproject.toml +87 -0
mx8fs-1.0.0.6/PKG-INFO
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mx8fs
|
|
3
|
+
Version: 1.0.0.6
|
|
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
|
+
Requires-Dist: pydantic (>=2.5.2)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# MX8 File system
|
|
20
|
+
|
|
21
|
+
This library provides environment agnostic file system access across local and AWS, including:
|
|
22
|
+
- File / IO
|
|
23
|
+
- List / Glob
|
|
24
|
+
- Locking
|
|
25
|
+
- Caching
|
|
26
|
+
- Comparing Dictionaries
|
|
27
|
+
|
|
28
|
+
# Pre-commit hooks
|
|
29
|
+
|
|
30
|
+
We use precommit to run formatting checks, so whenever you clone a project run:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pre-commit install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Before you do anything else.
|
|
37
|
+
|
|
38
|
+
You can run this at any time using:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pre-commit run --all-files
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Setting up the development environment
|
|
45
|
+
|
|
46
|
+
You can install the full dev requirements by running [setup.sh](setup.sh) to
|
|
47
|
+
1. Install the current repo and the python lib
|
|
48
|
+
1. Run the pre-commit hooks on all files
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
## Code conventions and structure
|
|
53
|
+
|
|
54
|
+
We use python type hinting with pylance and flake8 for linting. Unit tests are created to 100% branch coverage.
|
|
55
|
+
|
|
56
|
+
Key libraries are Boto3 and FastAPI. Full details are in [requirements.txt](requirements.txt).
|
|
57
|
+
|
|
58
|
+
The code is structured as follows:
|
|
59
|
+
|
|
60
|
+
- The [mx8fs](mx8fs) folder contains the full library.
|
|
61
|
+
- Tests are stored in the [tests](test) folder and run using pytest.
|
|
62
|
+
|
|
63
|
+
* The github actions are in [main.yam](.github/workflows/main.yml)
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
Copyright © 2025 MX8 Labs
|
|
68
|
+
|
|
69
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 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 of the Software.
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
74
|
+
|
mx8fs-1.0.0.6/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
Key libraries are Boto3 and FastAPI. Full details are in [requirements.txt](requirements.txt).
|
|
39
|
+
|
|
40
|
+
The code is structured as follows:
|
|
41
|
+
|
|
42
|
+
- The [mx8fs](mx8fs) folder contains the full library.
|
|
43
|
+
- Tests are stored in the [tests](test) folder and run using pytest.
|
|
44
|
+
|
|
45
|
+
* The github actions are in [main.yam](.github/workflows/main.yml)
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
Copyright © 2025 MX8 Labs
|
|
50
|
+
|
|
51
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
54
|
+
|
|
55
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
@@ -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,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache decorator for the MX8 AI API.
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2023-2025 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
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import pickle
|
|
15
|
+
from json.decoder import JSONDecodeError
|
|
16
|
+
from time import time
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from .file_io import BinaryFileHandler, read_file, write_file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_cache_filename(path: str, name: str, extension: str, expiration_seconds: int = 0, **kwargs: Dict) -> str:
|
|
23
|
+
"""Create an optionally time expiring cache filename using hashed parameters."""
|
|
24
|
+
|
|
25
|
+
# Create hashes based on the name and parameter hashes
|
|
26
|
+
args = kwargs.pop("extra_args", ())
|
|
27
|
+
param_hash = hashlib.sha256(pickle.dumps((args, kwargs))).hexdigest()
|
|
28
|
+
|
|
29
|
+
# If the cache is set to expire, add the current epoch rounded down
|
|
30
|
+
# to the nearest expiration_seconds to the hash
|
|
31
|
+
if expiration_seconds > 0:
|
|
32
|
+
epoch = int(int(time()))
|
|
33
|
+
time_hash = "_" + str(epoch - epoch % expiration_seconds)
|
|
34
|
+
else:
|
|
35
|
+
time_hash = ""
|
|
36
|
+
|
|
37
|
+
# Create the filename for the cache
|
|
38
|
+
return f"{path}/{name}_{param_hash}{time_hash}.cache.{extension}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_clean_kwargs(kwargs: Dict, ignore_kwargs: Optional[List[str]]) -> Dict:
|
|
42
|
+
"""Remove ignored kwargs from the kwargs dict"""
|
|
43
|
+
|
|
44
|
+
clean_kwargs = kwargs.copy()
|
|
45
|
+
for ignore in ignore_kwargs or []:
|
|
46
|
+
clean_kwargs.pop(ignore, None)
|
|
47
|
+
|
|
48
|
+
return clean_kwargs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _do_logging(
|
|
52
|
+
log_group: str,
|
|
53
|
+
result: Any,
|
|
54
|
+
args: Tuple,
|
|
55
|
+
kwargs: Dict,
|
|
56
|
+
filename: str,
|
|
57
|
+
func: Callable,
|
|
58
|
+
expiration_seconds: int,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Log cache hit"""
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
result = json.loads(result)
|
|
64
|
+
except (JSONDecodeError, TypeError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
if log_group:
|
|
68
|
+
try:
|
|
69
|
+
logging.getLogger(log_group).info(
|
|
70
|
+
"Cache hit",
|
|
71
|
+
extra={
|
|
72
|
+
"cache_result": result,
|
|
73
|
+
"cache_args": args,
|
|
74
|
+
"cache_kwargs": kwargs,
|
|
75
|
+
"cache_filename": filename,
|
|
76
|
+
"cache_function": func.__name__,
|
|
77
|
+
"cache_expiration_seconds": expiration_seconds,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
except TypeError: # pragma: no cover
|
|
81
|
+
# If we get a type error, case the dangerous types to strings
|
|
82
|
+
logging.getLogger(log_group).info(
|
|
83
|
+
"Cache hit",
|
|
84
|
+
extra={
|
|
85
|
+
"cache_result": str(result),
|
|
86
|
+
"cache_args": args,
|
|
87
|
+
"cache_kwargs": str(kwargs),
|
|
88
|
+
"cache_filename": filename,
|
|
89
|
+
"cache_function": func.__name__,
|
|
90
|
+
"cache_expiration_seconds": expiration_seconds,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cache_to_disk_binary(
|
|
96
|
+
path: str,
|
|
97
|
+
expiration_seconds: int = 0,
|
|
98
|
+
log_group: str = "",
|
|
99
|
+
ignore_kwargs: Optional[List[str]] = None,
|
|
100
|
+
) -> Callable[..., Callable[..., Any]]:
|
|
101
|
+
"""Cache decorator for any MX8 functions.
|
|
102
|
+
|
|
103
|
+
This decorator will cache the result of the function to disk, and return
|
|
104
|
+
the cached result on subsequent calls. This is useful for caching the
|
|
105
|
+
results of expensive operations, such as calling the AI API
|
|
106
|
+
|
|
107
|
+
Parameters:
|
|
108
|
+
path: The path to the cache directory
|
|
109
|
+
expiration_seconds: The number of seconds before the cache expires
|
|
110
|
+
log_group: The log group to log cache hits to
|
|
111
|
+
ignore_kwargs: A list of kwargs to ignore when creating the cache key
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def decorator(func: Callable) -> Callable[..., Any]:
|
|
115
|
+
def wrapper(*args: Tuple, **kwargs: Dict) -> Any:
|
|
116
|
+
clean_kwargs = _get_clean_kwargs(kwargs, ignore_kwargs)
|
|
117
|
+
|
|
118
|
+
filename = get_cache_filename(
|
|
119
|
+
path,
|
|
120
|
+
func.__name__,
|
|
121
|
+
"pickle",
|
|
122
|
+
expiration_seconds,
|
|
123
|
+
extra_args=args, # type: ignore
|
|
124
|
+
**clean_kwargs,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
# Try to read the cached result from disk
|
|
129
|
+
with BinaryFileHandler(filename) as file_handler:
|
|
130
|
+
result = pickle.load(file_handler)
|
|
131
|
+
|
|
132
|
+
_do_logging(log_group, result, args, clean_kwargs, filename, func, expiration_seconds)
|
|
133
|
+
|
|
134
|
+
except FileNotFoundError:
|
|
135
|
+
# Cache miss, execute the function and save the result to disk
|
|
136
|
+
result = func(*args, **kwargs)
|
|
137
|
+
with BinaryFileHandler(filename, "wb") as file_handler:
|
|
138
|
+
pickle.dump(result, file_handler)
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
return wrapper
|
|
143
|
+
|
|
144
|
+
return decorator
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cache_to_disk(
|
|
148
|
+
path: str,
|
|
149
|
+
expiration_seconds: int = 0,
|
|
150
|
+
log_group: str = "",
|
|
151
|
+
ignore_kwargs: Optional[List[str]] = None,
|
|
152
|
+
) -> Callable[..., Callable[..., str | Any]]:
|
|
153
|
+
"""Cache decorator for any MX8 functions.
|
|
154
|
+
|
|
155
|
+
This decorator will cache the result of the function to disk, and return
|
|
156
|
+
the cached result on subsequent calls. This is useful for caching the
|
|
157
|
+
results of expensive operations, such as calling the AI API
|
|
158
|
+
|
|
159
|
+
Parameters:
|
|
160
|
+
path: The path to the cache directory
|
|
161
|
+
expiration_seconds: The number of seconds before the cache expires
|
|
162
|
+
log_group: The log group to log cache hits to
|
|
163
|
+
ignore_kwargs: A list of kwargs to ignore when creating the cache key
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def decorator(func: Callable) -> Callable[..., str | Any]:
|
|
167
|
+
def wrapper(*args: Tuple, **kwargs: Dict) -> str | Any:
|
|
168
|
+
|
|
169
|
+
clean_kwargs = _get_clean_kwargs(kwargs, ignore_kwargs)
|
|
170
|
+
|
|
171
|
+
filename = get_cache_filename(
|
|
172
|
+
path,
|
|
173
|
+
func.__name__,
|
|
174
|
+
"txt",
|
|
175
|
+
expiration_seconds,
|
|
176
|
+
extra_args=args, # type: ignore
|
|
177
|
+
**clean_kwargs,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Try to read the cached result from disk
|
|
182
|
+
result = read_file(filename)
|
|
183
|
+
|
|
184
|
+
_do_logging(log_group, result, args, clean_kwargs, filename, func, expiration_seconds)
|
|
185
|
+
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
# Cache miss, execute the function and save the result to disk
|
|
188
|
+
result = func(*args, **kwargs)
|
|
189
|
+
write_file(filename, result)
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
return wrapper
|
|
194
|
+
|
|
195
|
+
return decorator
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Support functions for testing
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2023-2025 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
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
from difflib import ndiff
|
|
15
|
+
from logging import getLogger
|
|
16
|
+
from tempfile import NamedTemporaryFile
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from mx8fs import read_file, write_file
|
|
21
|
+
|
|
22
|
+
logger = getLogger("mx8.comparer")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_diff(a: str, b: str) -> str:
|
|
26
|
+
return "\n".join(d for d in ndiff(a.splitlines(), b.splitlines()) if not d.startswith(" "))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Differences:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._differences: List[Dict[str, str]] = []
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return json.dumps(self._differences, indent=4)
|
|
35
|
+
|
|
36
|
+
def __eq__(self, value: object) -> bool:
|
|
37
|
+
return self._differences == value
|
|
38
|
+
|
|
39
|
+
def __bool__(self) -> bool:
|
|
40
|
+
return bool(self._differences)
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
return len(self._differences)
|
|
44
|
+
|
|
45
|
+
def append(self, differences: Dict[str, str]) -> None:
|
|
46
|
+
self._differences.append(differences)
|
|
47
|
+
|
|
48
|
+
def clear(self) -> None:
|
|
49
|
+
self._differences.clear()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def keys(self) -> List[str]:
|
|
53
|
+
return [list(d.keys())[0] for d in self._differences]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResultsComparer:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
ignore_keys: Optional[List[str]],
|
|
60
|
+
create_test_data: bool = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._ignore_keys = ignore_keys if ignore_keys else []
|
|
63
|
+
self._create_test_data = create_test_data
|
|
64
|
+
self._differences = Differences()
|
|
65
|
+
|
|
66
|
+
def _log_differences(self, key: str, correct: str, test: str) -> None:
|
|
67
|
+
"""Log the differences between two strings"""
|
|
68
|
+
if correct != test:
|
|
69
|
+
self._differences.append({key: get_diff(correct, test)})
|
|
70
|
+
|
|
71
|
+
def _compare_dicts(self, correct: Any, test: Any, recursive: bool = False, root_key: str = "root") -> None:
|
|
72
|
+
"""
|
|
73
|
+
Compare two dictionaries recursively, ignoring elements with the given key
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if not recursive:
|
|
77
|
+
logger.debug(
|
|
78
|
+
{
|
|
79
|
+
"message": "Comparing dictionaries",
|
|
80
|
+
"dict1": correct,
|
|
81
|
+
"dict2": test,
|
|
82
|
+
"ignore_keys": self._ignore_keys,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if isinstance(correct, list) and isinstance(test, list):
|
|
87
|
+
if len(correct) != len(test):
|
|
88
|
+
self._log_differences(root_key, json.dumps(correct), json.dumps(test))
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
for i, the_dict in enumerate(correct):
|
|
92
|
+
self._compare_dicts(the_dict, test[i], recursive=True, root_key=f"{root_key}[{i}]")
|
|
93
|
+
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Check if both inputs are dictionaries
|
|
97
|
+
if not isinstance(correct, dict) or not isinstance(test, dict):
|
|
98
|
+
self._log_differences(root_key, json.dumps(correct), json.dumps(test))
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Get the set of keys for each dictionary
|
|
102
|
+
correct_keys = set(correct.keys())
|
|
103
|
+
test_keys = set(test.keys())
|
|
104
|
+
|
|
105
|
+
# Check if the keys are the same
|
|
106
|
+
if correct_keys != test_keys:
|
|
107
|
+
self._log_differences(root_key, json.dumps(correct), json.dumps(test))
|
|
108
|
+
else:
|
|
109
|
+
# Recursively compare the values for each key
|
|
110
|
+
for key in correct_keys:
|
|
111
|
+
if key not in self._ignore_keys:
|
|
112
|
+
self._compare_dicts(correct[key], test[key], recursive=True, root_key=f"{root_key}/{key}")
|
|
113
|
+
|
|
114
|
+
def compare_dicts(self, correct: Any, test: Any) -> Differences:
|
|
115
|
+
"""Compare two dictionaries"""
|
|
116
|
+
self._compare_dicts(correct, test)
|
|
117
|
+
return self._differences
|
|
118
|
+
|
|
119
|
+
def get_text_differences(self, test: str, correct: str) -> Differences:
|
|
120
|
+
"""Compare a test file with a correct file"""
|
|
121
|
+
if self._create_test_data:
|
|
122
|
+
# make the directory if it doesn't exist
|
|
123
|
+
os.makedirs(os.path.dirname(correct), exist_ok=True)
|
|
124
|
+
|
|
125
|
+
# copy the test file to the correct file
|
|
126
|
+
shutil.copyfile(test, correct)
|
|
127
|
+
|
|
128
|
+
differences = Differences()
|
|
129
|
+
|
|
130
|
+
if diff := get_diff(read_file(correct), read_file(test)):
|
|
131
|
+
differences.append({"file": diff})
|
|
132
|
+
|
|
133
|
+
return differences
|
|
134
|
+
|
|
135
|
+
def get_dict_differences(self, test: str, correct: str) -> Differences:
|
|
136
|
+
"""Compare a test file with a correct file"""
|
|
137
|
+
|
|
138
|
+
# Load the test file
|
|
139
|
+
test_dict = json.loads(read_file(test))
|
|
140
|
+
|
|
141
|
+
self._differences.clear()
|
|
142
|
+
if self._create_test_data:
|
|
143
|
+
try:
|
|
144
|
+
correct_dict = json.loads(read_file(correct))
|
|
145
|
+
self._compare_dicts(correct_dict, test_dict)
|
|
146
|
+
assert self._differences == [], "The files should be identical"
|
|
147
|
+
except (FileNotFoundError, AssertionError):
|
|
148
|
+
# Save the test file as the correct file
|
|
149
|
+
os.makedirs(os.path.dirname(correct), exist_ok=True)
|
|
150
|
+
write_file(correct, json.dumps(test_dict, indent=4, ensure_ascii=False).strip())
|
|
151
|
+
self._differences.clear()
|
|
152
|
+
else:
|
|
153
|
+
correct_dict = json.loads(read_file(correct))
|
|
154
|
+
self._compare_dicts(correct_dict, test_dict)
|
|
155
|
+
|
|
156
|
+
return self._differences
|
|
157
|
+
|
|
158
|
+
def get_api_response_differences(
|
|
159
|
+
self,
|
|
160
|
+
response: Any,
|
|
161
|
+
correct_file: str,
|
|
162
|
+
) -> Differences:
|
|
163
|
+
"""Check the response from the reporting API and return the differences"""
|
|
164
|
+
|
|
165
|
+
file_name = os.path.basename(correct_file)
|
|
166
|
+
|
|
167
|
+
# Write the response to a temporary file
|
|
168
|
+
try:
|
|
169
|
+
result = json.dumps(response.json(), indent=4, ensure_ascii=False)
|
|
170
|
+
compare_func = self.get_dict_differences
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
compare_func = self.get_text_differences
|
|
173
|
+
result = response.text
|
|
174
|
+
|
|
175
|
+
with NamedTemporaryFile(mode="wt", delete=False, prefix=file_name) as temp_file:
|
|
176
|
+
temp_file.write(result.strip())
|
|
177
|
+
temp_file.flush()
|
|
178
|
+
temp_file_name = temp_file.name
|
|
179
|
+
|
|
180
|
+
# Compare the response to the correct file
|
|
181
|
+
mismatches = compare_func(temp_file_name, correct_file)
|
|
182
|
+
|
|
183
|
+
# Clean up the temporary file
|
|
184
|
+
os.remove(temp_file_name)
|
|
185
|
+
|
|
186
|
+
return mismatches
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS file IO functions
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2023-2025 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
|
+
import os
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from glob import glob
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from shutil import copyfile
|
|
16
|
+
from typing import IO, Any, Dict, List, Literal, Tuple, cast
|
|
17
|
+
|
|
18
|
+
import boto3
|
|
19
|
+
from botocore.config import Config
|
|
20
|
+
|
|
21
|
+
boto_config = Config(
|
|
22
|
+
max_pool_connections=int(os.getenv("BOTO_MAX_CONNECTIONS", 10)),
|
|
23
|
+
connect_timeout=float(os.getenv("BOTO_CONNECT_TIMEOUT", 10.0)),
|
|
24
|
+
read_timeout=float(os.getenv("BOTO_READ_TIMEOUT", 10.0)),
|
|
25
|
+
retries={
|
|
26
|
+
"total_max_attempts": int(os.getenv("BOTO_MAX_RETRIES", 5)),
|
|
27
|
+
"mode": cast(Literal["legacy", "standard", "adaptive"], os.getenv("BOTO_RETRY_MODE", "standard")),
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
s3_client = boto3.client(
|
|
32
|
+
service_name="s3",
|
|
33
|
+
config=boto_config,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_bucket_key(path: str) -> Tuple[str, str]:
|
|
38
|
+
"""Get the bucket and key from a S3 path"""
|
|
39
|
+
path = path.replace("s3://", "")
|
|
40
|
+
bucket, key = path.split("/", 1)
|
|
41
|
+
return bucket, key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def file_exists(file: str) -> bool:
|
|
45
|
+
"""Check if a file exists on S3 or local storage"""
|
|
46
|
+
if file.startswith("s3://"):
|
|
47
|
+
bucket, key = get_bucket_key(file)
|
|
48
|
+
try:
|
|
49
|
+
s3_client.head_object(Bucket=bucket, Key=key)
|
|
50
|
+
return True
|
|
51
|
+
except s3_client.exceptions.ClientError:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
return os.path.exists(file)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_file(file: str) -> str:
|
|
58
|
+
"""Read a file from S3 or local storage with UTF-8 encoding"""
|
|
59
|
+
if file.startswith("s3://"):
|
|
60
|
+
bucket, key = get_bucket_key(file)
|
|
61
|
+
try:
|
|
62
|
+
return str(s3_client.get_object(Bucket=bucket, Key=key)["Body"].read().decode("utf-8"))
|
|
63
|
+
except s3_client.exceptions.NoSuchKey as exc:
|
|
64
|
+
raise FileNotFoundError(f"File {file} not found") from exc
|
|
65
|
+
else:
|
|
66
|
+
with open(file, mode="r", encoding="UTF-8") as file_io:
|
|
67
|
+
return file_io.read()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_file(file: str, data: str) -> None:
|
|
71
|
+
"""Write a file to S3 or local storage with UTF-8 encoding"""
|
|
72
|
+
if file.startswith("s3://"):
|
|
73
|
+
bucket, key = get_bucket_key(file)
|
|
74
|
+
s3_client.put_object(Bucket=bucket, Key=key, Body=data.encode("UTF-8"))
|
|
75
|
+
else:
|
|
76
|
+
os.makedirs(os.path.dirname(file), exist_ok=True)
|
|
77
|
+
with open(file, mode="w", encoding="UTF-8") as file_io:
|
|
78
|
+
file_io.write(data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def delete_file(file: str) -> None:
|
|
82
|
+
"""Delete a file from S3 or local storage"""
|
|
83
|
+
if file.startswith("s3://"):
|
|
84
|
+
bucket, key = get_bucket_key(file)
|
|
85
|
+
s3_client.delete_object(Bucket=bucket, Key=key)
|
|
86
|
+
else:
|
|
87
|
+
try:
|
|
88
|
+
os.remove(file)
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
# Ignore if the file does not exist for S3 consistency
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def copy_file(src: str, dst: str) -> None:
|
|
95
|
+
"""Copy a file from S3 or local storage"""
|
|
96
|
+
if src.startswith("s3://"):
|
|
97
|
+
src_bucket, src_key = get_bucket_key(src)
|
|
98
|
+
dst_bucket, dst_key = get_bucket_key(dst)
|
|
99
|
+
|
|
100
|
+
s3_client.copy_object(
|
|
101
|
+
Bucket=dst_bucket,
|
|
102
|
+
Key=dst_key,
|
|
103
|
+
CopySource={"Bucket": src_bucket, "Key": src_key},
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
107
|
+
copyfile(src, dst)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def move_file(src: str, dst: str) -> None:
|
|
111
|
+
"""Move a file from S3 or local storage"""
|
|
112
|
+
copy_file(src, dst)
|
|
113
|
+
delete_file(src)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def list_files(root_path: str, file_type: str, prefix: str = "") -> List[str]:
|
|
117
|
+
"""Returns a list of files from S3 or local storage with the relevant suffix and optional prefix.
|
|
118
|
+
|
|
119
|
+
The prefix signficantly improves performance for S3 by reducing the number of objects listed.
|
|
120
|
+
"""
|
|
121
|
+
if root_path.startswith("s3://"):
|
|
122
|
+
bucket, key = get_bucket_key(root_path)
|
|
123
|
+
key = key + "/" if not key.endswith("/") else key
|
|
124
|
+
continuation_token = None
|
|
125
|
+
files = []
|
|
126
|
+
|
|
127
|
+
while True:
|
|
128
|
+
boto_kwargs = {
|
|
129
|
+
"Bucket": bucket,
|
|
130
|
+
"Prefix": key + prefix,
|
|
131
|
+
"Delimiter": "/",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if continuation_token:
|
|
135
|
+
boto_kwargs["ContinuationToken"] = continuation_token
|
|
136
|
+
|
|
137
|
+
boto_response = s3_client.list_objects_v2(**boto_kwargs)
|
|
138
|
+
|
|
139
|
+
if "Contents" in boto_response:
|
|
140
|
+
files.extend(
|
|
141
|
+
[
|
|
142
|
+
obj["Key"].replace(key, "").replace(f".{file_type}", "")
|
|
143
|
+
for obj in boto_response["Contents"]
|
|
144
|
+
if obj["Key"].endswith(file_type)
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if boto_response.get("IsTruncated"): # There are more objects to list
|
|
149
|
+
continuation_token = boto_response.get("NextContinuationToken")
|
|
150
|
+
else:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
return files
|
|
154
|
+
|
|
155
|
+
return [os.path.split(f)[1][: -len(file_type) - 1] for f in glob(os.path.join(root_path, f"{prefix}*.{file_type}"))]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def most_recent_timestamp(root_path: str, file_type: str) -> float:
|
|
159
|
+
"""Returns the most recent timestamp from S3 or local storage with the suffix"""
|
|
160
|
+
if root_path.startswith("s3://"):
|
|
161
|
+
bucket, key = get_bucket_key(root_path)
|
|
162
|
+
boto_response = s3_client.list_objects_v2(Bucket=bucket, Prefix=key, Delimiter="/")
|
|
163
|
+
if "Contents" not in boto_response:
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
return max(
|
|
167
|
+
[obj["LastModified"] for obj in boto_response["Contents"] if obj["Key"].endswith(file_type)],
|
|
168
|
+
default=datetime(1970, 1, 1),
|
|
169
|
+
).timestamp()
|
|
170
|
+
|
|
171
|
+
return max(
|
|
172
|
+
[os.path.getmtime(f) for f in glob(os.path.join(root_path, f"*.{file_type}"))],
|
|
173
|
+
default=0,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_public_url(file: str, expires_in: int = 3600) -> str:
|
|
178
|
+
"""Get a signed URL for a file on S3"""
|
|
179
|
+
|
|
180
|
+
if file.startswith("s3://"):
|
|
181
|
+
bucket, key = get_bucket_key(file)
|
|
182
|
+
presigned_url = s3_client.generate_presigned_url(
|
|
183
|
+
ClientMethod="get_object",
|
|
184
|
+
Params={"Bucket": bucket, "Key": key},
|
|
185
|
+
ExpiresIn=expires_in,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return str(presigned_url)
|
|
189
|
+
|
|
190
|
+
return file
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BinaryFileHandler:
|
|
194
|
+
"""File handler for S3 or local storage"""
|
|
195
|
+
|
|
196
|
+
_buffer: IO[Any]
|
|
197
|
+
|
|
198
|
+
def __init__(self, path: str, mode: str = "rb", content_type: str | None = None):
|
|
199
|
+
"""
|
|
200
|
+
Creates the class, emulating the file object.
|
|
201
|
+
|
|
202
|
+
For S3, returns a BytesIO object for writing, and downloads the file
|
|
203
|
+
For local storage, returns a file object
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
if mode not in ["rb", "wb"]:
|
|
207
|
+
raise NotImplementedError(f"mode {mode} is not supported")
|
|
208
|
+
|
|
209
|
+
self.path = path
|
|
210
|
+
self.mode = mode
|
|
211
|
+
self.content_type = content_type
|
|
212
|
+
self.is_s3 = path.startswith("s3://")
|
|
213
|
+
|
|
214
|
+
if self.is_s3:
|
|
215
|
+
self._buffer = BytesIO()
|
|
216
|
+
else:
|
|
217
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
218
|
+
self._buffer = open( # pylint: disable=consider-using-with
|
|
219
|
+
self.path, self.mode, encoding="UTF-8" if self.mode == "w" else None
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def __enter__(self) -> BytesIO | IO:
|
|
223
|
+
"""Read from S3 and open the stream"""
|
|
224
|
+
if self.is_s3:
|
|
225
|
+
bucket, key = get_bucket_key(self.path)
|
|
226
|
+
if self.mode == "rb":
|
|
227
|
+
# Download the file from S3 to the stream
|
|
228
|
+
try:
|
|
229
|
+
s3_client.download_fileobj(Bucket=bucket, Key=key, Fileobj=self._buffer)
|
|
230
|
+
except s3_client.exceptions.ClientError as exc:
|
|
231
|
+
raise FileNotFoundError(f"File {self.path} not found") from exc
|
|
232
|
+
self._buffer.seek(0)
|
|
233
|
+
|
|
234
|
+
return self._buffer
|
|
235
|
+
|
|
236
|
+
def __exit__(self, *_: List[Any], **__: Dict[str, Any]) -> None:
|
|
237
|
+
"""Write to S3 or local storage and close the stream"""
|
|
238
|
+
if self.is_s3 and self.mode == "wb":
|
|
239
|
+
self._buffer.seek(0)
|
|
240
|
+
bucket, key = get_bucket_key(self.path)
|
|
241
|
+
s3_client.upload_fileobj(
|
|
242
|
+
Fileobj=self._buffer,
|
|
243
|
+
Bucket=bucket,
|
|
244
|
+
Key=key,
|
|
245
|
+
ExtraArgs=({"ContentType": self.content_type} if self.content_type else None),
|
|
246
|
+
)
|
|
247
|
+
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 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.info("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.info("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.info("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,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic file storage class.
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2023-2025 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
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import string
|
|
14
|
+
from typing import Any, Callable, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
from mx8fs import delete_file, file_exists, list_files, read_file, write_file
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonFileStorage:
|
|
20
|
+
"""A storage class for JSON serializable pydantic models."""
|
|
21
|
+
|
|
22
|
+
_extension: str
|
|
23
|
+
_key_field: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_path: str, randomizer: Optional[Callable[[], None]] = None) -> None:
|
|
26
|
+
self.base_path = base_path
|
|
27
|
+
|
|
28
|
+
if "AWS_LAMBDA_FUNCTION_NAME" in os.environ and randomizer is None:
|
|
29
|
+
raise ValueError("Randomizer must be provided in AWS Lambda environment")
|
|
30
|
+
|
|
31
|
+
self.randomizer = randomizer or random.seed
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _json_to_model(json: str) -> Any: # pragma: no cover
|
|
35
|
+
raise NotImplementedError()
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _dict_to_model(json: dict) -> Any: # pragma: no cover
|
|
39
|
+
raise NotImplementedError()
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _model_to_json(content: Any) -> str: # pragma: no cover
|
|
43
|
+
raise NotImplementedError()
|
|
44
|
+
|
|
45
|
+
def _get_unique_key(self, key_length: int = 8) -> str:
|
|
46
|
+
"""Create a eight letter unique key. This gives us nearly 3 trillion possibities"""
|
|
47
|
+
|
|
48
|
+
self.randomizer()
|
|
49
|
+
|
|
50
|
+
# Generate a random key
|
|
51
|
+
key: str = "".join(random.choices(string.ascii_uppercase + string.digits, k=key_length)) # NOSONAR
|
|
52
|
+
|
|
53
|
+
# If the key already exists, try again
|
|
54
|
+
if file_exists(self._get_path(key)):
|
|
55
|
+
return self._get_unique_key(key_length)
|
|
56
|
+
|
|
57
|
+
return key
|
|
58
|
+
|
|
59
|
+
def list(self) -> List[str]:
|
|
60
|
+
"""List files in storage."""
|
|
61
|
+
|
|
62
|
+
return list_files(self.base_path, self._extension)
|
|
63
|
+
|
|
64
|
+
def read(self, key: str) -> Any:
|
|
65
|
+
"""Read a file from storage."""
|
|
66
|
+
|
|
67
|
+
return self._json_to_model(read_file(self._get_path(key)))
|
|
68
|
+
|
|
69
|
+
def write(self, content: Any, key: Union[str, None] = None) -> Any:
|
|
70
|
+
"""Write a file to storage."""
|
|
71
|
+
return self.write_dict(content.model_dump(), key)
|
|
72
|
+
|
|
73
|
+
def write_dict(self, content: dict, key: Union[str, None] = None) -> Any:
|
|
74
|
+
"""Write a file to storage."""
|
|
75
|
+
|
|
76
|
+
# If no key is provided, generate a unique key
|
|
77
|
+
key = key or content.get(self._key_field, None)
|
|
78
|
+
if not key:
|
|
79
|
+
key = self._get_unique_key()
|
|
80
|
+
|
|
81
|
+
# Add the key to the content
|
|
82
|
+
content[self._key_field] = key
|
|
83
|
+
content_out = self._dict_to_model(content)
|
|
84
|
+
|
|
85
|
+
# Now write the file
|
|
86
|
+
return self.update(content_out)
|
|
87
|
+
|
|
88
|
+
def update(self, content: Any) -> Any:
|
|
89
|
+
"""Update a file in storage."""
|
|
90
|
+
|
|
91
|
+
write_file(
|
|
92
|
+
self._get_path(getattr(content, self._key_field)),
|
|
93
|
+
self._model_to_json(content),
|
|
94
|
+
)
|
|
95
|
+
return content
|
|
96
|
+
|
|
97
|
+
def delete(self, key: str) -> None:
|
|
98
|
+
"""Delete a file from storage."""
|
|
99
|
+
|
|
100
|
+
delete_file(self._get_path(key))
|
|
101
|
+
|
|
102
|
+
def _get_path(self, key: str) -> str:
|
|
103
|
+
"""Get the path for a file."""
|
|
104
|
+
return os.path.join(self.base_path, f"{key}.{self._extension}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def json_file_storage_factory(extension: str, model: Any, key_field: str = "key") -> type[JsonFileStorage]:
|
|
108
|
+
"""Create a file storage class."""
|
|
109
|
+
|
|
110
|
+
cls: type[JsonFileStorage] = type(f"{model.__class__}Storage", (JsonFileStorage,), {})
|
|
111
|
+
|
|
112
|
+
def _json_to_model(json: str) -> Any:
|
|
113
|
+
"""Convert a JSON object to a model."""
|
|
114
|
+
return model.model_validate_json(json)
|
|
115
|
+
|
|
116
|
+
def _dict_to_model(json: dict) -> Any:
|
|
117
|
+
"""Convert a dictionary to a model."""
|
|
118
|
+
return model(**json)
|
|
119
|
+
|
|
120
|
+
def _model_to_json(content: Any) -> str:
|
|
121
|
+
"""Convert a model to a JSON object."""
|
|
122
|
+
if not isinstance(content, model): # pragma: no cover
|
|
123
|
+
raise ValueError(f"Expected {model}, got {type(content)}")
|
|
124
|
+
|
|
125
|
+
return str(content.model_dump_json())
|
|
126
|
+
|
|
127
|
+
setattr(cls, "_json_to_model", staticmethod(_json_to_model))
|
|
128
|
+
setattr(cls, "_dict_to_model", staticmethod(_dict_to_model))
|
|
129
|
+
setattr(cls, "_model_to_json", staticmethod(_model_to_json))
|
|
130
|
+
setattr(cls, "_extension", extension)
|
|
131
|
+
setattr(cls, "_key_field", key_field)
|
|
132
|
+
|
|
133
|
+
return cls
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "mx8fs"
|
|
3
|
+
version = "1.0.0.6"
|
|
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
|
+
pydantic = ">=2.5.2"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.dev.dependencies]
|
|
16
|
+
autopep8 = ">=2.1.0"
|
|
17
|
+
coverage = ">=7.4.4"
|
|
18
|
+
mkdocs-material = ">=9.5.18"
|
|
19
|
+
pytest-cov = ">=5.0.0"
|
|
20
|
+
pytest = ">=8.1.1"
|
|
21
|
+
pre-commit = ">=3.7.0"
|
|
22
|
+
black = ">=24.4.0"
|
|
23
|
+
mypy = ">=1.10.1"
|
|
24
|
+
isort = ">=5.13.2"
|
|
25
|
+
boto3-stubs = {version = ">=1.34.134", extras = ["s3", "s3control"]}
|
|
26
|
+
pygls = ">=1.3.1"
|
|
27
|
+
lsprotocol = ">=1.0.0"
|
|
28
|
+
pytest-mypy = ">=0.10.3"
|
|
29
|
+
flake8-pyproject = ">=1.2.3"
|
|
30
|
+
flake8 = ">=7.1.1"
|
|
31
|
+
autoflake = ">=2.3.1"
|
|
32
|
+
types-retry = "0.9.9.4"
|
|
33
|
+
pytest-xdist = "3.6.1"
|
|
34
|
+
httpx = ">=0.20.0"
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
python_version = "3.10"
|
|
38
|
+
check_untyped_defs = true
|
|
39
|
+
ignore_missing_imports = true
|
|
40
|
+
disallow_untyped_defs = true
|
|
41
|
+
warn_return_any = true
|
|
42
|
+
exclude = "(test_data|.venv|.cdk.out)"
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
norecursedirs = "tests/test_data"
|
|
46
|
+
addopts = [
|
|
47
|
+
"--cov=mx8fs",
|
|
48
|
+
"--cov-fail-under=100",
|
|
49
|
+
"--cov-branch",
|
|
50
|
+
"--cov-config=.coveragerc",
|
|
51
|
+
"--color=yes",
|
|
52
|
+
"--cov-report=lcov:coverage/lcov.info",
|
|
53
|
+
"--cov-report=term-missing:skip-covered",
|
|
54
|
+
"--disable-pytest-warnings",
|
|
55
|
+
"--durations=50",
|
|
56
|
+
"--verbose",
|
|
57
|
+
"--capture=no",
|
|
58
|
+
"--showlocals",
|
|
59
|
+
"--tb=short",
|
|
60
|
+
"-n=4",
|
|
61
|
+
]
|
|
62
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
63
|
+
|
|
64
|
+
[tool.black]
|
|
65
|
+
line-length = 120
|
|
66
|
+
target-version = ['py310']
|
|
67
|
+
include = '\.pyi?$'
|
|
68
|
+
force-exclude = "test_data|.venv"
|
|
69
|
+
|
|
70
|
+
[tool.flake8]
|
|
71
|
+
exclude = "test_data|.venv"
|
|
72
|
+
max-line-length = 120
|
|
73
|
+
max-complexity = 18
|
|
74
|
+
ignore = ["E203", "W503"]
|
|
75
|
+
|
|
76
|
+
[tool.pylint]
|
|
77
|
+
extension-pkg-whitelist = "pydantic"
|
|
78
|
+
ignore = "test_data|.venv"
|
|
79
|
+
max-line-length = 120
|
|
80
|
+
|
|
81
|
+
[tool.isort]
|
|
82
|
+
profile = "black"
|
|
83
|
+
line_length = 120
|
|
84
|
+
|
|
85
|
+
[build-system]
|
|
86
|
+
requires = ["poetry-core>=1.0.0"]
|
|
87
|
+
build-backend = "poetry.core.masonry.api"
|