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.
- gestalt_sdk-0.0.1a8/PKG-INFO +10 -0
- gestalt_sdk-0.0.1a8/README.md +105 -0
- gestalt_sdk-0.0.1a8/gestalt/__init__.py +151 -0
- gestalt_sdk-0.0.1a8/gestalt/_api.py +99 -0
- gestalt_sdk-0.0.1a8/gestalt/_bootstrap.py +80 -0
- gestalt_sdk-0.0.1a8/gestalt/_build.py +134 -0
- gestalt_sdk-0.0.1a8/gestalt/_cache.py +126 -0
- gestalt_sdk-0.0.1a8/gestalt/_catalog.py +239 -0
- gestalt_sdk-0.0.1a8/gestalt/_indexeddb.py +604 -0
- gestalt_sdk-0.0.1a8/gestalt/_operations.py +250 -0
- gestalt_sdk-0.0.1a8/gestalt/_plugin.py +361 -0
- gestalt_sdk-0.0.1a8/gestalt/_providers.py +168 -0
- gestalt_sdk-0.0.1a8/gestalt/_pyinstaller.py +4 -0
- gestalt_sdk-0.0.1a8/gestalt/_runtime.py +747 -0
- gestalt_sdk-0.0.1a8/gestalt/_s3.py +594 -0
- gestalt_sdk-0.0.1a8/gestalt/_serialization.py +23 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/__init__.py +1 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/__init__.py +18 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/auth_pb2.py +62 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/auth_pb2_grpc.py +227 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/cache_pb2.py +67 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/cache_pb2_grpc.py +356 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/datastore_pb2.py +100 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/datastore_pb2_grpc.py +877 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/plugin_pb2.py +96 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/plugin_pb2_grpc.py +270 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/runtime_pb2.py +49 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/runtime_pb2_grpc.py +184 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/s3_pb2.py +91 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/s3_pb2_grpc.py +361 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/secrets_pb2.py +41 -0
- gestalt_sdk-0.0.1a8/gestalt/gen/v1/secrets_pb2_grpc.py +77 -0
- gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/PKG-INFO +10 -0
- gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/SOURCES.txt +46 -0
- gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/dependency_links.txt +1 -0
- gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/requires.txt +5 -0
- gestalt_sdk-0.0.1a8/gestalt_sdk.egg-info/top_level.txt +1 -0
- gestalt_sdk-0.0.1a8/pyproject.toml +40 -0
- gestalt_sdk-0.0.1a8/setup.cfg +4 -0
- gestalt_sdk-0.0.1a8/tests/test_build.py +130 -0
- gestalt_sdk-0.0.1a8/tests/test_cache_transport.py +107 -0
- gestalt_sdk-0.0.1a8/tests/test_indexeddb_cursor_unit.py +36 -0
- gestalt_sdk-0.0.1a8/tests/test_indexeddb_transport.py +280 -0
- gestalt_sdk-0.0.1a8/tests/test_operations.py +103 -0
- gestalt_sdk-0.0.1a8/tests/test_plugin.py +256 -0
- gestalt_sdk-0.0.1a8/tests/test_runtime.py +702 -0
- gestalt_sdk-0.0.1a8/tests/test_s3_transport.py +268 -0
- 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
|