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.
Files changed (78) hide show
  1. semifun-0.3.0/.github/workflows/publish.yml +84 -0
  2. semifun-0.3.0/.github/workflows/test.yml +55 -0
  3. semifun-0.3.0/.gitignore +11 -0
  4. semifun-0.3.0/COPYING +2 -0
  5. semifun-0.3.0/LICENSE +5 -0
  6. semifun-0.3.0/PKG-INFO +13 -0
  7. semifun-0.3.0/README.md +75 -0
  8. semifun-0.3.0/VERSION +1 -0
  9. semifun-0.3.0/bump_version_and_publish.py +93 -0
  10. semifun-0.3.0/pyproject.toml +43 -0
  11. semifun-0.3.0/src/semifun/__init__.py +0 -0
  12. semifun-0.3.0/src/semifun/caching/__init__.py +0 -0
  13. semifun-0.3.0/src/semifun/caching/cached_method.py +56 -0
  14. semifun-0.3.0/src/semifun/caching/cached_property.py +52 -0
  15. semifun-0.3.0/src/semifun/caching/dictdefault.py +59 -0
  16. semifun-0.3.0/src/semifun/cli/__init__.py +0 -0
  17. semifun-0.3.0/src/semifun/cli/argv.py +28 -0
  18. semifun-0.3.0/src/semifun/cli/cast.py +101 -0
  19. semifun-0.3.0/src/semifun/cli/decorator.py +26 -0
  20. semifun-0.3.0/src/semifun/cli/dispatch.py +145 -0
  21. semifun-0.3.0/src/semifun/di/__init__.py +0 -0
  22. semifun-0.3.0/src/semifun/di/async_execution_context.py +154 -0
  23. semifun-0.3.0/src/semifun/di/injector.py +103 -0
  24. semifun-0.3.0/src/semifun/di/model.py +57 -0
  25. semifun-0.3.0/src/semifun/di/registry_integration.py +29 -0
  26. semifun-0.3.0/src/semifun/di/signature_processing.py +71 -0
  27. semifun-0.3.0/src/semifun/di/sync_execution_context.py +158 -0
  28. semifun-0.3.0/src/semifun/plugins/__init__.py +1 -0
  29. semifun-0.3.0/src/semifun/plugins/index.py +87 -0
  30. semifun-0.3.0/src/semifun/plugins/model.py +153 -0
  31. semifun-0.3.0/src/semifun/plugins/registry.py +132 -0
  32. semifun-0.3.0/src/semifun/plugins/scanner.py +99 -0
  33. semifun-0.3.0/src/semifun/plugins/testing.py +111 -0
  34. semifun-0.3.0/tests/caching/conftest.py +30 -0
  35. semifun-0.3.0/tests/caching/test_cached_method.py +145 -0
  36. semifun-0.3.0/tests/caching/test_cached_property.py +203 -0
  37. semifun-0.3.0/tests/caching/test_dictdefault.py +172 -0
  38. semifun-0.3.0/tests/cli/test_argv.py +36 -0
  39. semifun-0.3.0/tests/cli/test_cast.py +131 -0
  40. semifun-0.3.0/tests/cli/test_decorator.py +24 -0
  41. semifun-0.3.0/tests/cli/test_dispatch.py +303 -0
  42. semifun-0.3.0/tests/di/test_di_ctx.py +332 -0
  43. semifun-0.3.0/tests/di/test_injector.py +366 -0
  44. semifun-0.3.0/tests/di/test_model.py +61 -0
  45. semifun-0.3.0/tests/di/test_signature_processing.py +131 -0
  46. semifun-0.3.0/tests/di/test_sync_injector.py +263 -0
  47. semifun-0.3.0/tests/plugins/test_model.py +239 -0
  48. semifun-0.3.0/tests/plugins/test_registry.py +169 -0
  49. semifun-0.3.0/tests/plugins/test_scanner.py +92 -0
  50. semifun-0.3.0/tests/test_version_sync.py +34 -0
  51. semifun-0.3.0/tmsgpack/MANIFEST.in +5 -0
  52. semifun-0.3.0/tmsgpack/README.md +4 -0
  53. semifun-0.3.0/tmsgpack/build_pyx.py +67 -0
  54. semifun-0.3.0/tmsgpack/pyproject.toml +37 -0
  55. semifun-0.3.0/tmsgpack/setup.py +26 -0
  56. semifun-0.3.0/tmsgpack/tests/test_build_pyx.py +65 -0
  57. semifun-0.3.0/tmsgpack/tests/test_codec.py +197 -0
  58. semifun-0.3.0/tmsgpack/tests/test_round_trip.py +190 -0
  59. semifun-0.3.0/tmsgpack/tmsgpack/__init__.py +0 -0
  60. semifun-0.3.0/tmsgpack/tmsgpack/api.py +57 -0
  61. semifun-0.3.0/tmsgpack/tmsgpack/codec.py +124 -0
  62. semifun-0.3.0/tmsgpack/tmsgpack/core.pyx +570 -0
  63. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/01-imports +10 -0
  64. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/02-constants +53 -0
  65. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/03-encode-fn +153 -0
  66. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/04-decode-fn +140 -0
  67. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/05-encode-buffer +66 -0
  68. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/06-decode-buffer +72 -0
  69. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/07-encode-buffer-slow +33 -0
  70. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/08-decode-buffer-slow +37 -0
  71. semifun-0.3.0/tmsgpack/tmsgpack/src-parts/09-exceptions +1 -0
  72. semifun-0.3.0/tmsgpack-js/index.js +4 -0
  73. semifun-0.3.0/tmsgpack-js/package.json +33 -0
  74. semifun-0.3.0/tmsgpack-js/src/api.js +57 -0
  75. semifun-0.3.0/tmsgpack-js/src/buffers.js +101 -0
  76. semifun-0.3.0/tmsgpack-js/src/engine.js +361 -0
  77. semifun-0.3.0/tmsgpack-js/src/exceptions.js +7 -0
  78. 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
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.so
4
+ *.c
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ node_modules/
10
+ .venv/
11
+ uv.lock
semifun-0.3.0/COPYING ADDED
@@ -0,0 +1,2 @@
1
+ Copyright (C) 2025 Yaakov Belch <yaakov.belch@gmail.com>
2
+ Licensed under the MIT license. See LICENSE file for details.
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'
@@ -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