pyazrico 0.1.0__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.
- pyazrico-0.1.0/.gitignore +14 -0
- pyazrico-0.1.0/LICENSE +21 -0
- pyazrico-0.1.0/PKG-INFO +76 -0
- pyazrico-0.1.0/README.md +52 -0
- pyazrico-0.1.0/pyproject.toml +62 -0
- pyazrico-0.1.0/src/azrico/NameMatcher.py +41 -0
- pyazrico-0.1.0/src/azrico/__init__.py +8 -0
- pyazrico-0.1.0/src/azrico/classes/CapacityDict.py +17 -0
- pyazrico-0.1.0/src/azrico/classes/__init__.py +3 -0
- pyazrico-0.1.0/src/azrico/file_utils.py +35 -0
- pyazrico-0.1.0/src/azrico/parse_utils.py +23 -0
- pyazrico-0.1.0/src/azrico/py.typed +0 -0
- pyazrico-0.1.0/src/azrico/str_utils.py +91 -0
- pyazrico-0.1.0/tests/test_str_utils.py +5 -0
pyazrico-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azrideus
|
|
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.
|
pyazrico-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyazrico
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared utilities used across Azrico Python packages.
|
|
5
|
+
Author-email: Azrideus <gebraengineer@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: helpers,utilities
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
18
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
22
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyazrico
|
|
26
|
+
|
|
27
|
+
Shared utilities used across my Python packages.
|
|
28
|
+
|
|
29
|
+
Installed as `pyazrico`, imported as `azrico`.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pyazrico
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from azrico import str_utils
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Modules
|
|
44
|
+
|
|
45
|
+
| Module | Description |
|
|
46
|
+
| ----------------- | ----------------- |
|
|
47
|
+
| `azrico.str_utils` | String utilities |
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m venv .venv
|
|
53
|
+
.venv\Scripts\activate # Windows
|
|
54
|
+
# source .venv/bin/activate # macOS / Linux
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
|
|
57
|
+
pytest # run tests
|
|
58
|
+
ruff check . # lint
|
|
59
|
+
mypy # type-check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Releasing to PyPI
|
|
63
|
+
|
|
64
|
+
1. Bump `__version__` in `src/azrico/__init__.py`.
|
|
65
|
+
2. Build and upload:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
rm -rf dist
|
|
69
|
+
python -m build
|
|
70
|
+
twine check dist/*
|
|
71
|
+
twine upload dist/* # or --repository testpypi first
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
pyazrico-0.1.0/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# pyazrico
|
|
2
|
+
|
|
3
|
+
Shared utilities used across my Python packages.
|
|
4
|
+
|
|
5
|
+
Installed as `pyazrico`, imported as `azrico`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pyazrico
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from azrico import str_utils
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Modules
|
|
20
|
+
|
|
21
|
+
| Module | Description |
|
|
22
|
+
| ----------------- | ----------------- |
|
|
23
|
+
| `azrico.str_utils` | String utilities |
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python -m venv .venv
|
|
29
|
+
.venv\Scripts\activate # Windows
|
|
30
|
+
# source .venv/bin/activate # macOS / Linux
|
|
31
|
+
pip install -e ".[dev]"
|
|
32
|
+
|
|
33
|
+
pytest # run tests
|
|
34
|
+
ruff check . # lint
|
|
35
|
+
mypy # type-check
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Releasing to PyPI
|
|
39
|
+
|
|
40
|
+
1. Bump `__version__` in `src/azrico/__init__.py`.
|
|
41
|
+
2. Build and upload:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
rm -rf dist
|
|
45
|
+
python -m build
|
|
46
|
+
twine check dist/*
|
|
47
|
+
twine upload dist/* # or --repository testpypi first
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyazrico"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Shared utilities used across Azrico Python packages."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Azrideus", email = "gebraengineer@gmail.com" }]
|
|
14
|
+
keywords = ["utilities", "helpers"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8",
|
|
28
|
+
"pytest-cov>=5",
|
|
29
|
+
"ruff>=0.6",
|
|
30
|
+
"mypy>=1.10",
|
|
31
|
+
"build>=1.2",
|
|
32
|
+
"twine>=5",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# [project.urls]
|
|
36
|
+
# Homepage = "https://github.com/<user>/pyazrico"
|
|
37
|
+
# Issues = "https://github.com/<user>/pyazrico/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "src/azrico/__init__.py"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/azrico"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src/azrico", "tests", "README.md", "LICENSE"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
addopts = "-ra"
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
python_version = "3.10"
|
|
61
|
+
strict = true
|
|
62
|
+
files = ["src"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NameMatcher:
|
|
6
|
+
"""Exact match, then a unique prefix ("mon"), then a unique per-part prefix ("g-1", "g1" -> "guard-1").
|
|
7
|
+
|
|
8
|
+
Parts are runs of letters or digits; a letter part matches by prefix, a number part only in full.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def match(query: str, names: Iterable[str]) -> list[str]:
|
|
13
|
+
"""The single resolved name, or every candidate when ambiguous, or [] when nothing matches."""
|
|
14
|
+
query = query.casefold()
|
|
15
|
+
words = NameMatcher._parts(query)
|
|
16
|
+
exact: list[str] = []
|
|
17
|
+
prefix: list[str] = []
|
|
18
|
+
parts: list[str] = []
|
|
19
|
+
for name in names:
|
|
20
|
+
key = name.casefold()
|
|
21
|
+
key_words = NameMatcher._parts(key)
|
|
22
|
+
if key == query:
|
|
23
|
+
exact.append(name)
|
|
24
|
+
if key.startswith(query):
|
|
25
|
+
prefix.append(name)
|
|
26
|
+
if len(words) == len(key_words) and all(
|
|
27
|
+
map(NameMatcher._part_matches, key_words, words)
|
|
28
|
+
):
|
|
29
|
+
parts.append(name)
|
|
30
|
+
for found in (exact, prefix, parts):
|
|
31
|
+
if len(found) == 1:
|
|
32
|
+
return found
|
|
33
|
+
return sorted(set(prefix + parts))
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _parts(name: str) -> list[str]:
|
|
37
|
+
return re.findall(r"[^\W\d_]+|\d+", name)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _part_matches(key_word: str, word: str) -> bool:
|
|
41
|
+
return key_word == word if word.isdigit() else key_word.startswith(word)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CapacityDict(OrderedDict):
|
|
5
|
+
"""
|
|
6
|
+
Dict with a maximum capacity. When the capacity is exceeded, the oldest item is removed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, capacity: int, *args, **kwargs):
|
|
10
|
+
self.capacity = capacity
|
|
11
|
+
super().__init__(*args, **kwargs)
|
|
12
|
+
|
|
13
|
+
def __setitem__(self, key, value):
|
|
14
|
+
# If the key is new and we are at capacity, evict the oldest item
|
|
15
|
+
if key not in self and len(self) >= self.capacity:
|
|
16
|
+
self.popitem(last=False) # last=False pops the FIFO (oldest) item
|
|
17
|
+
super().__setitem__(key, value)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""File utilities."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
# Defaults for `file_list_hashed`.
|
|
8
|
+
SKIP_EXTS = frozenset({".pyc", ".pyo", ".tmp", ".log"})
|
|
9
|
+
SKIP_DIR_NAMES = frozenset({".git", "__pycache__", "node_modules", ".venv", "venv"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hash_file(path) -> str:
|
|
13
|
+
"""SHA-256 of a file's contents, streamed in 1 MiB blocks."""
|
|
14
|
+
digest = hashlib.sha256()
|
|
15
|
+
with open(path, "rb") as f:
|
|
16
|
+
for block in iter(lambda: f.read(1024 * 1024), b""):
|
|
17
|
+
digest.update(block)
|
|
18
|
+
return digest.hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def file_list_hashed(
|
|
22
|
+
requested_dir, *, skip_exts=SKIP_EXTS, skip_dir_names=SKIP_DIR_NAMES
|
|
23
|
+
) -> dict[str, str]:
|
|
24
|
+
"""Map every file under `requested_dir` (recursive) to its content hash, skipping
|
|
25
|
+
`skip_dir_names` directories and `skip_exts` file types."""
|
|
26
|
+
files = {}
|
|
27
|
+
for path in glob.glob(f"{requested_dir}/**/*", recursive=True):
|
|
28
|
+
if os.path.isdir(path):
|
|
29
|
+
continue
|
|
30
|
+
if any(part in skip_dir_names for part in path.split(os.sep)):
|
|
31
|
+
continue
|
|
32
|
+
if os.path.splitext(path)[1].lower() in skip_exts:
|
|
33
|
+
continue
|
|
34
|
+
files[path] = hash_file(path)
|
|
35
|
+
return files
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
def parse_bool(value) -> bool:
|
|
2
|
+
"""Parse common string boolean values, falling back to normal truthiness."""
|
|
3
|
+
if isinstance(value, str):
|
|
4
|
+
return value.strip().lower() in (
|
|
5
|
+
"1",
|
|
6
|
+
"true",
|
|
7
|
+
"on",
|
|
8
|
+
"ok",
|
|
9
|
+
"yes",
|
|
10
|
+
"y",
|
|
11
|
+
"ye",
|
|
12
|
+
"yep",
|
|
13
|
+
"yeah",
|
|
14
|
+
"continue",
|
|
15
|
+
"go",
|
|
16
|
+
"good",
|
|
17
|
+
"sure",
|
|
18
|
+
"okay",
|
|
19
|
+
"proceed",
|
|
20
|
+
"confirm",
|
|
21
|
+
"confirmed",
|
|
22
|
+
)
|
|
23
|
+
return bool(value)
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""String utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def str_loose_match(a: str, b: str) -> bool:
|
|
8
|
+
"""Case-insensitive equality after stripping all whitespace and dash and underscores."""
|
|
9
|
+
bad_chars = re.compile(r"[\s\-_]+")
|
|
10
|
+
a = bad_chars.sub("", a or "").casefold()
|
|
11
|
+
b = bad_chars.sub("", b or "").casefold()
|
|
12
|
+
return a == b
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def str_loose_in(value: str, items) -> bool:
|
|
16
|
+
"""`value in items`, compared with `str_loose_match`."""
|
|
17
|
+
return any(str_loose_match(value, item) for item in items or ())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def slugify(text: str, maxlen: int = 48) -> str:
|
|
21
|
+
"""Lowercase [a-z0-9-] slug, truncated at a word boundary (no mid-word cut).
|
|
22
|
+
Empty / all-punctuation input yields "default"."""
|
|
23
|
+
return m_sanitize_text(text, replacement="-", maxlen=maxlen, default="default")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def m_sanitize_text(
|
|
27
|
+
value: str,
|
|
28
|
+
*,
|
|
29
|
+
keep: str = "",
|
|
30
|
+
replacement: str = "",
|
|
31
|
+
lowercase: bool = True,
|
|
32
|
+
maxlen: int = 0,
|
|
33
|
+
default: str = "",
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Keep only [A-Za-z0-9] plus the punctuation listed in `keep`.
|
|
36
|
+
|
|
37
|
+
Every run of other characters becomes `replacement`, which is then trimmed
|
|
38
|
+
off both ends. `keep` is "_-" for slugs, "._-" for filenames, "" (the
|
|
39
|
+
default) for alphanumeric-only. `maxlen` truncates at the last
|
|
40
|
+
`replacement` boundary so a word is never cut in half, and `default` is
|
|
41
|
+
returned when nothing survives.
|
|
42
|
+
"""
|
|
43
|
+
s = re.sub(f"[^A-Za-z0-9{re.escape(keep)}]+", replacement, (value or "").strip())
|
|
44
|
+
if lowercase:
|
|
45
|
+
s = s.lower()
|
|
46
|
+
if replacement:
|
|
47
|
+
s = s.strip(replacement)
|
|
48
|
+
if maxlen and len(s) > maxlen:
|
|
49
|
+
cut = s[:maxlen]
|
|
50
|
+
if replacement in cut[maxlen // 2 :]:
|
|
51
|
+
cut = cut[: cut.rfind(replacement)]
|
|
52
|
+
s = cut.strip(replacement)
|
|
53
|
+
elif maxlen:
|
|
54
|
+
s = s[:maxlen]
|
|
55
|
+
return s or default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def basic_str_array(v, whitespace_seperated: bool = False, unique: bool = False) -> list[str]:
|
|
59
|
+
"""
|
|
60
|
+
converts input to a list
|
|
61
|
+
split by , \n | or \\, and strips whitespace from each element.
|
|
62
|
+
unique=True drops duplicates, keeping first-seen order.
|
|
63
|
+
A bracketed string is read as an array: "[1, 2]" and '["1", "2"]' -> ["1", "2"]
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(v, str) and v.strip().startswith("[") and v.strip().endswith("]"):
|
|
66
|
+
try:
|
|
67
|
+
v = json.loads(v)
|
|
68
|
+
except ValueError:
|
|
69
|
+
v = [x.strip().strip("\"'") for x in v.strip()[1:-1].split(",")]
|
|
70
|
+
if isinstance(v, str):
|
|
71
|
+
has_comma = "," in v
|
|
72
|
+
v = v.replace("\\", ",").replace("\n", ",").replace("|", ",")
|
|
73
|
+
# a comma seperated string should not be considred whitespace seperated.
|
|
74
|
+
if not has_comma and whitespace_seperated:
|
|
75
|
+
v = re.sub(r"\s+", ",", v)
|
|
76
|
+
v = v.split(",")
|
|
77
|
+
if not isinstance(v, list):
|
|
78
|
+
return []
|
|
79
|
+
items = [str(x).strip() for x in v if str(x).strip()]
|
|
80
|
+
return list(dict.fromkeys(items)) if unique else items
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalize_extensions(extensions) -> list[str]:
|
|
84
|
+
"""normalize file extensions to a lowercase dotted form
|
|
85
|
+
`pdf`, `.PDF`, `*.pdf` -> `.pdf`. Duplicates are dropped, order is kept.
|
|
86
|
+
"""
|
|
87
|
+
return list(
|
|
88
|
+
dict.fromkeys(
|
|
89
|
+
"." + ext.lower().lstrip("*.").strip() for ext in (extensions or []) if ext.strip(" *.")
|
|
90
|
+
)
|
|
91
|
+
)
|