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 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
+
@@ -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"