gestalt-sdk 0.0.1a8__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 (48) hide show
  1. gestalt_sdk-0.0.1a8/PKG-INFO +10 -0
  2. gestalt_sdk-0.0.1a8/README.md +105 -0
  3. gestalt_sdk-0.0.1a8/gestalt/__init__.py +151 -0
  4. gestalt_sdk-0.0.1a8/gestalt/_api.py +99 -0
  5. gestalt_sdk-0.0.1a8/gestalt/_bootstrap.py +80 -0
  6. gestalt_sdk-0.0.1a8/gestalt/_build.py +134 -0
  7. gestalt_sdk-0.0.1a8/gestalt/_cache.py +126 -0
  8. gestalt_sdk-0.0.1a8/gestalt/_catalog.py +239 -0
  9. gestalt_sdk-0.0.1a8/gestalt/_indexeddb.py +604 -0
  10. gestalt_sdk-0.0.1a8/gestalt/_operations.py +250 -0
  11. gestalt_sdk-0.0.1a8/gestalt/_plugin.py +361 -0
  12. gestalt_sdk-0.0.1a8/gestalt/_providers.py +168 -0
  13. gestalt_sdk-0.0.1a8/gestalt/_pyinstaller.py +4 -0
  14. gestalt_sdk-0.0.1a8/gestalt/_runtime.py +747 -0
  15. gestalt_sdk-0.0.1a8/gestalt/_s3.py +594 -0
  16. gestalt_sdk-0.0.1a8/gestalt/_serialization.py +23 -0
  17. gestalt_sdk-0.0.1a8/gestalt/gen/__init__.py +1 -0
  18. gestalt_sdk-0.0.1a8/gestalt/gen/v1/__init__.py +18 -0
  19. gestalt_sdk-0.0.1a8/gestalt/gen/v1/auth_pb2.py +62 -0
  20. gestalt_sdk-0.0.1a8/gestalt/gen/v1/auth_pb2_grpc.py +227 -0
  21. gestalt_sdk-0.0.1a8/gestalt/gen/v1/cache_pb2.py +67 -0
  22. gestalt_sdk-0.0.1a8/gestalt/gen/v1/cache_pb2_grpc.py +356 -0
  23. gestalt_sdk-0.0.1a8/gestalt/gen/v1/datastore_pb2.py +100 -0
  24. gestalt_sdk-0.0.1a8/gestalt/gen/v1/datastore_pb2_grpc.py +877 -0
  25. gestalt_sdk-0.0.1a8/gestalt/gen/v1/plugin_pb2.py +96 -0
  26. gestalt_sdk-0.0.1a8/gestalt/gen/v1/plugin_pb2_grpc.py +270 -0
  27. gestalt_sdk-0.0.1a8/gestalt/gen/v1/runtime_pb2.py +49 -0
  28. gestalt_sdk-0.0.1a8/gestalt/gen/v1/runtime_pb2_grpc.py +184 -0
  29. gestalt_sdk-0.0.1a8/gestalt/gen/v1/s3_pb2.py +91 -0
  30. gestalt_sdk-0.0.1a8/gestalt/gen/v1/s3_pb2_grpc.py +361 -0
  31. gestalt_sdk-0.0.1a8/gestalt/gen/v1/secrets_pb2.py +41 -0
  32. gestalt_sdk-0.0.1a8/gestalt/gen/v1/secrets_pb2_grpc.py +77 -0
  33. gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/PKG-INFO +10 -0
  34. gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/SOURCES.txt +46 -0
  35. gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/dependency_links.txt +1 -0
  36. gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/requires.txt +5 -0
  37. gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/top_level.txt +1 -0
  38. gestalt_sdk-0.0.1a8/pyproject.toml +40 -0
  39. gestalt_sdk-0.0.1a8/setup.cfg +4 -0
  40. gestalt_sdk-0.0.1a8/tests/test_build.py +130 -0
  41. gestalt_sdk-0.0.1a8/tests/test_cache_transport.py +107 -0
  42. gestalt_sdk-0.0.1a8/tests/test_indexeddb_cursor_unit.py +36 -0
  43. gestalt_sdk-0.0.1a8/tests/test_indexeddb_transport.py +280 -0
  44. gestalt_sdk-0.0.1a8/tests/test_operations.py +103 -0
  45. gestalt_sdk-0.0.1a8/tests/test_plugin.py +256 -0
  46. gestalt_sdk-0.0.1a8/tests/test_runtime.py +702 -0
  47. gestalt_sdk-0.0.1a8/tests/test_s3_transport.py +268 -0
  48. gestalt_sdk-0.0.1a8/tests/test_serialization.py +40 -0
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: gestalt-sdk
3
+ Version: 0.0.1a8
4
+ Summary: Python SDK for Gestalt executable providers
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: grpcio<2,>=1.80.0
7
+ Requires-Dist: pyyaml==6.0.3
8
+ Requires-Dist: pyinstaller<7
9
+ Requires-Dist: protobuf<8,>=6.33.1
10
+ Requires-Dist: typing-extensions==4.12.2
@@ -0,0 +1,105 @@
1
+ # Gestalt Python SDK
2
+
3
+ This package provides the Python authoring surface for executable Gestalt
4
+ providers.
5
+
6
+ It is intended to be used by source providers discovered through
7
+ `[tool.gestalt].provider` in `pyproject.toml` (or the legacy
8
+ `[tool.gestalt].plugin` key) and by packaged providers built from that
9
+ same source tree.
10
+
11
+ Python source providers are developed locally via `from.source.path` and
12
+ released through `gestaltd provider release` for the host platform by default,
13
+ or for every requested target platform when you pass `--platform`. In CI,
14
+ prefer `--platform all` to build the full supported release matrix.
15
+
16
+ For non-host targets, configure a matching Python build interpreter with
17
+ `GESTALT_PYTHON_<GOOS>_<GOARCH>` or a target-specific virtualenv such as
18
+ `.venv-<goos>-<goarch>/`.
19
+
20
+ ## Regenerating Protobuf Stubs
21
+
22
+ The checked-in Python protobuf stubs live in `gestalt/gen/v1`.
23
+
24
+ This is an SDK maintainer workflow. Provider authors consume the checked-in
25
+ stubs through the `gestalt` package and do not need to regenerate them in
26
+ provider repositories.
27
+
28
+ Regenerate them from the repo root with:
29
+
30
+ ```sh
31
+ uv run python sdk/python/scripts/generate_stubs.py
32
+ ```
33
+
34
+ The script uses pinned `buf` remote Python plugins so the generated stubs stay
35
+ reproducible while `plugin_pb2.py` tracks the protobuf `6.33.1` runtime floor
36
+ used by this SDK package and remains compatible with protobuf 7 runtimes.
37
+ `buf` must be available on `PATH`.
38
+
39
+ ## Publishing
40
+
41
+ The SDK is published as the `gestalt` package to a private Python index.
42
+
43
+ Release tags stay aligned with the repo's SDK tag convention:
44
+
45
+ - `sdk/python/v0.0.1`
46
+ - `sdk/python/v0.0.1-alpha.1`
47
+ - `sdk/python/v0.0.1-beta.1`
48
+ - `sdk/python/v0.0.1-rc.1`
49
+
50
+ The release workflow normalizes those tag versions to PEP 440 before building
51
+ and publishing with `uv`, so `sdk/python/v0.0.1-alpha.1` becomes package
52
+ version `0.0.1a1`.
53
+
54
+ The GitHub Actions workflow expects these repository secrets:
55
+
56
+ - `GESTALT_PYTHON_PUBLISH_URL`
57
+ - either `GESTALT_PYTHON_PUBLISH_TOKEN`
58
+ - or `GESTALT_PYTHON_PUBLISH_USERNAME` and `GESTALT_PYTHON_PUBLISH_PASSWORD`
59
+
60
+ ## Consuming From A Private Index
61
+
62
+ In an internal provider repo, pin `gestalt` to the private index with `uv` so
63
+ the package does not fall back to PyPI:
64
+
65
+ ```toml
66
+ [[tool.uv.index]]
67
+ name = "valon-internal"
68
+ url = "https://packages.example.com/simple"
69
+ explicit = true
70
+ authenticate = "always"
71
+
72
+ [tool.uv.sources]
73
+ gestalt = { index = "valon-internal" }
74
+
75
+ [project]
76
+ dependencies = ["gestalt==0.0.1a1"]
77
+ ```
78
+
79
+ At install time, provide credentials via the environment variables derived from
80
+ the index name:
81
+
82
+ ```sh
83
+ export UV_INDEX_VALON_INTERNAL_USERNAME=...
84
+ export UV_INDEX_VALON_INTERNAL_PASSWORD=...
85
+ ```
86
+
87
+ That lets `~/src/gestalt-providers` depend on `gestalt` like any other Python
88
+ package while keeping the SDK off the public package indexes.
89
+
90
+ ## Local SDK Checks
91
+
92
+ From `sdk/python`, install the SDK plus its dev tooling and run the checks used
93
+ in CI:
94
+
95
+ ```sh
96
+ uv sync --group dev
97
+ uv run ruff check .
98
+ uv run ty check --exclude 'gestalt/gen/**' gestalt scripts tests
99
+ uv run vulture --config pyproject.toml
100
+ uv run python -m unittest discover -s tests
101
+ ```
102
+
103
+ The generated protobuf stubs under `gestalt/gen` are excluded from the static
104
+ analysis tools because they are vendored output rather than hand-maintained SDK
105
+ code.
@@ -0,0 +1,151 @@
1
+ from ._api import (
2
+ OK,
3
+ Access,
4
+ Credential,
5
+ Error,
6
+ Model,
7
+ Request,
8
+ Response,
9
+ Subject,
10
+ field,
11
+ )
12
+ from ._cache import Cache, CacheEntry, cache_socket_env
13
+ from ._catalog import (
14
+ Catalog,
15
+ CatalogOperation,
16
+ CatalogParameter,
17
+ OperationAnnotations,
18
+ SessionCatalogProvider,
19
+ )
20
+ from ._indexeddb import (
21
+ CURSOR_NEXT,
22
+ CURSOR_NEXT_UNIQUE,
23
+ CURSOR_PREV,
24
+ CURSOR_PREV_UNIQUE,
25
+ AlreadyExistsError,
26
+ Cursor,
27
+ Index,
28
+ IndexedDB,
29
+ IndexSchema,
30
+ KeyRange,
31
+ NotFoundError,
32
+ ObjectStore,
33
+ ObjectStoreSchema,
34
+ indexeddb_socket_env,
35
+ )
36
+ from ._plugin import Plugin, operation, session_catalog
37
+ from ._providers import (
38
+ AuthenticatedUser,
39
+ AuthProvider,
40
+ BeginLoginRequest,
41
+ BeginLoginResponse,
42
+ CacheProvider,
43
+ Closer,
44
+ CompleteLoginRequest,
45
+ ExternalTokenValidator,
46
+ HealthChecker,
47
+ MetadataProvider,
48
+ PluginProvider,
49
+ PluginProviderAdapter,
50
+ ProviderKind,
51
+ ProviderMetadata,
52
+ S3Provider,
53
+ SecretsProvider,
54
+ SessionTTLProvider,
55
+ WarningsProvider,
56
+ )
57
+ from ._s3 import (
58
+ ENV_S3_SOCKET,
59
+ S3,
60
+ ByteRange,
61
+ CopyOptions,
62
+ ListOptions,
63
+ ListPage,
64
+ ObjectMeta,
65
+ ObjectRef,
66
+ PresignMethod,
67
+ PresignOptions,
68
+ PresignResult,
69
+ ReadOptions,
70
+ S3InvalidRangeError,
71
+ S3NotFoundError,
72
+ S3Object,
73
+ S3PreconditionFailedError,
74
+ S3ReadStream,
75
+ WriteOptions,
76
+ s3_socket_env,
77
+ )
78
+
79
+ __all__ = [
80
+ "AlreadyExistsError",
81
+ "AuthProvider",
82
+ "AuthenticatedUser",
83
+ "Cache",
84
+ "CacheEntry",
85
+ "CacheProvider",
86
+ "Access",
87
+ "BeginLoginRequest",
88
+ "BeginLoginResponse",
89
+ "CURSOR_NEXT",
90
+ "CURSOR_NEXT_UNIQUE",
91
+ "CURSOR_PREV",
92
+ "CURSOR_PREV_UNIQUE",
93
+ "Catalog",
94
+ "CatalogOperation",
95
+ "CatalogParameter",
96
+ "Credential",
97
+ "Closer",
98
+ "CompleteLoginRequest",
99
+ "Cursor",
100
+ "Error",
101
+ "ENV_S3_SOCKET",
102
+ "ExternalTokenValidator",
103
+ "HealthChecker",
104
+ "Index",
105
+ "IndexedDB",
106
+ "IndexSchema",
107
+ "KeyRange",
108
+ "ListOptions",
109
+ "ListPage",
110
+ "MetadataProvider",
111
+ "Model",
112
+ "NotFoundError",
113
+ "OK",
114
+ "ObjectMeta",
115
+ "ObjectRef",
116
+ "ObjectStore",
117
+ "ObjectStoreSchema",
118
+ "OperationAnnotations",
119
+ "Plugin",
120
+ "PluginProvider",
121
+ "PluginProviderAdapter",
122
+ "PresignMethod",
123
+ "PresignOptions",
124
+ "PresignResult",
125
+ "ProviderKind",
126
+ "ProviderMetadata",
127
+ "Request",
128
+ "Response",
129
+ "ReadOptions",
130
+ "S3",
131
+ "S3InvalidRangeError",
132
+ "S3NotFoundError",
133
+ "S3Object",
134
+ "S3PreconditionFailedError",
135
+ "S3Provider",
136
+ "S3ReadStream",
137
+ "SecretsProvider",
138
+ "Subject",
139
+ "SessionCatalogProvider",
140
+ "SessionTTLProvider",
141
+ "WarningsProvider",
142
+ "cache_socket_env",
143
+ "WriteOptions",
144
+ "ByteRange",
145
+ "CopyOptions",
146
+ "field",
147
+ "indexeddb_socket_env",
148
+ "operation",
149
+ "s3_socket_env",
150
+ "session_catalog",
151
+ ]
@@ -0,0 +1,99 @@
1
+ import dataclasses
2
+ from dataclasses import MISSING
3
+ from http import HTTPStatus
4
+ from typing import Any, Final, Generic, TypeVar
5
+
6
+ from typing_extensions import dataclass_transform
7
+
8
+ FIELD_DESCRIPTION_KEY: Final[str] = "description"
9
+ FIELD_REQUIRED_KEY: Final[str] = "required"
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ @dataclasses.dataclass(slots=True)
15
+ class Subject:
16
+ id: str = ""
17
+ kind: str = ""
18
+ display_name: str = ""
19
+ auth_source: str = ""
20
+
21
+
22
+ @dataclasses.dataclass(slots=True)
23
+ class Credential:
24
+ mode: str = ""
25
+ subject_id: str = ""
26
+ connection: str = ""
27
+ instance: str = ""
28
+
29
+
30
+ @dataclasses.dataclass(slots=True)
31
+ class Access:
32
+ policy: str = ""
33
+ role: str = ""
34
+
35
+
36
+ @dataclasses.dataclass(slots=True)
37
+ class Request:
38
+ token: str = ""
39
+ connection_params: dict[str, str] = dataclasses.field(default_factory=dict)
40
+ subject: Subject = dataclasses.field(default_factory=Subject)
41
+ credential: Credential = dataclasses.field(default_factory=Credential)
42
+ access: Access = dataclasses.field(default_factory=Access)
43
+
44
+ def connection_param(self, name: str) -> str | None:
45
+ return self.connection_params.get(name)
46
+
47
+
48
+ @dataclasses.dataclass(slots=True)
49
+ class Response(Generic[T]):
50
+ status: int | None
51
+ body: T
52
+
53
+
54
+ def OK(body: T) -> Response[T]:
55
+ return Response(status=HTTPStatus.OK, body=body)
56
+
57
+
58
+ class Error(Exception):
59
+ def __init__(self, status: int | HTTPStatus, message: str = "") -> None:
60
+ self.status = int(status)
61
+ if message:
62
+ self.message = message
63
+ else:
64
+ try:
65
+ self.message = HTTPStatus(self.status).phrase
66
+ except ValueError:
67
+ self.message = ""
68
+ super().__init__(self.message)
69
+
70
+
71
+ def field(
72
+ *,
73
+ description: str = "",
74
+ default: Any = MISSING,
75
+ default_factory: Any = MISSING,
76
+ required: bool | None = None,
77
+ ) -> Any:
78
+ metadata: dict[str, Any] = {}
79
+ if description:
80
+ metadata[FIELD_DESCRIPTION_KEY] = description
81
+ if required is not None:
82
+ metadata[FIELD_REQUIRED_KEY] = required
83
+
84
+ kwargs: dict[str, Any] = {"metadata": metadata}
85
+ if default is not MISSING:
86
+ kwargs["default"] = default
87
+ if default_factory is not MISSING:
88
+ kwargs["default_factory"] = default_factory
89
+ return dataclasses.field(**kwargs)
90
+
91
+
92
+ @dataclass_transform(field_specifiers=(field,))
93
+ class Model:
94
+ """Base class for operation input/output types. Subclasses are automatically dataclasses."""
95
+
96
+ def __init_subclass__(cls, **kwargs: Any) -> None:
97
+ super().__init_subclass__(**kwargs)
98
+ if "__dataclass_fields__" not in cls.__dict__:
99
+ dataclasses.dataclass(cls)
@@ -0,0 +1,80 @@
1
+ import json
2
+ import pathlib
3
+ from dataclasses import dataclass
4
+ from typing import Final
5
+
6
+ BUNDLED_CONFIG_NAME: Final[str] = "gestalt-runtime.json"
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class PluginTarget:
11
+ module_name: str
12
+ attribute_name: str | None = None
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BundledPluginConfig:
17
+ target: str
18
+ plugin_name: str | None = None
19
+ runtime_kind: str | None = None
20
+
21
+
22
+ def parse_plugin_target(target: str) -> PluginTarget:
23
+ module_name, sep, attribute_name = target.partition(":")
24
+ module_name = module_name.strip()
25
+ attribute_name = attribute_name.strip() or None
26
+ if not module_name:
27
+ raise RuntimeError("tool.gestalt.provider or tool.gestalt.plugin must be in module or module:attribute form")
28
+ if sep and attribute_name is None:
29
+ raise RuntimeError("tool.gestalt.provider or tool.gestalt.plugin attribute is required when ':' is present")
30
+
31
+ return PluginTarget(
32
+ module_name=module_name,
33
+ attribute_name=attribute_name,
34
+ )
35
+
36
+
37
+ def read_bundled_plugin_config(*, bundle_root: pathlib.Path) -> BundledPluginConfig | None:
38
+ config_path = bundle_root / BUNDLED_CONFIG_NAME
39
+ if not config_path.exists():
40
+ return None
41
+
42
+ data = json.loads(config_path.read_text(encoding="utf-8"))
43
+ target = str(data.get("target", "")).strip()
44
+ if not target:
45
+ raise RuntimeError(f"{config_path} is missing target")
46
+
47
+ plugin_name = data.get("plugin_name")
48
+ if plugin_name is not None:
49
+ plugin_name = str(plugin_name).strip() or None
50
+
51
+ runtime_kind = data.get("runtime_kind")
52
+ if runtime_kind is not None:
53
+ runtime_kind = str(runtime_kind).strip() or None
54
+
55
+ return BundledPluginConfig(
56
+ target=target,
57
+ plugin_name=plugin_name,
58
+ runtime_kind=runtime_kind,
59
+ )
60
+
61
+
62
+ def write_bundled_plugin_config(
63
+ path: pathlib.Path,
64
+ *,
65
+ target: str,
66
+ plugin_name: str,
67
+ runtime_kind: str,
68
+ ) -> None:
69
+ path.write_text(
70
+ json.dumps(
71
+ {
72
+ "target": target,
73
+ "plugin_name": plugin_name,
74
+ "runtime_kind": runtime_kind,
75
+ }
76
+ ),
77
+ encoding="utf-8",
78
+ )
79
+
80
+
@@ -0,0 +1,134 @@
1
+ import os
2
+ import pathlib
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ from dataclasses import dataclass
7
+ from typing import Final
8
+
9
+ from ._bootstrap import (
10
+ BUNDLED_CONFIG_NAME,
11
+ parse_plugin_target,
12
+ write_bundled_plugin_config,
13
+ )
14
+
15
+ USAGE: Final[str] = "usage: python -m gestalt._build ROOT MODULE[:ATTRIBUTE] OUTPUT PLUGIN_NAME RUNTIME_KIND GOOS GOARCH"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class BuildArgs:
20
+ root: pathlib.Path
21
+ target: str
22
+ output_path: pathlib.Path
23
+ plugin_name: str
24
+ runtime_kind: str
25
+ goos: str
26
+ goarch: str
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ build_args = _parse_build_args(sys.argv[1:] if argv is None else argv)
31
+ if build_args is None:
32
+ print(USAGE, file=sys.stderr)
33
+ return 2
34
+
35
+ build_plugin_binary(build_args)
36
+ return 0
37
+
38
+
39
+ def _parse_build_args(args: list[str]) -> BuildArgs | None:
40
+ if len(args) != 7:
41
+ return None
42
+
43
+ root, target, output_path, plugin_name, runtime_kind, goos, goarch = args
44
+ return BuildArgs(
45
+ root=pathlib.Path(root),
46
+ target=target,
47
+ output_path=pathlib.Path(output_path),
48
+ plugin_name=plugin_name,
49
+ runtime_kind=runtime_kind,
50
+ goos=goos,
51
+ goarch=goarch,
52
+ )
53
+
54
+
55
+ def build_plugin_binary(args: BuildArgs) -> None:
56
+ root_path = args.root.resolve()
57
+ output_path = args.output_path.resolve()
58
+ plugin_target = parse_plugin_target(args.target)
59
+
60
+ output_path.parent.mkdir(parents=True, exist_ok=True)
61
+ with tempfile.TemporaryDirectory(prefix="gestalt-python-release-") as work_dir:
62
+ work_path = pathlib.Path(work_dir)
63
+ bundle_config_path = work_path / BUNDLED_CONFIG_NAME
64
+ write_bundled_plugin_config(
65
+ bundle_config_path,
66
+ target=args.target,
67
+ plugin_name=args.plugin_name,
68
+ runtime_kind=args.runtime_kind,
69
+ )
70
+
71
+ subprocess.run(
72
+ _pyinstaller_command(
73
+ root_path=root_path,
74
+ output_path=output_path,
75
+ module_name=plugin_target.module_name,
76
+ bundle_config_path=bundle_config_path,
77
+ target_goos=args.goos,
78
+ target_goarch=args.goarch,
79
+ ),
80
+ cwd=root_path,
81
+ check=True,
82
+ )
83
+
84
+
85
+ def _pyinstaller_command(
86
+ *,
87
+ root_path: pathlib.Path,
88
+ output_path: pathlib.Path,
89
+ module_name: str,
90
+ bundle_config_path: pathlib.Path,
91
+ target_goos: str,
92
+ target_goarch: str,
93
+ ) -> list[str]:
94
+ pyinstaller_name = output_path.name.removesuffix(".exe") if target_goos == "windows" else output_path.name
95
+
96
+ command = [
97
+ sys.executable,
98
+ "-m",
99
+ "PyInstaller",
100
+ "--noconfirm",
101
+ "--clean",
102
+ "--onefile",
103
+ "--distpath",
104
+ str(output_path.parent),
105
+ "--workpath",
106
+ str(bundle_config_path.parent / "build"),
107
+ "--specpath",
108
+ str(bundle_config_path.parent / "spec"),
109
+ "--name",
110
+ pyinstaller_name,
111
+ "--hidden-import",
112
+ module_name,
113
+ "--paths",
114
+ str(root_path),
115
+ "--add-data",
116
+ f"{bundle_config_path}{os.pathsep}.",
117
+ str(pathlib.Path(__file__).with_name("_pyinstaller.py")),
118
+ ]
119
+ if sys.platform == "darwin" and target_goos == "darwin":
120
+ target_arch = _darwin_target_arch(target_goarch)
121
+ if target_arch:
122
+ command.extend(["--target-arch", target_arch])
123
+ return command
124
+
125
+
126
+ def _darwin_target_arch(goarch: str) -> str | None:
127
+ return {
128
+ "amd64": "x86_64",
129
+ "arm64": "arm64",
130
+ }.get(goarch)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as _dt
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable
7
+
8
+ import grpc
9
+ from google.protobuf import duration_pb2 as _duration_pb2
10
+
11
+ from .gen.v1 import cache_pb2 as _pb
12
+ from .gen.v1 import cache_pb2_grpc as _pb_grpc
13
+
14
+ pb: Any = _pb
15
+ pb_grpc: Any = _pb_grpc
16
+ duration_pb2: Any = _duration_pb2
17
+
18
+ ENV_CACHE_SOCKET = "GESTALT_CACHE_SOCKET"
19
+
20
+
21
+ def cache_socket_env(name: str | None = None) -> str:
22
+ trimmed = (name or "").strip()
23
+ if not trimmed:
24
+ return ENV_CACHE_SOCKET
25
+ normalized = "".join(
26
+ ch.upper() if ch.isascii() and ch.isalnum() else "_" for ch in trimmed
27
+ )
28
+ return f"{ENV_CACHE_SOCKET}_{normalized}"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CacheEntry:
33
+ key: str
34
+ value: bytes
35
+
36
+
37
+ class Cache:
38
+ def __init__(self, name: str | None = None) -> None:
39
+ env_name = cache_socket_env(name)
40
+ socket_path = os.environ.get(env_name, "")
41
+ if not socket_path:
42
+ raise RuntimeError(f"{env_name} is not set")
43
+ self._channel = grpc.insecure_channel(f"unix:{socket_path}")
44
+ self._stub = pb_grpc.CacheStub(self._channel)
45
+
46
+ def close(self) -> None:
47
+ self._channel.close()
48
+
49
+ def get(self, key: str) -> bytes | None:
50
+ resp = _grpc_call(self._stub.Get, pb.CacheGetRequest(key=key))
51
+ if not resp.found:
52
+ return None
53
+ return bytes(resp.value)
54
+
55
+ def get_many(self, keys: list[str]) -> dict[str, bytes]:
56
+ resp = _grpc_call(self._stub.GetMany, pb.CacheGetManyRequest(keys=keys))
57
+ out: dict[str, bytes] = {}
58
+ for entry in resp.entries:
59
+ if entry.found:
60
+ out[entry.key] = bytes(entry.value)
61
+ return out
62
+
63
+ def set(
64
+ self,
65
+ key: str,
66
+ value: bytes,
67
+ ttl: _dt.timedelta | None = None,
68
+ ) -> None:
69
+ _grpc_call(
70
+ self._stub.Set,
71
+ pb.CacheSetRequest(key=key, value=bytes(value), ttl=_duration_from_ttl(ttl)),
72
+ )
73
+
74
+ def set_many(
75
+ self,
76
+ entries: Iterable[CacheEntry],
77
+ ttl: _dt.timedelta | None = None,
78
+ ) -> None:
79
+ _grpc_call(
80
+ self._stub.SetMany,
81
+ pb.CacheSetManyRequest(
82
+ entries=[
83
+ pb.CacheSetEntry(key=entry.key, value=bytes(entry.value))
84
+ for entry in entries
85
+ ],
86
+ ttl=_duration_from_ttl(ttl),
87
+ ),
88
+ )
89
+
90
+ def delete(self, key: str) -> bool:
91
+ resp = _grpc_call(self._stub.Delete, pb.CacheDeleteRequest(key=key))
92
+ return bool(resp.deleted)
93
+
94
+ def delete_many(self, keys: list[str]) -> int:
95
+ resp = _grpc_call(self._stub.DeleteMany, pb.CacheDeleteManyRequest(keys=keys))
96
+ return int(resp.deleted)
97
+
98
+ def touch(self, key: str, ttl: _dt.timedelta) -> bool:
99
+ resp = _grpc_call(
100
+ self._stub.Touch,
101
+ pb.CacheTouchRequest(key=key, ttl=_duration_from_ttl(ttl)),
102
+ )
103
+ return bool(resp.touched)
104
+
105
+ def __enter__(self) -> Cache:
106
+ return self
107
+
108
+ def __exit__(self, *args: Any) -> None:
109
+ self.close()
110
+
111
+
112
+ def _duration_from_ttl(ttl: _dt.timedelta | None) -> Any:
113
+ if ttl is None:
114
+ return None
115
+ if ttl.total_seconds() <= 0:
116
+ return None
117
+ duration = duration_pb2.Duration()
118
+ duration.FromTimedelta(ttl)
119
+ return duration
120
+
121
+
122
+ def _grpc_call(method: Any, request: Any) -> Any:
123
+ try:
124
+ return method(request)
125
+ except grpc.RpcError:
126
+ raise