semifun 0.3.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.
- semifun-0.3.0/.github/workflows/publish.yml +84 -0
- semifun-0.3.0/.github/workflows/test.yml +55 -0
- semifun-0.3.0/.gitignore +11 -0
- semifun-0.3.0/COPYING +2 -0
- semifun-0.3.0/LICENSE +5 -0
- semifun-0.3.0/PKG-INFO +13 -0
- semifun-0.3.0/README.md +75 -0
- semifun-0.3.0/VERSION +1 -0
- semifun-0.3.0/bump_version_and_publish.py +93 -0
- semifun-0.3.0/pyproject.toml +43 -0
- semifun-0.3.0/src/semifun/__init__.py +0 -0
- semifun-0.3.0/src/semifun/caching/__init__.py +0 -0
- semifun-0.3.0/src/semifun/caching/cached_method.py +56 -0
- semifun-0.3.0/src/semifun/caching/cached_property.py +52 -0
- semifun-0.3.0/src/semifun/caching/dictdefault.py +59 -0
- semifun-0.3.0/src/semifun/cli/__init__.py +0 -0
- semifun-0.3.0/src/semifun/cli/argv.py +28 -0
- semifun-0.3.0/src/semifun/cli/cast.py +101 -0
- semifun-0.3.0/src/semifun/cli/decorator.py +26 -0
- semifun-0.3.0/src/semifun/cli/dispatch.py +145 -0
- semifun-0.3.0/src/semifun/di/__init__.py +0 -0
- semifun-0.3.0/src/semifun/di/async_execution_context.py +154 -0
- semifun-0.3.0/src/semifun/di/injector.py +103 -0
- semifun-0.3.0/src/semifun/di/model.py +57 -0
- semifun-0.3.0/src/semifun/di/registry_integration.py +29 -0
- semifun-0.3.0/src/semifun/di/signature_processing.py +71 -0
- semifun-0.3.0/src/semifun/di/sync_execution_context.py +158 -0
- semifun-0.3.0/src/semifun/plugins/__init__.py +1 -0
- semifun-0.3.0/src/semifun/plugins/index.py +87 -0
- semifun-0.3.0/src/semifun/plugins/model.py +153 -0
- semifun-0.3.0/src/semifun/plugins/registry.py +132 -0
- semifun-0.3.0/src/semifun/plugins/scanner.py +99 -0
- semifun-0.3.0/src/semifun/plugins/testing.py +111 -0
- semifun-0.3.0/tests/caching/conftest.py +30 -0
- semifun-0.3.0/tests/caching/test_cached_method.py +145 -0
- semifun-0.3.0/tests/caching/test_cached_property.py +203 -0
- semifun-0.3.0/tests/caching/test_dictdefault.py +172 -0
- semifun-0.3.0/tests/cli/test_argv.py +36 -0
- semifun-0.3.0/tests/cli/test_cast.py +131 -0
- semifun-0.3.0/tests/cli/test_decorator.py +24 -0
- semifun-0.3.0/tests/cli/test_dispatch.py +303 -0
- semifun-0.3.0/tests/di/test_di_ctx.py +332 -0
- semifun-0.3.0/tests/di/test_injector.py +366 -0
- semifun-0.3.0/tests/di/test_model.py +61 -0
- semifun-0.3.0/tests/di/test_signature_processing.py +131 -0
- semifun-0.3.0/tests/di/test_sync_injector.py +263 -0
- semifun-0.3.0/tests/plugins/test_model.py +239 -0
- semifun-0.3.0/tests/plugins/test_registry.py +169 -0
- semifun-0.3.0/tests/plugins/test_scanner.py +92 -0
- semifun-0.3.0/tests/test_version_sync.py +34 -0
- semifun-0.3.0/tmsgpack/MANIFEST.in +5 -0
- semifun-0.3.0/tmsgpack/README.md +4 -0
- semifun-0.3.0/tmsgpack/build_pyx.py +67 -0
- semifun-0.3.0/tmsgpack/pyproject.toml +37 -0
- semifun-0.3.0/tmsgpack/setup.py +26 -0
- semifun-0.3.0/tmsgpack/tests/test_build_pyx.py +65 -0
- semifun-0.3.0/tmsgpack/tests/test_codec.py +197 -0
- semifun-0.3.0/tmsgpack/tests/test_round_trip.py +190 -0
- semifun-0.3.0/tmsgpack/tmsgpack/__init__.py +0 -0
- semifun-0.3.0/tmsgpack/tmsgpack/api.py +57 -0
- semifun-0.3.0/tmsgpack/tmsgpack/codec.py +124 -0
- semifun-0.3.0/tmsgpack/tmsgpack/core.pyx +570 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/01-imports +10 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/02-constants +53 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/03-encode-fn +153 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/04-decode-fn +140 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/05-encode-buffer +66 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/06-decode-buffer +72 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/07-encode-buffer-slow +33 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/08-decode-buffer-slow +37 -0
- semifun-0.3.0/tmsgpack/tmsgpack/src-parts/09-exceptions +1 -0
- semifun-0.3.0/tmsgpack-js/index.js +4 -0
- semifun-0.3.0/tmsgpack-js/package.json +33 -0
- semifun-0.3.0/tmsgpack-js/src/api.js +57 -0
- semifun-0.3.0/tmsgpack-js/src/buffers.js +101 -0
- semifun-0.3.0/tmsgpack-js/src/engine.js +361 -0
- semifun-0.3.0/tmsgpack-js/src/exceptions.js +7 -0
- semifun-0.3.0/tmsgpack-js/test/test.js +201 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
name: Publish to PyPI and NPM
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish-semifun:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: release
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v4
|
|
22
|
+
with:
|
|
23
|
+
python-version: '3.14'
|
|
24
|
+
|
|
25
|
+
- name: Build semifun
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install build
|
|
28
|
+
rm -rf dist/
|
|
29
|
+
python -m build
|
|
30
|
+
|
|
31
|
+
- name: Publish semifun to PyPI
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
33
|
+
|
|
34
|
+
publish-tmsgpack:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment: release
|
|
37
|
+
|
|
38
|
+
permissions:
|
|
39
|
+
id-token: write
|
|
40
|
+
contents: read
|
|
41
|
+
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
|
|
45
|
+
- name: Set up Python
|
|
46
|
+
uses: actions/setup-python@v4
|
|
47
|
+
with:
|
|
48
|
+
python-version: '3.14'
|
|
49
|
+
|
|
50
|
+
- name: Build tmsgpack sdist
|
|
51
|
+
run: |
|
|
52
|
+
cd tmsgpack
|
|
53
|
+
python -m pip install build
|
|
54
|
+
rm -rf dist/
|
|
55
|
+
python -m build --sdist
|
|
56
|
+
|
|
57
|
+
- name: Publish tmsgpack to PyPI
|
|
58
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
59
|
+
with:
|
|
60
|
+
packages-dir: tmsgpack/dist/
|
|
61
|
+
|
|
62
|
+
publish-tmsgpack-js:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
environment: release
|
|
65
|
+
|
|
66
|
+
permissions:
|
|
67
|
+
id-token: write
|
|
68
|
+
contents: read
|
|
69
|
+
|
|
70
|
+
steps:
|
|
71
|
+
- uses: actions/checkout@v4
|
|
72
|
+
|
|
73
|
+
- name: Setup Node.js
|
|
74
|
+
uses: actions/setup-node@v4
|
|
75
|
+
with:
|
|
76
|
+
node-version: '20'
|
|
77
|
+
registry-url: 'https://registry.npmjs.org'
|
|
78
|
+
|
|
79
|
+
- name: Ensure npm 11.5.1 or later is installed
|
|
80
|
+
run: npm install -g npm@latest
|
|
81
|
+
|
|
82
|
+
- name: Publish to NPM
|
|
83
|
+
run: npm publish --provenance
|
|
84
|
+
working-directory: ./tmsgpack-js
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
semifun:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Set up Python
|
|
15
|
+
uses: actions/setup-python@v4
|
|
16
|
+
with:
|
|
17
|
+
python-version: '3.14'
|
|
18
|
+
|
|
19
|
+
- name: Install and test
|
|
20
|
+
run: |
|
|
21
|
+
pip install -e '.[tmsgpack]' pytest pytest-asyncio pytest-timeout
|
|
22
|
+
pytest tests/
|
|
23
|
+
|
|
24
|
+
tmsgpack:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- name: Set up Python
|
|
31
|
+
uses: actions/setup-python@v4
|
|
32
|
+
with:
|
|
33
|
+
python-version: '3.14'
|
|
34
|
+
|
|
35
|
+
- name: Install and test
|
|
36
|
+
run: |
|
|
37
|
+
pip install -e .
|
|
38
|
+
cd tmsgpack
|
|
39
|
+
pip install -e . pytest pytest-asyncio pytest-timeout
|
|
40
|
+
pytest tests/
|
|
41
|
+
|
|
42
|
+
tmsgpack-js:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@v4
|
|
47
|
+
|
|
48
|
+
- name: Setup Node.js
|
|
49
|
+
uses: actions/setup-node@v4
|
|
50
|
+
with:
|
|
51
|
+
node-version: '20'
|
|
52
|
+
|
|
53
|
+
- name: Test
|
|
54
|
+
run: node test/test.js
|
|
55
|
+
working-directory: ./tmsgpack-js
|
semifun-0.3.0/.gitignore
ADDED
semifun-0.3.0/COPYING
ADDED
semifun-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright 2025 Yaakov Belch.
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
4
|
+
|
|
5
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
semifun-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semifun
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Caching, plugins, dependency injection, and CLI dispatch utilities
|
|
5
|
+
Project-URL: Source, https://github.com/Yaakov-Belch/semifun
|
|
6
|
+
Project-URL: Tracker, https://github.com/Yaakov-Belch/semifun/issues
|
|
7
|
+
Author-email: Yaakov Belch <yaakov.belch@gmail.com>
|
|
8
|
+
License: ISC
|
|
9
|
+
License-File: COPYING
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Provides-Extra: tmsgpack
|
|
13
|
+
Requires-Dist: tmsgpack>=0.1.0; extra == 'tmsgpack'
|
semifun-0.3.0/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# semifun
|
|
2
|
+
|
|
3
|
+
Python utilities: caching, plugin discovery, dependency injection, and CLI dispatch.
|
|
4
|
+
|
|
5
|
+
This repo also contains **tmsgpack**, a typed MessagePack serializer with both
|
|
6
|
+
Python (Cython) and JavaScript implementations:
|
|
7
|
+
|
|
8
|
+
- `tmsgpack/` -- Python package (published to PyPI as `tmsgpack`)
|
|
9
|
+
- `tmsgpack-js/` -- JavaScript package (published to npm as `tmsgpack`)
|
|
10
|
+
|
|
11
|
+
Install the pure-Python tools with `pip install semifun`. To include tmsgpack,
|
|
12
|
+
use `pip install semifun[tmsgpack]` (requires a C compiler for Cython).
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## Versioning
|
|
16
|
+
|
|
17
|
+
A single `VERSION` file at the repo root is the source of truth. The Python and
|
|
18
|
+
JavaScript tmsgpack packages carry the same version.
|
|
19
|
+
|
|
20
|
+
To bump all packages at once:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
python bump_version_and_publish.py 0.2.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This updates `VERSION`, both `pyproject.toml` files, and `tmsgpack-js/package.json`.
|
|
27
|
+
A test (`tests/test_version_sync.py`) asserts all four stay in sync.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## Publishing
|
|
31
|
+
|
|
32
|
+
The CI workflow (`.github/workflows/publish.yml`) publishes all three packages
|
|
33
|
+
when you push a `v*` tag. It uses trusted publishing (OIDC) through a GitHub
|
|
34
|
+
`release` environment -- no API keys in the repo.
|
|
35
|
+
|
|
36
|
+
The script commits, tags, and pushes in one step:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python bump_version_and_publish.py 0.2.0
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This triggers three parallel CI jobs:
|
|
43
|
+
|
|
44
|
+
- **semifun** -- built and published to PyPI (wheel + sdist)
|
|
45
|
+
- **tmsgpack** -- published to PyPI as an sdist (users compile Cython on install)
|
|
46
|
+
- **tmsgpack-js** -- published to npm with provenance
|
|
47
|
+
|
|
48
|
+
Before the first publish, configure trusted publishers on PyPI and npm for the
|
|
49
|
+
`Yaakov-Belch/semifun` repository, and create a `release` environment in the
|
|
50
|
+
GitHub repo settings.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
The repo uses a [uv workspace](https://docs.astral.sh/uv/concepts/workspaces/)
|
|
56
|
+
so that `tmsgpack` resolves `semifun` locally during development.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/Yaakov-Belch/semifun.git
|
|
60
|
+
cd semifun
|
|
61
|
+
uv sync
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run the tests:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# semifun (pure Python)
|
|
68
|
+
uv run pytest tests/
|
|
69
|
+
|
|
70
|
+
# tmsgpack (requires Cython build)
|
|
71
|
+
cd tmsgpack && uv run pytest tests/
|
|
72
|
+
|
|
73
|
+
# tmsgpack-js
|
|
74
|
+
node tmsgpack-js/test/test.js
|
|
75
|
+
```
|
semifun-0.3.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.0
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Bump the version across all packages, commit, tag, and push.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python bump_version_and_publish.py 0.2.23
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Path(__file__).parent
|
|
14
|
+
|
|
15
|
+
TARGETS = {
|
|
16
|
+
"VERSION": REPO_ROOT / "VERSION",
|
|
17
|
+
"pyproject.toml (semifun)": REPO_ROOT / "pyproject.toml",
|
|
18
|
+
"tmsgpack/pyproject.toml": REPO_ROOT / "tmsgpack" / "pyproject.toml",
|
|
19
|
+
"tmsgpack-js/package.json": REPO_ROOT / "tmsgpack-js" / "package.json",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def update_version_file(path: Path, new: str) -> str:
|
|
26
|
+
old = path.read_text().strip()
|
|
27
|
+
path.write_text(new + "\n")
|
|
28
|
+
return old
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def update_toml_version(path: Path, new: str) -> str:
|
|
32
|
+
text = path.read_text()
|
|
33
|
+
match = re.search(r'^(version\s*=\s*)"([^"]+)"', text, re.MULTILINE)
|
|
34
|
+
if not match:
|
|
35
|
+
raise ValueError(f"No version field found in {path}")
|
|
36
|
+
old = match.group(2)
|
|
37
|
+
updated = text[: match.start(2)] + new + text[match.end(2) :]
|
|
38
|
+
path.write_text(updated)
|
|
39
|
+
return old
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update_package_json(path: Path, new: str) -> str:
|
|
43
|
+
data = json.loads(path.read_text())
|
|
44
|
+
old = data["version"]
|
|
45
|
+
data["version"] = new
|
|
46
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
47
|
+
return old
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run(cmd):
|
|
51
|
+
print(f" $ {cmd}")
|
|
52
|
+
result = subprocess.run(cmd, shell=True, cwd=REPO_ROOT)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
print(f"Error: command failed with exit code {result.returncode}", file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main():
|
|
59
|
+
if len(sys.argv) != 2:
|
|
60
|
+
print(f"Usage: python {sys.argv[0]} <version>", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
new_version = sys.argv[1]
|
|
64
|
+
|
|
65
|
+
if not SEMVER_RE.match(new_version):
|
|
66
|
+
print(f"Error: '{new_version}' is not a valid version (expected X.Y.Z)", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
updaters = {
|
|
70
|
+
"VERSION": update_version_file,
|
|
71
|
+
"pyproject.toml (semifun)": update_toml_version,
|
|
72
|
+
"tmsgpack/pyproject.toml": update_toml_version,
|
|
73
|
+
"tmsgpack-js/package.json": update_package_json,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
print("Updating versions:")
|
|
77
|
+
for label, path in TARGETS.items():
|
|
78
|
+
old = updaters[label](path, new_version)
|
|
79
|
+
if old == new_version:
|
|
80
|
+
print(f" {label}: already {new_version}")
|
|
81
|
+
else:
|
|
82
|
+
print(f" {label}: {old} -> {new_version}")
|
|
83
|
+
|
|
84
|
+
print("\nCommit, tag, and push:")
|
|
85
|
+
run(f'git commit -a -m "Bump version to {new_version}"')
|
|
86
|
+
run(f"git tag v{new_version}")
|
|
87
|
+
run("git push && git push --tags")
|
|
88
|
+
|
|
89
|
+
print(f"\nDone. CI will publish v{new_version} to PyPI and npm.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "semifun"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Caching, plugins, dependency injection, and CLI dispatch utilities"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
license = {text = "ISC"}
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Yaakov Belch", email = "yaakov.belch@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
tmsgpack = ["tmsgpack>=0.1.0"]
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
tmsgpack = { workspace = true }
|
|
17
|
+
|
|
18
|
+
[tool.uv.workspace]
|
|
19
|
+
members = ["tmsgpack"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Source = "https://github.com/Yaakov-Belch/semifun"
|
|
23
|
+
Tracker = "https://github.com/Yaakov-Belch/semifun/issues"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.0.0",
|
|
28
|
+
"pytest-asyncio>=0.24.0",
|
|
29
|
+
"pytest-timeout>=2.3.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
addopts = "--import-mode=importlib"
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
timeout = 60
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/semifun"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import functools
|
|
3
|
+
from semifun.caching.dictdefault import dictdefault
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def cached_method(fn):
|
|
7
|
+
"""Decorator that caches method results by hashed arguments.
|
|
8
|
+
|
|
9
|
+
Example::
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MyService:
|
|
13
|
+
cache_codec: Inject[TmsgpackCodec]
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
@cached_method
|
|
17
|
+
def get_config(self, env: str) -> Config: ...
|
|
18
|
+
|
|
19
|
+
@cached_method
|
|
20
|
+
async def fetch_user(self, user_id: int) -> User: ...
|
|
21
|
+
"""
|
|
22
|
+
sig = inspect.signature(fn)
|
|
23
|
+
params = list(sig.parameters.keys())
|
|
24
|
+
assert params and params[0] == 'self'
|
|
25
|
+
cache_attr = f'_method_cache_{fn.__name__}'
|
|
26
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
27
|
+
dd = dictdefault.a if is_async else dictdefault
|
|
28
|
+
|
|
29
|
+
# Build a signature without 'self' for normalization
|
|
30
|
+
norm_sig = sig.replace(parameters=[sig.parameters[p] for p in params[1:]])
|
|
31
|
+
|
|
32
|
+
def _get_cache(self):
|
|
33
|
+
try:
|
|
34
|
+
return getattr(self, cache_attr)
|
|
35
|
+
except AttributeError:
|
|
36
|
+
cache = {}
|
|
37
|
+
object.__setattr__(self, cache_attr, cache)
|
|
38
|
+
return cache
|
|
39
|
+
|
|
40
|
+
def _make_key(self, args, kwargs):
|
|
41
|
+
bound = norm_sig.bind(*args, **kwargs)
|
|
42
|
+
bound.apply_defaults()
|
|
43
|
+
return self.cache_codec.hash_to_bytes(bound.args + tuple(bound.kwargs.items()))
|
|
44
|
+
|
|
45
|
+
if is_async:
|
|
46
|
+
@functools.wraps(fn)
|
|
47
|
+
async def wrapper(self, *args, **kwargs):
|
|
48
|
+
key = _make_key(self, args, kwargs)
|
|
49
|
+
return await dd(_get_cache(self), key, lambda: fn(self, *args, **kwargs))
|
|
50
|
+
return wrapper
|
|
51
|
+
else:
|
|
52
|
+
@functools.wraps(fn)
|
|
53
|
+
def wrapper(self, *args, **kwargs):
|
|
54
|
+
key = _make_key(self, args, kwargs)
|
|
55
|
+
return dd(_get_cache(self), key, lambda: fn(self, *args, **kwargs))
|
|
56
|
+
return wrapper
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# We do not use functools.cached_property because it contains messy
|
|
2
|
+
# and unnecessary code for threaded programs. In addition, our
|
|
3
|
+
# `@cached_property` decorator can be used correctly with async methods.
|
|
4
|
+
|
|
5
|
+
import inspect, asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, TypeVar, Generic, Callable, Any, overload
|
|
8
|
+
|
|
9
|
+
T = TypeVar('T')
|
|
10
|
+
|
|
11
|
+
class cached_property(Generic[T]):
|
|
12
|
+
fn: Callable[[Any], T]
|
|
13
|
+
_name: str
|
|
14
|
+
|
|
15
|
+
def __init__(self, fn: Callable[[Any], T]):
|
|
16
|
+
import types
|
|
17
|
+
fn2 = fn if isinstance(fn, types.FunctionType) else getattr(fn, '__call__', fn) # type: ignore[arg-type]
|
|
18
|
+
if inspect.iscoroutinefunction(fn2):
|
|
19
|
+
self.fn = lambda self: create_task_loop_check(fn(self), name=None, context=None) # type: ignore[assignment,return-value]
|
|
20
|
+
else:
|
|
21
|
+
self.fn = fn
|
|
22
|
+
self._name = fn.__name__
|
|
23
|
+
self.__doc__ = fn.__doc__
|
|
24
|
+
|
|
25
|
+
@overload
|
|
26
|
+
def __get__(self, instance: None, cls: type) -> cached_property[T]: ...
|
|
27
|
+
@overload
|
|
28
|
+
def __get__(self, instance: object, cls: type) -> T: ...
|
|
29
|
+
|
|
30
|
+
def __get__(self, instance: object | None, cls: type) -> T | cached_property[T]:
|
|
31
|
+
if instance is None:
|
|
32
|
+
return self
|
|
33
|
+
value = self.fn(instance)
|
|
34
|
+
instance.__dict__[self._name] = value
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
def create_task_loop_check(coro: Any, name: str | None, context: Any) -> LoopCheck:
|
|
38
|
+
task = asyncio.create_task(coro, name=name, context=context)
|
|
39
|
+
task._loop_check_parent_task = asyncio.current_task() # type: ignore[attr-defined]
|
|
40
|
+
return LoopCheck(task=task)
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class LoopCheck:
|
|
44
|
+
task: asyncio.Task[Any]
|
|
45
|
+
|
|
46
|
+
def __await__(self):
|
|
47
|
+
testing = asyncio.current_task()
|
|
48
|
+
while testing:
|
|
49
|
+
if testing is self.task:
|
|
50
|
+
raise ValueError(f'Deadlock: {self.task}')
|
|
51
|
+
testing = getattr(testing, '_loop_check_parent_task', None)
|
|
52
|
+
return self.task.__await__()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""dictdefault — a caching accessor for normal dicts.
|
|
2
|
+
|
|
3
|
+
The name is a play on ``defaultdict``: instead of using a special dict type,
|
|
4
|
+
you use a normal dict and a caching accessor function.
|
|
5
|
+
|
|
6
|
+
Variants:
|
|
7
|
+
|
|
8
|
+
- ``dictdefault`` — sync, key not passed to the function.
|
|
9
|
+
- ``dictdefault.k`` — sync, key passed as first argument.
|
|
10
|
+
- ``dictdefault.a`` — async, key not passed.
|
|
11
|
+
- ``dictdefault.ak`` — async, key passed as first argument.
|
|
12
|
+
|
|
13
|
+
The async variants ensure the computation runs only once, even when
|
|
14
|
+
additional requests arrive while the first computation is still running.
|
|
15
|
+
All callers receive the same awaitable task object.
|
|
16
|
+
|
|
17
|
+
All variants accept ``**kwargs`` which are forwarded to the function.
|
|
18
|
+
They are considered only the first time — when the computation is performed.
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
cache1 = {}; cache2 = {} # sync and async use distinct caching formats
|
|
23
|
+
config = dictdefault(cache1, 'db', load_config) # → load_config()
|
|
24
|
+
user = dictdefault.k(cache1, 'alice', fetch_user) # → fetch_user('alice')
|
|
25
|
+
session = await dictdefault.a(cache2, 'main', async_create_session)
|
|
26
|
+
# → await async_create_session()
|
|
27
|
+
page = await dictdefault.ak(cache2, url, async_fetch_page)
|
|
28
|
+
# → await async_fetch_page(url)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def _mk_dictdefault():
|
|
32
|
+
def _dd(with_key):
|
|
33
|
+
def dictdefault(_d, _key, _fn, **kwargs):
|
|
34
|
+
if _key not in _d:
|
|
35
|
+
_d[_key] = _fn(_key, **kwargs) if with_key else _fn(**kwargs)
|
|
36
|
+
return _d[_key]
|
|
37
|
+
return dictdefault
|
|
38
|
+
|
|
39
|
+
def _dda(with_key):
|
|
40
|
+
from semifun.caching.cached_property import create_task_loop_check
|
|
41
|
+
async def dictdefault(_d, _key, _fn, **kwargs):
|
|
42
|
+
if _key not in _d:
|
|
43
|
+
_d[_key] = (
|
|
44
|
+
create_task_loop_check(
|
|
45
|
+
_fn(_key, **kwargs) if with_key else _fn(**kwargs),
|
|
46
|
+
name=None, context=None,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return await _d[_key]
|
|
50
|
+
return dictdefault
|
|
51
|
+
|
|
52
|
+
dictdefault = _dd(False)
|
|
53
|
+
dictdefault.k = _dd(True)
|
|
54
|
+
dictdefault.a = _dda(False)
|
|
55
|
+
dictdefault.ak = _dda(True)
|
|
56
|
+
|
|
57
|
+
return dictdefault
|
|
58
|
+
|
|
59
|
+
dictdefault = _mk_dictdefault()
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Parse CLI argv into positional args and keyword args.
|
|
2
|
+
|
|
3
|
+
Pure string processing — no knowledge of the target function.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def split_argv(argv: list[str]) -> tuple[list[str], dict[str, str]]:
|
|
8
|
+
"""Split argv tokens into positional args and keyword args.
|
|
9
|
+
|
|
10
|
+
Tokens containing '=' (split at the first '=') become keyword args.
|
|
11
|
+
All other tokens are positional args, in their original order.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
split_argv(['hello', 'time=now', 'world', 'age=10'])
|
|
15
|
+
→ (['hello', 'world'], {'time': 'now', 'age': '10'})
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
(args, kwargs) — both contain raw strings, no type casting.
|
|
19
|
+
"""
|
|
20
|
+
args: list[str] = []
|
|
21
|
+
kwargs: dict[str, str] = {}
|
|
22
|
+
for token in argv:
|
|
23
|
+
if '=' in token:
|
|
24
|
+
key, value = token.split('=', 1)
|
|
25
|
+
kwargs[key] = value
|
|
26
|
+
else:
|
|
27
|
+
args.append(token)
|
|
28
|
+
return args, kwargs
|