placeframe-common 0.1.0.dev35547537192__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.
@@ -0,0 +1,35 @@
1
+ **/__pycache__
2
+ **/*.pyc
3
+ **/*.pyo
4
+ **/*.pyd
5
+ **/.venv/
6
+ **/*.egg-info
7
+ **/dist/
8
+ **/build/
9
+ !/build/
10
+ **/*.sln
11
+ **/*.slnx
12
+ **/bin/
13
+ **/obj/
14
+ **/bin.meta
15
+ **/obj.meta
16
+ .pytest_cache
17
+ .ruff_cache
18
+ .vs
19
+ .act
20
+ .env
21
+ .env.shas
22
+ .secrets
23
+ score/compose.yaml
24
+ score/manifests.yaml
25
+ score/.score-compose/
26
+ score/.score-k8s/
27
+ metadata.json
28
+ storage/
29
+ tidy-commits.json
30
+ .build-version.json
31
+ artifacts/
32
+ .pnpm-store/
33
+ .placeframe/
34
+ .claude/scheduled_tasks.lock
35
+ bookmark.md
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: placeframe-common
3
+ Version: 0.1.0.dev35547537192
4
+ Summary: Shared utilities for Placeframe services: boto/S3, Docker SDK, Litestar, JWT
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: boto3>=1.39.5
8
+ Requires-Dist: botocore>=1.42.24
9
+ Requires-Dist: litestar>=2.19.0
10
+ Requires-Dist: logconf[otlp]
11
+ Requires-Dist: pydantic>=2.12.5
12
+ Description-Content-Type: text/markdown
13
+
14
+ # placeframe-common
15
+
16
+ Shared Python utilities for Placeframe's backend services: boto/S3 helpers, Docker SDK helpers, Litestar middleware, and JWT utilities. Distribution name `placeframe-common`, import name `placeframe_common`.
17
+
18
+ Consumed by the `api`, `lease-server`, `localizer`, `reconstructor`, `zed-capture`, `database-manager`, and `scripts` packages in the [placeframe](https://github.com/outernet-foundation/placeframe) repo. Versions are published to PyPI from per-package git tags; the committed `pyproject.toml` version is a permanent `0.0.0.dev0` sentinel patched at publish time.
@@ -0,0 +1,5 @@
1
+ # placeframe-common
2
+
3
+ Shared Python utilities for Placeframe's backend services: boto/S3 helpers, Docker SDK helpers, Litestar middleware, and JWT utilities. Distribution name `placeframe-common`, import name `placeframe_common`.
4
+
5
+ Consumed by the `api`, `lease-server`, `localizer`, `reconstructor`, `zed-capture`, `database-manager`, and `scripts` packages in the [placeframe](https://github.com/outernet-foundation/placeframe) repo. Versions are published to PyPI from per-package git tags; the committed `pyproject.toml` version is a permanent `0.0.0.dev0` sentinel patched at publish time.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "placeframe-common"
3
+ version = "0.1.0.dev35547537192"
4
+ description = "Shared utilities for Placeframe services: boto/S3, Docker SDK, Litestar, JWT"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "boto3>=1.39.5",
10
+ "litestar>=2.19.0",
11
+ "pydantic>=2.12.5",
12
+ "botocore>=1.42.24",
13
+ "logconf[otlp]",
14
+ ]
15
+
16
+ [dependency-groups]
17
+ dev = ["boto3-stubs[batch,ecs,secretsmanager,s3,lambda]>=1.40.29"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/placeframe_common"]
25
+ include = ["src/placeframe_common/py.typed"]
@@ -0,0 +1,79 @@
1
+ from typing import TYPE_CHECKING, Any, cast
2
+
3
+ import boto3
4
+ from botocore.config import Config
5
+ from pydantic import AnyHttpUrl
6
+
7
+ if TYPE_CHECKING:
8
+ from mypy_boto3_batch import BatchClient
9
+ from mypy_boto3_ecs import ECSClient
10
+ from mypy_boto3_lambda import LambdaClient
11
+ from mypy_boto3_s3 import S3Client
12
+ from mypy_boto3_secretsmanager import SecretsManagerClient
13
+ else:
14
+ BatchClient = Any
15
+ ECSClient = Any
16
+ S3Client = Any
17
+ SecretsManagerClient = Any
18
+ LambdaClient = Any
19
+
20
+
21
+ def create_batch_client() -> BatchClient:
22
+ return cast(BatchClient, boto3.client("batch", region_name="us-east-1")) # type: ignore[call-arg]
23
+
24
+
25
+ def create_ecs_client() -> ECSClient:
26
+ return cast(
27
+ ECSClient,
28
+ boto3.client( # pyright: ignore[reportUnknownMemberType]
29
+ "ecs",
30
+ region_name="us-east-1",
31
+ config=Config(
32
+ connect_timeout=10,
33
+ read_timeout=910,
34
+ tcp_keepalive=True,
35
+ retries={"max_attempts": 3, "mode": "standard"},
36
+ ),
37
+ ),
38
+ ) # type: ignore[call-arg]
39
+
40
+
41
+ def create_lambda_client() -> LambdaClient:
42
+ return cast(
43
+ LambdaClient,
44
+ boto3.client( # pyright: ignore[reportUnknownMemberType]
45
+ "lambda",
46
+ region_name="us-east-1",
47
+ config=Config(
48
+ connect_timeout=10,
49
+ read_timeout=910,
50
+ tcp_keepalive=True,
51
+ retries={"max_attempts": 3, "mode": "standard"},
52
+ ),
53
+ ),
54
+ ) # type: ignore[call-arg]
55
+
56
+
57
+ def create_s3_client(
58
+ s3_endpoint_url: AnyHttpUrl | None, s3_access_key: str | None, s3_secret_key: str | None
59
+ ) -> S3Client:
60
+
61
+ kwargs: dict[str, Any] = {}
62
+
63
+ if s3_endpoint_url:
64
+ kwargs.update(
65
+ endpoint_url=str(s3_endpoint_url),
66
+ aws_access_key_id=s3_access_key,
67
+ aws_secret_access_key=s3_secret_key,
68
+ config=Config(
69
+ signature_version="s3v4",
70
+ region_name="us-east-1", # required by SigV4
71
+ s3={"addressing_style": "path"}, # ← force path-style (/{bucket}/{key})
72
+ ),
73
+ )
74
+
75
+ return cast(S3Client, boto3.client("s3", **kwargs)) # type: ignore[call-arg]
76
+
77
+
78
+ def create_secretsmanager_client() -> SecretsManagerClient:
79
+ return cast(SecretsManagerClient, boto3.client("secretsmanager", region_name="us-east-1")) # type: ignore[call-arg]
@@ -0,0 +1,106 @@
1
+ from logging import getLogger
2
+ from typing import Any, Sequence, cast
3
+
4
+ from litestar import Litestar, Request, Response, get
5
+ from litestar.exceptions import HTTPException, ValidationException
6
+ from litestar.handlers import HTTPRouteHandler
7
+ from litestar.logging import BaseLoggingConfig
8
+ from litestar.openapi.config import OpenAPIConfig
9
+ from litestar.openapi.spec import Schema
10
+ from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType
11
+ from litestar.plugins import OpenAPISchemaPlugin
12
+ from litestar.response import Redirect
13
+ from litestar.types import ControllerRouterHandler, Method, Middleware, Empty, EmptyType
14
+ from litestar.types.internal_types import PathParameterDefinition
15
+ from litestar.typing import FieldDefinition
16
+
17
+ logger = getLogger("uvicorn.error")
18
+
19
+
20
+ class FloatFormatPlugin(OpenAPISchemaPlugin):
21
+ # OpenAPIFormat enum has no DOUBLE member but Schema.format serializes any
22
+ # string subclass; cast satisfies the static type and emits "format": "double".
23
+ _DOUBLE_FORMAT = cast(OpenAPIFormat, "double")
24
+
25
+ @staticmethod
26
+ def is_plugin_supported_type(value: Any) -> bool:
27
+ return value is float
28
+
29
+ def is_plugin_supported_field(self, field_definition: FieldDefinition) -> bool:
30
+ return field_definition.annotation is float
31
+
32
+ def to_openapi_schema(self, field_definition: FieldDefinition, schema_creator: Any) -> Schema:
33
+ return Schema(type=OpenAPIType.NUMBER, format=self._DOUBLE_FORMAT)
34
+
35
+
36
+ # Make codegened client functions use the same name as their corresponding server functions
37
+ def use_handler_name(
38
+ route_handler: HTTPRouteHandler, http_method: Method, path_components: list[str | PathParameterDefinition]
39
+ ) -> str:
40
+ return route_handler.handler_name
41
+
42
+
43
+ def log_http_exception(request: Request[Any, Any, Any], exception: HTTPException) -> Response[dict[str, Any]]:
44
+ # Server Errors
45
+ if exception.status_code >= 500:
46
+ logger.exception(
47
+ "HTTPException %s on %s %s: %r",
48
+ exception.status_code,
49
+ request.method,
50
+ request.url.path,
51
+ exception.detail,
52
+ exc_info=exception,
53
+ )
54
+
55
+ return Response(content={"detail": "Internal Server Error"}, status_code=exception.status_code)
56
+
57
+ # Client Errors
58
+ logger.info(
59
+ "HTTPException %s on %s %s: %r", exception.status_code, request.method, request.url.path, exception.detail
60
+ )
61
+
62
+ content: dict[str, Any] = {"detail": exception.detail}
63
+
64
+ if isinstance(exception, ValidationException) and exception.extra:
65
+ content["validation_errors"] = exception.extra
66
+
67
+ return Response(content=content, status_code=exception.status_code)
68
+
69
+
70
+ def log_unhandled_exception(request: Request[Any, Any, Any], exception: Exception) -> Response[dict[str, Any]]:
71
+ logger.exception("Unhandled exception on %s %s", request.method, request.url.path, exc_info=exception)
72
+
73
+ return Response(content={"detail": "Internal Server Error"}, status_code=500)
74
+
75
+
76
+ @get("/", include_in_schema=False)
77
+ async def root() -> Redirect:
78
+ return Redirect(path="/schema")
79
+
80
+
81
+ @get("/health", include_in_schema=False)
82
+ async def health_check() -> dict[str, str]:
83
+ return {"status": "ok"}
84
+
85
+
86
+ def create_litestar_app(
87
+ route_handlers: Sequence[ControllerRouterHandler],
88
+ openapi_config: OpenAPIConfig,
89
+ middleware: Sequence[Middleware] | None = None,
90
+ # Default Empty preserves Litestar's auto-LoggingConfig behavior for callers
91
+ # that haven't configured logging themselves (api, localizer). Pass None to
92
+ # opt out — required when the caller has already called dictConfig() and
93
+ # would have its handlers clobbered by Litestar's default queue-based setup.
94
+ logging_config: BaseLoggingConfig | EmptyType | None = Empty,
95
+ ) -> Litestar:
96
+ openapi_config.operation_id_creator = use_handler_name
97
+
98
+ return Litestar(
99
+ [root, health_check, *route_handlers],
100
+ openapi_config=openapi_config,
101
+ middleware=middleware,
102
+ request_max_body_size=1024 * 1024 * 1024,
103
+ exception_handlers={HTTPException: log_http_exception, Exception: log_unhandled_exception},
104
+ logging_config=logging_config,
105
+ plugins=[FloatFormatPlugin()],
106
+ )
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from logconf import configure_logging as _configure_logging
6
+
7
+
8
+ def configure_logging(
9
+ service_name: str,
10
+ *,
11
+ instance_id: str | None = None,
12
+ log_file_path: Path | None = None,
13
+ uvicorn_logger_handlers: bool = False,
14
+ ) -> Path | None:
15
+ return _configure_logging(
16
+ service_name,
17
+ service_namespace="placeframe",
18
+ instance_id=instance_id,
19
+ log_file_path=log_file_path,
20
+ uvicorn_logger_handlers=uvicorn_logger_handlers,
21
+ )
@@ -0,0 +1,75 @@
1
+ import json
2
+ from typing import ClassVar
3
+
4
+ from litestar.enums import RequestEncodingType
5
+ from litestar.openapi.spec import Encoding, OpenAPIMediaType, Operation, Reference, RequestBody, Schema
6
+ from pydantic import BaseModel, ConfigDict
7
+ from pydantic.types import Json as JsonMetadata
8
+
9
+
10
+ def multipart_json(value: str | list[str]) -> str:
11
+ if isinstance(value, list):
12
+ if len(value) != 1:
13
+ raise ValueError("expected exactly one multipart value")
14
+ return value[0]
15
+
16
+ return value
17
+
18
+
19
+ def multipart_json_list(value: str | list[str]) -> str:
20
+ if isinstance(value, list):
21
+ if len(value) == 1:
22
+ value = value[0]
23
+ else:
24
+ return json.dumps(value)
25
+
26
+ if "," in value and not value.lstrip().startswith("["):
27
+ return json.dumps([part.strip() for part in value.split(",")])
28
+
29
+ if not value.lstrip().startswith("["):
30
+ return json.dumps([value])
31
+
32
+ return value
33
+
34
+
35
+ class MultipartRequestModel(BaseModel):
36
+ model_config = ConfigDict(arbitrary_types_allowed=True)
37
+ multipart_json_fields: ClassVar[dict[str, set[str]]] = {}
38
+
39
+ @classmethod
40
+ def __pydantic_init_subclass__(cls, **kwargs: object) -> None: # noqa: PLW3201
41
+ super().__pydantic_init_subclass__(**kwargs)
42
+ cls.multipart_json_fields[cls.__name__] = {
43
+ field_name
44
+ for field_name, field_info in cls.model_fields.items()
45
+ if any(type(item) is JsonMetadata for item in field_info.metadata)
46
+ }
47
+
48
+
49
+ class MultipartRequestOperation(Operation):
50
+ def to_schema(self) -> dict[str, object]:
51
+ request_body = self.request_body
52
+ if not isinstance(request_body, RequestBody):
53
+ return super().to_schema()
54
+
55
+ media_type = request_body.content.get(RequestEncodingType.MULTI_PART)
56
+ if not isinstance(media_type, OpenAPIMediaType):
57
+ return super().to_schema()
58
+
59
+ schema = media_type.schema
60
+ model_name: str | None = None
61
+
62
+ if isinstance(schema, Reference):
63
+ model_name = schema.ref.rsplit("/", maxsplit=1)[-1]
64
+ elif isinstance(schema, Schema) and isinstance(schema.title, str):
65
+ model_name = schema.title
66
+
67
+ json_fields = MultipartRequestModel.multipart_json_fields.get(model_name or "", set())
68
+ if json_fields:
69
+ encoding = media_type.encoding or {}
70
+ encoding.update({
71
+ field_name: Encoding(content_type="application/json") for field_name in sorted(json_fields)
72
+ })
73
+ media_type.encoding = encoding
74
+
75
+ return super().to_schema()
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tarfile
5
+ from os import PathLike
6
+ from pathlib import Path
7
+
8
+
9
+ def build_tar(
10
+ src_directory: str | PathLike[str],
11
+ dst_path: str | PathLike[str],
12
+ exclude_suffixes: tuple[str, ...] = (),
13
+ ) -> None:
14
+ src_path = Path(src_directory).resolve()
15
+ if not src_path.is_dir():
16
+ raise FileNotFoundError(f"{src_path} is not a directory")
17
+ dst = Path(dst_path)
18
+ # Atomic publish: build into a sibling .tmp, fsync, rename, fsync the parent
19
+ # directory so a power loss between rename-journal-commit and data-flush
20
+ # cannot leave the published name pointing at an empty inode.
21
+ temp_path = dst.with_name(dst.name + ".tmp")
22
+ if temp_path.exists():
23
+ temp_path.unlink()
24
+ with tarfile.open(temp_path, mode="w") as tar_file:
25
+ for path in sorted(src_path.rglob("*")):
26
+ if not path.is_file():
27
+ continue
28
+ if exclude_suffixes and path.suffix in exclude_suffixes:
29
+ continue
30
+ arcname = str(path.relative_to(src_path))
31
+ tar_file.add(str(path), arcname=arcname, recursive=False)
32
+ file_descriptor = os.open(temp_path, os.O_RDONLY)
33
+ try:
34
+ os.fsync(file_descriptor)
35
+ finally:
36
+ os.close(file_descriptor)
37
+ temp_path.rename(dst)
38
+ dir_fd = os.open(dst.parent, os.O_RDONLY)
39
+ try:
40
+ os.fsync(dir_fd)
41
+ finally:
42
+ os.close(dir_fd)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import tarfile
4
+ from collections.abc import Iterator
5
+ from io import RawIOBase
6
+ from typing import IO
7
+
8
+ from litestar.exceptions import HTTPException
9
+ from litestar.status_codes import HTTP_422_UNPROCESSABLE_ENTITY
10
+
11
+
12
+ # A stream-mode tar member's backing object is tarfile's internal _Stream, which lacks seekable().
13
+ # Consumers that probe the file interface (boto3's upload_fileobj calls seekable() to choose an upload
14
+ # manager) hit an AttributeError on it. Wrapping the member in a RawIOBase gives a real non-seekable
15
+ # binary stream: IOBase.seekable() already returns False, so a bare read() over the source is enough.
16
+ class _NonSeekableTarMember(RawIOBase):
17
+ def __init__(self, source: IO[bytes]) -> None:
18
+ self._source = source
19
+
20
+ def readable(self) -> bool:
21
+ return True
22
+
23
+ def read(self, size: int | None = -1) -> bytes:
24
+ return self._source.read(-1 if size is None else size)
25
+
26
+
27
+ # Streams a tar's regular-file members as (name, fileobj) pairs in stream mode, so it works for both
28
+ # seekable uploads and non-seekable storage bodies and never loads the whole tar into memory. The
29
+ # caller positions the fileobj at the start; each yielded fileobj is valid only until the next
30
+ # iteration, so read it before advancing. A malformed tar surfaces as a 422.
31
+ def iter_tar_file_members(fileobj: IO[bytes]) -> Iterator[tuple[str, RawIOBase]]:
32
+ try:
33
+ with tarfile.open(fileobj=fileobj, mode="r|*") as tar:
34
+ for member in tar:
35
+ if not member.isfile():
36
+ continue
37
+
38
+ extracted = tar.extractfile(member)
39
+ if extracted is None:
40
+ continue
41
+
42
+ yield member.name, _NonSeekableTarMember(extracted)
43
+ except tarfile.ReadError as error:
44
+ raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid tar file: {error}") from error
@@ -0,0 +1,94 @@
1
+ from typing import Annotated
2
+ from uuid import UUID, uuid4
3
+
4
+ import pytest
5
+ from placeframe_common.multipart_requests import (
6
+ MultipartRequestModel,
7
+ MultipartRequestOperation,
8
+ multipart_json,
9
+ multipart_json_list,
10
+ )
11
+ from litestar import Litestar, post
12
+ from litestar.datastructures import UploadFile
13
+ from litestar.enums import RequestEncodingType
14
+ from litestar.params import Body
15
+ from pydantic import BaseModel, BeforeValidator, Json, ValidationError
16
+
17
+
18
+ class _CameraConfig(BaseModel):
19
+ width: int
20
+
21
+
22
+ class _MultipartUuidListPayload(MultipartRequestModel):
23
+ reconstruction_ids: Annotated[Json[list[UUID]], BeforeValidator(multipart_json_list)]
24
+
25
+
26
+ class _MultipartObjectPayload(MultipartRequestModel):
27
+ camera_config: Annotated[Json[_CameraConfig], BeforeValidator(multipart_json)]
28
+
29
+
30
+ class _MultipartRoutePayload(MultipartRequestModel):
31
+ reconstruction_ids: Annotated[Json[list[UUID]], BeforeValidator(multipart_json_list)]
32
+ camera_config: Annotated[Json[_CameraConfig], BeforeValidator(multipart_json)]
33
+ image: UploadFile
34
+
35
+
36
+ @post("/localization", operation_class=MultipartRequestOperation, sync_to_thread=False)
37
+ def _multipart_route(data: Annotated[_MultipartRoutePayload, Body(media_type=RequestEncodingType.MULTI_PART)]) -> None:
38
+ del data
39
+
40
+
41
+ @pytest.fixture
42
+ def uuid_list_input(request: pytest.FixtureRequest) -> tuple[str | list[str], list[UUID]]:
43
+ a, b = uuid4(), uuid4()
44
+ formats: dict[str, tuple[str | list[str], list[UUID]]] = {
45
+ "single-uuid-in-list": ([str(a)], [a]),
46
+ "bare-uuid-string": (str(a), [a]),
47
+ "json-array-single": ([f'["{a}"]'], [a]),
48
+ "json-array-multiple": ([f'["{a}","{b}"]'], [a, b]),
49
+ "csv-string": ([f"{a},{b}"], [a, b]),
50
+ "multi-format-list": ([str(a), str(b)], [a, b]),
51
+ }
52
+ return formats[request.param]
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "uuid_list_input",
57
+ [
58
+ "single-uuid-in-list",
59
+ "bare-uuid-string",
60
+ "json-array-single",
61
+ "json-array-multiple",
62
+ "csv-string",
63
+ "multi-format-list",
64
+ ],
65
+ indirect=True,
66
+ )
67
+ def test_multipart_request_model_parses_uuid_list(uuid_list_input: tuple[str | list[str], list[UUID]]) -> None:
68
+ raw_input, expected = uuid_list_input
69
+ payload = _MultipartUuidListPayload.model_validate({"reconstruction_ids": raw_input})
70
+
71
+ assert payload.reconstruction_ids == expected
72
+
73
+
74
+ def test_multipart_request_model_parses_json_objects_from_single_part_lists() -> None:
75
+ payload = _MultipartObjectPayload.model_validate({"camera_config": ['{"width": 1280}']})
76
+
77
+ assert payload.camera_config == _CameraConfig(width=1280)
78
+
79
+
80
+ def test_multipart_request_model_rejects_multiple_json_object_parts() -> None:
81
+ with pytest.raises(ValidationError) as exc_info:
82
+ _MultipartObjectPayload.model_validate({"camera_config": ['{"width": 1280}', '{"width": 720}']})
83
+
84
+ assert exc_info.value.errors()[0]["type"] == "value_error"
85
+
86
+
87
+ def test_multipart_request_operation_marks_uuid_arrays_as_json() -> None:
88
+ app = Litestar(route_handlers=[_multipart_route])
89
+
90
+ schema = app.openapi_schema.to_schema()
91
+ content = schema["paths"]["/localization"]["post"]["requestBody"]["content"]["multipart/form-data"]
92
+
93
+ assert content["encoding"]["camera_config"]["contentType"] == "application/json"
94
+ assert content["encoding"]["reconstruction_ids"]["contentType"] == "application/json"