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.
- placeframe_common-0.1.0.dev35547537192/.gitignore +35 -0
- placeframe_common-0.1.0.dev35547537192/PKG-INFO +18 -0
- placeframe_common-0.1.0.dev35547537192/README.md +5 -0
- placeframe_common-0.1.0.dev35547537192/pyproject.toml +25 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/boto_clients.py +79 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/litestar.py +106 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/logging_config.py +21 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/multipart_requests.py +75 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/py.typed +0 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/stream_tar.py +42 -0
- placeframe_common-0.1.0.dev35547537192/src/placeframe_common/tar.py +44 -0
- placeframe_common-0.1.0.dev35547537192/tests/__init__.py +0 -0
- placeframe_common-0.1.0.dev35547537192/tests/test_multipart_requests.py +94 -0
|
@@ -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()
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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"
|