pafex 2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pafex-2.3/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 pafex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pafex-2.3/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: pafex
3
+ Version: 2.3
4
+ Summary: Minimal checksummed binary configuration handling.
5
+ Author-email: redomaitic <redomaitic@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ Minimal checksummed binary configuration handling for Python. `Config`
15
+ layers user settings over `DEFAULT_CONFIG`; `load_config`/`save_config`
16
+ persist to a strict binary format with optional gzip/xz/zlib compression.
17
+ Standard library only.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install pafex
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from pafex import Config, load_config, save_config
29
+
30
+ cfg = Config({'strict_mode': True, 'allowed_tags': {'p', 'a'}})
31
+ cfg.get('strict_mode') # True
32
+ cfg.get('strip_whitespace') # default from DEFAULT_CONFIG
33
+ cfg.update({'max_depth': 3}) # new Config, original untouched
34
+
35
+ save_config(cfg, 'default.config', compression='gzip')
36
+ loaded = load_config('default.config')
37
+ loaded == cfg # True
38
+ ```
39
+
40
+ Config file layout:
41
+
42
+ ```
43
+ raw: [8-byte MD5 checksum][4-byte size][config]
44
+ gzip: [8-byte MD5 checksum][4-byte size][gzip.compress(config)]
45
+ xz: [8-byte MD5 checksum][4-byte size][lzma.compress(config)]
46
+ zlib: [8-byte MD5 checksum][4-byte size][zlib.compress(config)]
47
+ ```
48
+
49
+ Writes are atomic (temp file + `os.replace`). With no explicit
50
+ path, `load_config`/`save_config` locate the caller's `default.config`
51
+ (caller's module directory first, then the package directory).
52
+
53
+ Errors raise `PafexError` subclasses: `ConfigLoadError` (read/parse
54
+ failures), `ConfigChecksumError` (integrity mismatch) and
55
+ `ConfigDecompressError` (corrupt compressed payload).
pafex-2.3/README.md ADDED
@@ -0,0 +1,42 @@
1
+ Minimal checksummed binary configuration handling for Python. `Config`
2
+ layers user settings over `DEFAULT_CONFIG`; `load_config`/`save_config`
3
+ persist to a strict binary format with optional gzip/xz/zlib compression.
4
+ Standard library only.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install pafex
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```python
15
+ from pafex import Config, load_config, save_config
16
+
17
+ cfg = Config({'strict_mode': True, 'allowed_tags': {'p', 'a'}})
18
+ cfg.get('strict_mode') # True
19
+ cfg.get('strip_whitespace') # default from DEFAULT_CONFIG
20
+ cfg.update({'max_depth': 3}) # new Config, original untouched
21
+
22
+ save_config(cfg, 'default.config', compression='gzip')
23
+ loaded = load_config('default.config')
24
+ loaded == cfg # True
25
+ ```
26
+
27
+ Config file layout:
28
+
29
+ ```
30
+ raw: [8-byte MD5 checksum][4-byte size][config]
31
+ gzip: [8-byte MD5 checksum][4-byte size][gzip.compress(config)]
32
+ xz: [8-byte MD5 checksum][4-byte size][lzma.compress(config)]
33
+ zlib: [8-byte MD5 checksum][4-byte size][zlib.compress(config)]
34
+ ```
35
+
36
+ Writes are atomic (temp file + `os.replace`). With no explicit
37
+ path, `load_config`/`save_config` locate the caller's `default.config`
38
+ (caller's module directory first, then the package directory).
39
+
40
+ Errors raise `PafexError` subclasses: `ConfigLoadError` (read/parse
41
+ failures), `ConfigChecksumError` (integrity mismatch) and
42
+ `ConfigDecompressError` (corrupt compressed payload).
@@ -0,0 +1,29 @@
1
+ """pafex: minimal checksummed binary configuration handling."""
2
+
3
+ from .exceptions import (
4
+ ConfigChecksumError,
5
+ ConfigDecompressError,
6
+ ConfigLoadError,
7
+ PafexError,
8
+ )
9
+ from .config import (
10
+ CONFIG_NAME,
11
+ DEFAULT_CONFIG,
12
+ Config,
13
+ find_default_config,
14
+ load_config,
15
+ save_config,
16
+ )
17
+
18
+ __all__ = [
19
+ 'Config',
20
+ 'DEFAULT_CONFIG',
21
+ 'CONFIG_NAME',
22
+ 'load_config',
23
+ 'save_config',
24
+ 'find_default_config',
25
+ 'PafexError',
26
+ 'ConfigLoadError',
27
+ 'ConfigChecksumError',
28
+ 'ConfigDecompressError',
29
+ ]
@@ -0,0 +1,255 @@
1
+ """Configuration loading and persistence for the pafex package.
2
+
3
+ Config files use a strict binary layout:
4
+
5
+ raw: [8-byte MD5 checksum][4-byte size][payload]
6
+ gzip: [8-byte MD5 checksum][4-byte size][gzip.compress(payload)]
7
+ xz: [8-byte MD5 checksum][4-byte size][lzma.compress(payload)]
8
+ zlib: [8-byte MD5 checksum][4-byte size][zlib.compress(payload)]
9
+ """
10
+
11
+ import gzip
12
+ import hashlib
13
+ import importlib.util
14
+ import lzma
15
+ import os
16
+ import pickle
17
+ import sys
18
+ import tempfile
19
+ import types
20
+ import zlib
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Mapping, Optional, Tuple, Union
23
+
24
+ from .exceptions import ConfigChecksumError, ConfigDecompressError, ConfigLoadError
25
+
26
+ PathLike = Union[str, os.PathLike]
27
+
28
+ DEFAULT_CONFIG: Mapping[str, Any] = types.MappingProxyType({
29
+ 'parse_tables': True,
30
+ 'strip_whitespace': True,
31
+ 'encoding': 'utf-8',
32
+ 'max_depth': 100,
33
+ 'default_tag': 'div',
34
+ 'preserve_comments': False,
35
+ 'strict_mode': False,
36
+ 'remove_comments': True,
37
+ 'allowed_tags': frozenset({'p', 'br', 'b', 'i', 'a', 'ul', 'ol', 'li', 'div', 'span'}),
38
+ 'max_file_size_mb': 10,
39
+ 'cache_size': 50,
40
+ })
41
+
42
+ _CHECKSUM_SIZE = 8
43
+ _SIZE_SIZE = 4
44
+ _HEADER_SIZE = _CHECKSUM_SIZE + _SIZE_SIZE
45
+
46
+
47
+ def _checksum(payload: bytes) -> bytes:
48
+ """First _CHECKSUM_SIZE bytes of the MD5 digest of payload."""
49
+ return hashlib.md5(payload).digest()[:_CHECKSUM_SIZE]
50
+
51
+
52
+ def _detect_compression(data: bytes) -> Optional[Tuple[str, Any]]:
53
+ """Detect compression format by magic bytes. Returns (name, module) or None.
54
+
55
+ zlib has no magic number; it is detected last by validating its CMF/FLG
56
+ header (deflate method, CINFO <= 7, no preset dictionary, header value
57
+ divisible by 31).
58
+ """
59
+ if data[:2] == b'\x1f\x8b':
60
+ return 'gzip', gzip
61
+ if data[:6] == b'\xfd7zXZ\x00':
62
+ return 'xz', lzma
63
+ if (
64
+ len(data) >= 2
65
+ and data[0] & 0x0F == 8
66
+ and data[0] >> 4 <= 7
67
+ and not data[1] & 0x20
68
+ and ((data[0] << 8) | data[1]) % 31 == 0
69
+ ):
70
+ return 'zlib', zlib
71
+ return None
72
+
73
+
74
+ def _verify_header(data: bytes, path: PathLike) -> bytes:
75
+ """Verify the 8-byte MD5 and 4-byte size header; return the payload."""
76
+ if len(data) < _HEADER_SIZE:
77
+ raise ConfigLoadError(
78
+ f"config file '{path}' is too short to be a valid config file"
79
+ )
80
+ stored = data[:_CHECKSUM_SIZE]
81
+ size = int.from_bytes(data[_CHECKSUM_SIZE:_HEADER_SIZE], 'big')
82
+ payload = data[_HEADER_SIZE:]
83
+ if len(payload) != size:
84
+ raise ConfigLoadError(
85
+ f"size mismatch in config file '{path}': file truncated or "
86
+ f"tampered with; expected {size} payload bytes, got {len(payload)}"
87
+ )
88
+ if _checksum(payload) != stored:
89
+ raise ConfigChecksumError(
90
+ f"checksum mismatch in config file '{path}': file may be corrupted "
91
+ "or tampered with; files written by older versions are not accepted"
92
+ )
93
+ return payload
94
+
95
+
96
+ def _sanitize_value(value: Any) -> Any:
97
+ """Recursively verify that a value only contains allowed plain types."""
98
+ if isinstance(value, (str, bytes, int, float, bool, type(None))):
99
+ return value
100
+ if isinstance(value, dict):
101
+ return {str(key): _sanitize_value(item) for key, item in value.items()}
102
+ if isinstance(value, (list, tuple)):
103
+ return [_sanitize_value(item) for item in value]
104
+ if isinstance(value, (set, frozenset)):
105
+ return {_sanitize_value(item) for item in value}
106
+ raise ConfigLoadError(
107
+ f'disallowed type {type(value).__name__!r} in config file; '
108
+ 'only plain data types are allowed'
109
+ )
110
+
111
+
112
+ class Config:
113
+ """Configuration holder that layers user settings over DEFAULT_CONFIG."""
114
+
115
+ def __init__(self, settings: Optional[Dict[str, Any]] = None):
116
+ self._settings: Dict[str, Any] = {**DEFAULT_CONFIG, **(settings or {})}
117
+
118
+ def get(self, key: str, default: Any = None) -> Any:
119
+ return self._settings.get(key, default)
120
+
121
+ def update(self, settings: Dict[str, Any]) -> 'Config':
122
+ """Return a new Config with extra settings layered on top of this one."""
123
+ return Config({**self._settings, **(settings or {})})
124
+
125
+ def to_dict(self) -> Dict[str, Any]:
126
+ return dict(self._settings)
127
+
128
+ @classmethod
129
+ def load_default(cls) -> 'Config':
130
+ """Return a Config built from DEFAULT_CONFIG without reading any file."""
131
+ return cls()
132
+
133
+ def __eq__(self, other: Any) -> bool:
134
+ if isinstance(other, Config):
135
+ return self._settings == other._settings
136
+ return NotImplemented
137
+
138
+ def __repr__(self) -> str:
139
+ return f'Config({self._settings})'
140
+
141
+
142
+ def _read_config(path: PathLike) -> Dict[str, Any]:
143
+ """Read config bytes from path."""
144
+ try:
145
+ with open(path, 'rb') as handle:
146
+ data = handle.read()
147
+ except Exception as exc:
148
+ raise ConfigLoadError(f"failed to read config file '{path}': {exc}") from exc
149
+
150
+ blob = _verify_header(data, path)
151
+
152
+ detected = _detect_compression(blob)
153
+ if detected is not None:
154
+ name, module = detected
155
+ try:
156
+ blob = module.decompress(blob)
157
+ except Exception as exc:
158
+ raise ConfigDecompressError(
159
+ f"failed to decompress {name} config file '{path}': {exc}"
160
+ ) from exc
161
+
162
+ try:
163
+ loaded = pickle.loads(blob)
164
+ except Exception as exc:
165
+ raise ConfigLoadError(
166
+ f"failed to load pickle payload from '{path}': {exc}"
167
+ ) from exc
168
+ if not isinstance(loaded, dict):
169
+ raise ConfigLoadError(
170
+ f"config file '{path}' must contain a dict, "
171
+ f"got {type(loaded).__name__}"
172
+ )
173
+ return _sanitize_value(loaded)
174
+
175
+
176
+ def load_config(path: Optional[PathLike] = None) -> Config:
177
+ """Load a Config from an explicit file, or the caller's default.config."""
178
+ if path is None:
179
+ path = _locate_config(sys._getframe(1).f_globals)
180
+ return Config(_read_config(path))
181
+
182
+
183
+ def save_config(
184
+ config: Union[Config, Dict[str, Any]],
185
+ path: Optional[PathLike] = None,
186
+ compression: Optional[str] = None,
187
+ ) -> Path:
188
+ """Persist settings to path atomically; no path auto-finds default.config."""
189
+ if compression not in (None, 'gzip', 'xz', 'zlib'):
190
+ raise ValueError(
191
+ f"unsupported compression {compression!r}; expected 'gzip', 'xz', 'zlib' or None"
192
+ )
193
+
194
+ if path is None:
195
+ path = _locate_config(sys._getframe(1).f_globals)
196
+
197
+ if isinstance(config, Config):
198
+ settings = config.to_dict()
199
+ else:
200
+ settings = Config(config).to_dict()
201
+ payload = pickle.dumps(settings, protocol=pickle.HIGHEST_PROTOCOL)
202
+ if compression == 'gzip':
203
+ payload = gzip.compress(payload)
204
+ elif compression == 'xz':
205
+ payload = lzma.compress(payload)
206
+ elif compression == 'zlib':
207
+ payload = zlib.compress(payload)
208
+ blob = _checksum(payload) + len(payload).to_bytes(_SIZE_SIZE, 'big') + payload
209
+
210
+ target = Path(path)
211
+ if not target.parent.exists():
212
+ target.parent.mkdir(parents=True, exist_ok=True)
213
+
214
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), suffix='.config.tmp')
215
+ try:
216
+ with os.fdopen(fd, 'wb') as handle:
217
+ handle.write(blob)
218
+ os.replace(tmp_name, str(target))
219
+ except Exception:
220
+ try:
221
+ os.unlink(tmp_name)
222
+ except OSError:
223
+ pass
224
+ raise
225
+ return target
226
+
227
+
228
+ CONFIG_NAME = 'default.config'
229
+
230
+
231
+ def _locate_config(g: Dict[str, Any]) -> Path:
232
+ """Resolve the default.config path for a module globals mapping."""
233
+ file = g.get('__file__')
234
+ if file:
235
+ candidate = Path(os.path.abspath(file)).parent / CONFIG_NAME
236
+ if candidate.exists():
237
+ return candidate
238
+ package = g.get('__package__') or g.get('__name__', '').partition('.')[0]
239
+ if package:
240
+ try:
241
+ spec = importlib.util.find_spec(package)
242
+ except (ImportError, AttributeError, ValueError):
243
+ spec = None
244
+ if spec and spec.submodule_search_locations:
245
+ return Path(spec.submodule_search_locations[0]) / CONFIG_NAME
246
+ raise ConfigLoadError(f'no {CONFIG_NAME} found for caller')
247
+
248
+
249
+ def find_default_config() -> Path:
250
+ """Locate the caller's default.config without loading it.
251
+
252
+ Searches the caller's module directory first, then the caller's
253
+ package directory. Raises ConfigLoadError when neither can be resolved.
254
+ """
255
+ return _locate_config(sys._getframe(1).f_globals)
@@ -0,0 +1,17 @@
1
+ """Custom exceptions for pafex configuration."""
2
+
3
+
4
+ class PafexError(Exception):
5
+ """Base exception for all pafex errors."""
6
+
7
+
8
+ class ConfigLoadError(PafexError):
9
+ """Raised when configuration cannot be loaded."""
10
+
11
+
12
+ class ConfigChecksumError(ConfigLoadError):
13
+ """Raised when a config file's checksum header does not match its payload."""
14
+
15
+
16
+ class ConfigDecompressError(ConfigLoadError):
17
+ """Raised when a compressed config file cannot be decompressed."""
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: pafex
3
+ Version: 2.3
4
+ Summary: Minimal checksummed binary configuration handling.
5
+ Author-email: redomaitic <redomaitic@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ Minimal checksummed binary configuration handling for Python. `Config`
15
+ layers user settings over `DEFAULT_CONFIG`; `load_config`/`save_config`
16
+ persist to a strict binary format with optional gzip/xz/zlib compression.
17
+ Standard library only.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install pafex
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from pafex import Config, load_config, save_config
29
+
30
+ cfg = Config({'strict_mode': True, 'allowed_tags': {'p', 'a'}})
31
+ cfg.get('strict_mode') # True
32
+ cfg.get('strip_whitespace') # default from DEFAULT_CONFIG
33
+ cfg.update({'max_depth': 3}) # new Config, original untouched
34
+
35
+ save_config(cfg, 'default.config', compression='gzip')
36
+ loaded = load_config('default.config')
37
+ loaded == cfg # True
38
+ ```
39
+
40
+ Config file layout:
41
+
42
+ ```
43
+ raw: [8-byte MD5 checksum][4-byte size][config]
44
+ gzip: [8-byte MD5 checksum][4-byte size][gzip.compress(config)]
45
+ xz: [8-byte MD5 checksum][4-byte size][lzma.compress(config)]
46
+ zlib: [8-byte MD5 checksum][4-byte size][zlib.compress(config)]
47
+ ```
48
+
49
+ Writes are atomic (temp file + `os.replace`). With no explicit
50
+ path, `load_config`/`save_config` locate the caller's `default.config`
51
+ (caller's module directory first, then the package directory).
52
+
53
+ Errors raise `PafexError` subclasses: `ConfigLoadError` (read/parse
54
+ failures), `ConfigChecksumError` (integrity mismatch) and
55
+ `ConfigDecompressError` (corrupt compressed payload).
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pafex/__init__.py
5
+ pafex/config.py
6
+ pafex/exceptions.py
7
+ pafex.egg-info/PKG-INFO
8
+ pafex.egg-info/SOURCES.txt
9
+ pafex.egg-info/dependency_links.txt
10
+ pafex.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ pafex
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.1.1", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pafex"
7
+ version = "2.3"
8
+ description = "Minimal checksummed binary configuration handling."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "redomaitic", email = "redomaitic@gmail.com"}
13
+ ]
14
+ requires-python = ">=3.8"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ ]
19
+
20
+ [tool.setuptools]
21
+ packages = ["pafex"]
pafex-2.3/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+