agentenv-framework 0.9.1254__py3-none-any.whl
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.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Google Cloud Storage implementation of the ObjectStore interface (the ``gcp`` extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
import google.auth
|
|
18
|
+
import requests
|
|
19
|
+
from agentenv_protocol.transfers import HttpGetGrant, HttpPostPolicyGrant, HttpPutGrant
|
|
20
|
+
from google.api_core.exceptions import GoogleAPIError, NotFound, PreconditionFailed, RetryError
|
|
21
|
+
from google.auth import iam
|
|
22
|
+
from google.auth.credentials import Signing
|
|
23
|
+
from google.auth.exceptions import GoogleAuthError, TransportError
|
|
24
|
+
from google.cloud import storage
|
|
25
|
+
from google.cloud.storage import transfer_manager
|
|
26
|
+
from google.cloud.storage.retry import DEFAULT_RETRY
|
|
27
|
+
from google.oauth2 import service_account
|
|
28
|
+
|
|
29
|
+
from agent_env.config.errors import ConfigError
|
|
30
|
+
from agent_env.store import _google
|
|
31
|
+
from agent_env.store.base import GrantUnavailableError, ObjectAlreadyExistsError, ObjectNotFoundError
|
|
32
|
+
from agent_env.store.object_store.object_store import (
|
|
33
|
+
DEFAULT_CONTENT_TYPE,
|
|
34
|
+
ObjectMetadata,
|
|
35
|
+
ObjectStore,
|
|
36
|
+
UploadPolicy,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_SCHEME = "gs://"
|
|
42
|
+
# A key's V4 signature lasts up to seven days; Google guarantees one made through IAM signBlob,
|
|
43
|
+
# which signs with a system-managed key, for twelve hours.
|
|
44
|
+
_MAX_KEY_SIGNED_SECONDS = 7 * 24 * 60 * 60
|
|
45
|
+
_MAX_IAM_SIGNED_SECONDS = 12 * 60 * 60
|
|
46
|
+
_PROBE_TIMEOUT_SECONDS = 20
|
|
47
|
+
# The client creates a larger object in a resumable session, which meets a taken key only at its
|
|
48
|
+
# last chunk.
|
|
49
|
+
_MULTIPART_LIMIT = 8 * 1024 * 1024
|
|
50
|
+
# Tags each write-once create, so a 412 for a create the client retried after losing its
|
|
51
|
+
# response can be told apart from an object another writer put there first.
|
|
52
|
+
_WRITE_ID = "agentenv-write-id"
|
|
53
|
+
_DOWNLOAD_WORKERS = 4
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _IamSigner(Signing):
|
|
57
|
+
"""Signs as a service account through IAM signBlob over one pooled session, retrying dropped
|
|
58
|
+
connections. google-auth's impersonated credentials open a connection per signature."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, credentials, email: str) -> None:
|
|
61
|
+
self._iam = iam.Signer(_google.pooled_request(), credentials, email)
|
|
62
|
+
self._email = email
|
|
63
|
+
|
|
64
|
+
def sign_bytes(self, message: bytes) -> bytes:
|
|
65
|
+
try:
|
|
66
|
+
return _google.RETRY(self._iam.sign)(message)
|
|
67
|
+
except RetryError as e:
|
|
68
|
+
raise TransportError(f"IAM signed nothing as {self._email} within the retry window: {e.cause}") from e
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def signer_email(self) -> str:
|
|
72
|
+
return self._email
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def signer(self) -> iam.Signer:
|
|
76
|
+
return self._iam
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GcsObjectStore(ObjectStore):
|
|
80
|
+
"""ObjectStore backed by a Cloud Storage bucket (injected client, config-free).
|
|
81
|
+
|
|
82
|
+
``signer`` signs URLs and grants: credentials that hold a key, or ones that sign through IAM.
|
|
83
|
+
Without one the store issues neither. Its reads return an object's bytes as stored."""
|
|
84
|
+
|
|
85
|
+
max_single_upload_bytes = 5 * 1024**4 # one Cloud Storage object
|
|
86
|
+
|
|
87
|
+
def __init__(self, client, bucket: str, *, signer: Signing | None = None) -> None:
|
|
88
|
+
self._client = client
|
|
89
|
+
self._bucket = bucket
|
|
90
|
+
self._signer = signer
|
|
91
|
+
# Grants are HTTPS URLs, so a plain-HTTP endpoint (a local emulator) issues none.
|
|
92
|
+
self.supports_transfer_grants = signer is not None and client.api_endpoint.startswith("https://")
|
|
93
|
+
self._max_signed_seconds = (
|
|
94
|
+
_MAX_KEY_SIGNED_SECONDS if isinstance(signer, service_account.Credentials) else _MAX_IAM_SIGNED_SECONDS
|
|
95
|
+
)
|
|
96
|
+
self._warned_unsigned = False
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_config(
|
|
100
|
+
cls, *, bucket: str, project: str | None = None, signing_service_account: str | None = None
|
|
101
|
+
) -> GcsObjectStore:
|
|
102
|
+
"""Authenticate through Application Default Credentials. With ``signing_service_account``
|
|
103
|
+
the store always signs as that account through IAM, which needs
|
|
104
|
+
``iam.serviceAccounts.signBlob`` on it; without it, credentials that can sign do. Lists
|
|
105
|
+
the bucket, and signs once when IAM signs, so a missing bucket or a signer that cannot sign
|
|
106
|
+
fails here rather than at the first read or signed URL."""
|
|
107
|
+
if signing_service_account is not None and not signing_service_account.strip():
|
|
108
|
+
raise ConfigError("signing_service_account is empty; omit it to sign with the credentials themselves")
|
|
109
|
+
try:
|
|
110
|
+
credentials, default_project = google.auth.default(scopes=_google.SCOPES)
|
|
111
|
+
except (GoogleAuthError, OSError) as e:
|
|
112
|
+
raise ConfigError(f"No Google credentials for gs://{bucket}: {e}") from e
|
|
113
|
+
client = storage.Client(project=project or default_project, credentials=credentials)
|
|
114
|
+
if signing_service_account:
|
|
115
|
+
signer = _IamSigner(credentials, signing_service_account)
|
|
116
|
+
else:
|
|
117
|
+
signer = credentials if isinstance(credentials, Signing) else None
|
|
118
|
+
probe = DEFAULT_RETRY.with_timeout(_PROBE_TIMEOUT_SECONDS)
|
|
119
|
+
try:
|
|
120
|
+
next(iter(client.list_blobs(bucket, max_results=1, timeout=_PROBE_TIMEOUT_SECONDS, retry=probe)), None)
|
|
121
|
+
except (GoogleAPIError, GoogleAuthError, requests.RequestException, ValueError) as e:
|
|
122
|
+
raise ConfigError(f"Cannot list gs://{bucket}: {e}") from e
|
|
123
|
+
if signer is not None and not isinstance(signer, service_account.Credentials):
|
|
124
|
+
try:
|
|
125
|
+
signer.sign_bytes(b"agent-env signing check")
|
|
126
|
+
except (GoogleAPIError, GoogleAuthError, requests.RequestException) as e:
|
|
127
|
+
quota = getattr(credentials, "quota_project_id", None)
|
|
128
|
+
billed = f" (requests are billed to quota project {quota!r})" if quota else ""
|
|
129
|
+
raise ConfigError(f"Cannot sign as {signer.signer_email}{billed}: {e}") from e
|
|
130
|
+
return cls(client, bucket, signer=signer)
|
|
131
|
+
|
|
132
|
+
def put(
|
|
133
|
+
self,
|
|
134
|
+
key: str,
|
|
135
|
+
data: bytes,
|
|
136
|
+
content_type: str = DEFAULT_CONTENT_TYPE,
|
|
137
|
+
allow_overwrite: bool = False,
|
|
138
|
+
) -> str:
|
|
139
|
+
url = self.object_url(key)
|
|
140
|
+
logger.info("Uploading %d bytes to %s", len(data), url)
|
|
141
|
+
blob = self._blob(url)
|
|
142
|
+
if allow_overwrite:
|
|
143
|
+
blob.upload_from_string(data, content_type=content_type)
|
|
144
|
+
else:
|
|
145
|
+
self._create(url, blob, lambda: blob.upload_from_string(
|
|
146
|
+
data, content_type=content_type, if_generation_match=0
|
|
147
|
+
))
|
|
148
|
+
return url
|
|
149
|
+
|
|
150
|
+
def put_file(self, key: str, file_path: str, content_type: str = DEFAULT_CONTENT_TYPE) -> str:
|
|
151
|
+
return self._put_file(self.object_url(key), file_path, content_type)
|
|
152
|
+
|
|
153
|
+
def put_file_at(self, object_url: str, file_path: str, content_type: str = DEFAULT_CONTENT_TYPE) -> str:
|
|
154
|
+
return self._put_file(object_url, file_path, content_type)
|
|
155
|
+
|
|
156
|
+
def get(self, object_url: str) -> bytes:
|
|
157
|
+
logger.info("Downloading from %s", object_url)
|
|
158
|
+
with _missing_as_not_found(object_url):
|
|
159
|
+
return self._blob(object_url).download_as_bytes(raw_download=True)
|
|
160
|
+
|
|
161
|
+
def download_to_file(self, object_url: str, dest_path: str) -> None:
|
|
162
|
+
"""Fetch ranges concurrently, each retried on its own, check the whole object's crc32c,
|
|
163
|
+
and rename into place: a failed download leaves neither a partial file nor a changed
|
|
164
|
+
destination, and a replaced destination keeps its permissions."""
|
|
165
|
+
logger.info("Streaming %s to %s", object_url, dest_path)
|
|
166
|
+
blob = self._blob(object_url)
|
|
167
|
+
dest = Path(dest_path)
|
|
168
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
partial = dest.with_name(f".{uuid.uuid4().hex[:16]}.part")
|
|
170
|
+
try:
|
|
171
|
+
with _missing_as_not_found(object_url):
|
|
172
|
+
transfer_manager.download_chunks_concurrently(
|
|
173
|
+
blob,
|
|
174
|
+
str(partial),
|
|
175
|
+
download_kwargs={"raw_download": True},
|
|
176
|
+
worker_type=transfer_manager.THREAD,
|
|
177
|
+
max_workers=_DOWNLOAD_WORKERS,
|
|
178
|
+
)
|
|
179
|
+
with contextlib.suppress(FileNotFoundError):
|
|
180
|
+
os.chmod(partial, stat.S_IMODE(dest.stat().st_mode))
|
|
181
|
+
os.replace(partial, dest)
|
|
182
|
+
finally:
|
|
183
|
+
partial.unlink(missing_ok=True)
|
|
184
|
+
|
|
185
|
+
def open(self, object_url: str) -> BinaryIO:
|
|
186
|
+
blob = self._existing_blob(object_url)
|
|
187
|
+
if blob is None:
|
|
188
|
+
raise ObjectNotFoundError(f"No object at {object_url}.")
|
|
189
|
+
return blob.open("rb", raw_download=True)
|
|
190
|
+
|
|
191
|
+
def get_object_metadata(self, key: str) -> ObjectMetadata | None:
|
|
192
|
+
return self.get_object_metadata_at(self.object_url(key))
|
|
193
|
+
|
|
194
|
+
def get_object_metadata_at(self, object_url: str) -> ObjectMetadata | None:
|
|
195
|
+
blob = self._existing_blob(object_url)
|
|
196
|
+
if blob is None:
|
|
197
|
+
return None
|
|
198
|
+
return ObjectMetadata(
|
|
199
|
+
content_type=blob.content_type,
|
|
200
|
+
size=blob.size,
|
|
201
|
+
last_modified=blob.updated,
|
|
202
|
+
content_encoding=blob.content_encoding,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def list(self, prefix: str) -> list[str]:
|
|
206
|
+
return self._list_keys(self._bucket, prefix)
|
|
207
|
+
|
|
208
|
+
def list_at(self, url_prefix: str) -> list[str]:
|
|
209
|
+
bucket, prefix = _split(url_prefix)
|
|
210
|
+
return [_url(bucket, key) for key in self._list_keys(bucket, prefix)]
|
|
211
|
+
|
|
212
|
+
def object_url(self, key: str) -> str:
|
|
213
|
+
return _url(self._bucket, key)
|
|
214
|
+
|
|
215
|
+
def get_object_key(self, object_url: str) -> str:
|
|
216
|
+
bucket, key = _split(object_url)
|
|
217
|
+
if bucket != self._bucket:
|
|
218
|
+
raise ValueError(f"{object_url!r} is not an object in gs://{self._bucket}.")
|
|
219
|
+
return key
|
|
220
|
+
|
|
221
|
+
def signed_get_url(self, object_url: str, expires_in: int = 3600) -> str | None:
|
|
222
|
+
blob = self._blob(object_url)
|
|
223
|
+
if not self._can_sign():
|
|
224
|
+
return None
|
|
225
|
+
return self._sign(blob, "GET", min(expires_in, self._max_signed_seconds))
|
|
226
|
+
|
|
227
|
+
def signed_put_url(self, object_url: str, expires_in: int = 3600) -> str | None:
|
|
228
|
+
blob = self._blob(object_url)
|
|
229
|
+
if not self._can_sign():
|
|
230
|
+
return None
|
|
231
|
+
return self._sign(blob, "PUT", min(expires_in, self._max_signed_seconds))
|
|
232
|
+
|
|
233
|
+
def issue_read_grant(self, object_url: str, *, expires_in: int = 3600) -> HttpGetGrant:
|
|
234
|
+
blob = self._blob(object_url)
|
|
235
|
+
expires_at = self._grant_expiry(expires_in)
|
|
236
|
+
return HttpGetGrant(kind="http-get", url=self._sign(blob, "GET", expires_in), expires_at=expires_at)
|
|
237
|
+
|
|
238
|
+
def issue_write_grant(
|
|
239
|
+
self,
|
|
240
|
+
object_url: str,
|
|
241
|
+
*,
|
|
242
|
+
media_type: str,
|
|
243
|
+
max_bytes: int,
|
|
244
|
+
expires_in: int = 3600,
|
|
245
|
+
) -> HttpPutGrant:
|
|
246
|
+
blob = self._blob(object_url)
|
|
247
|
+
expires_at = self._grant_expiry(expires_in)
|
|
248
|
+
bound = {"x-goog-content-length-range": f"0,{max_bytes}"}
|
|
249
|
+
return HttpPutGrant(
|
|
250
|
+
kind="http-put",
|
|
251
|
+
url=self._sign(blob, "PUT", expires_in, content_type=media_type, headers=dict(bound)),
|
|
252
|
+
expires_at=expires_at,
|
|
253
|
+
headers={"Content-Type": media_type, **bound},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def issue_upload_policy(
|
|
257
|
+
self, prefix_url: str, *, max_object_bytes: int, expires_in: int
|
|
258
|
+
) -> UploadPolicy:
|
|
259
|
+
"""A V4 POST policy for any key below ``prefix_url``. Its signature lasts all of
|
|
260
|
+
``expires_in`` up to the signer's cap whatever the caller's own token has left, so it is
|
|
261
|
+
refused only without a signer or past that cap."""
|
|
262
|
+
expires_at = self._grant_expiry(expires_in)
|
|
263
|
+
post = self._post_policy(prefix_url, expires_in, max_object_bytes)
|
|
264
|
+
return UploadPolicy(
|
|
265
|
+
write=HttpPostPolicyGrant(
|
|
266
|
+
kind="http-post-policy",
|
|
267
|
+
url=post["url"],
|
|
268
|
+
fields=post["fields"],
|
|
269
|
+
path_field="key",
|
|
270
|
+
file_field="file",
|
|
271
|
+
),
|
|
272
|
+
expires_at=expires_at,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def signed_post(
|
|
276
|
+
self, url_prefix: str, *, expires_in: int = 3600, max_bytes: int | None = None
|
|
277
|
+
) -> dict | None:
|
|
278
|
+
if not self._can_sign():
|
|
279
|
+
return None
|
|
280
|
+
return self._post_policy(url_prefix, min(expires_in, self._max_signed_seconds), max_bytes)
|
|
281
|
+
|
|
282
|
+
def _post_policy(self, url_prefix: str, expires_in: int, max_bytes: int | None) -> dict:
|
|
283
|
+
"""Built by hand: the client library's helper adds an exact-key condition, which would
|
|
284
|
+
confine the policy to one object. The prefix ends in ``/``, so that ``root`` does not
|
|
285
|
+
admit ``root-evil/``."""
|
|
286
|
+
bucket, prefix = _split(url_prefix.rstrip("/") + "/")
|
|
287
|
+
now = datetime.now(UTC).replace(microsecond=0)
|
|
288
|
+
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
|
|
289
|
+
credential = f"{self._signer.signer_email}/{now:%Y%m%d}/auto/storage/goog4_request"
|
|
290
|
+
conditions: list = [["starts-with", "$key", prefix], ["starts-with", "$Content-Type", ""]]
|
|
291
|
+
if max_bytes is not None:
|
|
292
|
+
conditions.append(["content-length-range", 0, max_bytes])
|
|
293
|
+
conditions += [
|
|
294
|
+
{"bucket": bucket},
|
|
295
|
+
{"x-goog-date": timestamp},
|
|
296
|
+
{"x-goog-credential": credential},
|
|
297
|
+
{"x-goog-algorithm": "GOOG4-RSA-SHA256"},
|
|
298
|
+
]
|
|
299
|
+
expiration = (now + timedelta(seconds=expires_in)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
300
|
+
document = json.dumps({"conditions": conditions, "expiration": expiration}, separators=(",", ":"))
|
|
301
|
+
policy = base64.b64encode(document.encode())
|
|
302
|
+
return {
|
|
303
|
+
"url": f"{self._client.api_endpoint}/{bucket}/",
|
|
304
|
+
"fields": {
|
|
305
|
+
"key": f"{prefix}${{filename}}",
|
|
306
|
+
"policy": policy.decode(),
|
|
307
|
+
"x-goog-algorithm": "GOOG4-RSA-SHA256",
|
|
308
|
+
"x-goog-credential": credential,
|
|
309
|
+
"x-goog-date": timestamp,
|
|
310
|
+
"x-goog-signature": self._signer.sign_bytes(policy).hex(),
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
def _put_file(self, object_url: str, file_path: str, content_type: str) -> str:
|
|
315
|
+
logger.info("Uploading %d bytes from %s to %s", os.path.getsize(file_path), file_path, object_url)
|
|
316
|
+
if os.path.getsize(file_path) > _MULTIPART_LIMIT and self._existing_blob(object_url) is not None:
|
|
317
|
+
raise ObjectAlreadyExistsError(f"Object already exists at {object_url}.")
|
|
318
|
+
blob = self._blob(object_url)
|
|
319
|
+
self._create(object_url, blob, lambda: blob.upload_from_filename(
|
|
320
|
+
file_path, content_type=content_type, if_generation_match=0
|
|
321
|
+
))
|
|
322
|
+
return object_url
|
|
323
|
+
|
|
324
|
+
def _create(self, object_url: str, blob, upload: Callable[[], None]) -> None:
|
|
325
|
+
write_id = uuid.uuid4().hex
|
|
326
|
+
blob.metadata = {_WRITE_ID: write_id}
|
|
327
|
+
try:
|
|
328
|
+
upload()
|
|
329
|
+
except PreconditionFailed as e:
|
|
330
|
+
existing = self._existing_blob(object_url)
|
|
331
|
+
if existing is None or (existing.metadata or {}).get(_WRITE_ID) != write_id:
|
|
332
|
+
raise ObjectAlreadyExistsError(f"Object already exists at {object_url}.") from e
|
|
333
|
+
|
|
334
|
+
def _blob(self, object_url: str):
|
|
335
|
+
bucket, key = _split_object(object_url)
|
|
336
|
+
return self._client.bucket(bucket).blob(key)
|
|
337
|
+
|
|
338
|
+
def _existing_blob(self, object_url: str):
|
|
339
|
+
bucket, key = _split_object(object_url)
|
|
340
|
+
return self._client.bucket(bucket).get_blob(key)
|
|
341
|
+
|
|
342
|
+
def _list_keys(self, bucket: str, prefix: str) -> list[str]:
|
|
343
|
+
blobs = self._client.list_blobs(bucket, prefix=prefix, fields="items(name),nextPageToken")
|
|
344
|
+
return [blob.name for blob in blobs if not blob.name.endswith("/")]
|
|
345
|
+
|
|
346
|
+
def _can_sign(self) -> bool:
|
|
347
|
+
if self._signer is None and not self._warned_unsigned:
|
|
348
|
+
logger.warning(
|
|
349
|
+
"GcsObjectStore has no signer, so it issues no signed URLs; set "
|
|
350
|
+
"signing_service_account, or run with credentials that can sign"
|
|
351
|
+
)
|
|
352
|
+
self._warned_unsigned = True
|
|
353
|
+
return self._signer is not None
|
|
354
|
+
|
|
355
|
+
def _grant_expiry(self, expires_in: int) -> datetime:
|
|
356
|
+
if self._signer is None:
|
|
357
|
+
raise GrantUnavailableError(
|
|
358
|
+
"GcsObjectStore has no signer; set signing_service_account to issue grants"
|
|
359
|
+
)
|
|
360
|
+
if expires_in > self._max_signed_seconds:
|
|
361
|
+
raise GrantUnavailableError(
|
|
362
|
+
f"GcsObjectStore's signer signs grants for at most {self._max_signed_seconds}s; "
|
|
363
|
+
f"{expires_in}s was requested"
|
|
364
|
+
)
|
|
365
|
+
# The signature's start is stamped to the second, after this.
|
|
366
|
+
return datetime.now(UTC).replace(microsecond=0) + timedelta(seconds=expires_in)
|
|
367
|
+
|
|
368
|
+
def _sign(
|
|
369
|
+
self,
|
|
370
|
+
blob,
|
|
371
|
+
method: str,
|
|
372
|
+
expires_in: int,
|
|
373
|
+
*,
|
|
374
|
+
content_type: str | None = None,
|
|
375
|
+
headers: dict[str, str] | None = None,
|
|
376
|
+
) -> str:
|
|
377
|
+
return blob.generate_signed_url(
|
|
378
|
+
version="v4",
|
|
379
|
+
expiration=timedelta(seconds=expires_in),
|
|
380
|
+
method=method,
|
|
381
|
+
content_type=content_type,
|
|
382
|
+
headers=headers,
|
|
383
|
+
credentials=self._signer,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _url(bucket: str, key: str) -> str:
|
|
388
|
+
return f"{_SCHEME}{bucket}/{key}"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _split(object_url: str) -> tuple[str, str]:
|
|
392
|
+
# Not urlparse: it would end the key at a "#" or "?", which object names may contain.
|
|
393
|
+
if not object_url.startswith(_SCHEME):
|
|
394
|
+
raise ValueError(f"{object_url!r} is not a gs:// object url.")
|
|
395
|
+
bucket, _, key = object_url[len(_SCHEME):].partition("/")
|
|
396
|
+
if not bucket:
|
|
397
|
+
raise ValueError(f"{object_url!r} names no bucket.")
|
|
398
|
+
return bucket, key
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _split_object(object_url: str) -> tuple[str, str]:
|
|
402
|
+
"""An object's url, which unlike a prefix's names a key: a signed url for an empty one
|
|
403
|
+
would list the bucket."""
|
|
404
|
+
bucket, key = _split(object_url)
|
|
405
|
+
if not key:
|
|
406
|
+
raise ValueError(f"{object_url!r} names no object.")
|
|
407
|
+
return bucket, key
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@contextlib.contextmanager
|
|
411
|
+
def _missing_as_not_found(object_url: str) -> Iterator[None]:
|
|
412
|
+
try:
|
|
413
|
+
yield
|
|
414
|
+
except NotFound as e:
|
|
415
|
+
raise ObjectNotFoundError(f"No object at {object_url}.") from e
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Local filesystem implementation of the ObjectStore interface (no infra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import shutil
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import BinaryIO
|
|
11
|
+
|
|
12
|
+
from agent_env.store.base import ObjectAlreadyExistsError, ObjectNotFoundError
|
|
13
|
+
from agent_env.store.local_state import ensure_state_dir
|
|
14
|
+
from agent_env.store.object_store.object_store import DEFAULT_CONTENT_TYPE, ObjectMetadata, ObjectStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalFilesystemObjectStore(ObjectStore):
|
|
18
|
+
"""ObjectStore backed by a local directory tree; content types are not persisted.
|
|
19
|
+
|
|
20
|
+
The root-level ``.gitignore`` key is reserved for the ignore rule the store writes."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, root: str) -> None:
|
|
23
|
+
self._root = Path(root)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def root(self) -> Path:
|
|
27
|
+
return self._root
|
|
28
|
+
|
|
29
|
+
def put(
|
|
30
|
+
self,
|
|
31
|
+
key: str,
|
|
32
|
+
data: bytes,
|
|
33
|
+
content_type: str = DEFAULT_CONTENT_TYPE,
|
|
34
|
+
allow_overwrite: bool = False,
|
|
35
|
+
) -> str:
|
|
36
|
+
path = self._resolve(key)
|
|
37
|
+
if path.exists() and not allow_overwrite:
|
|
38
|
+
raise ObjectAlreadyExistsError(f"Object already exists at {path}.")
|
|
39
|
+
ensure_state_dir(self._root)
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_bytes(data)
|
|
42
|
+
return self._to_url(path)
|
|
43
|
+
|
|
44
|
+
def put_file(self, key: str, file_path: str, content_type: str = DEFAULT_CONTENT_TYPE) -> str:
|
|
45
|
+
path = self._resolve(key)
|
|
46
|
+
if path.exists():
|
|
47
|
+
raise ObjectAlreadyExistsError(f"Object already exists at {path}.")
|
|
48
|
+
ensure_state_dir(self._root)
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
shutil.copyfile(file_path, path)
|
|
51
|
+
return self._to_url(path)
|
|
52
|
+
|
|
53
|
+
def put_file_at(self, object_url: str, file_path: str, content_type: str = DEFAULT_CONTENT_TYPE) -> str:
|
|
54
|
+
return self.put_file(self.get_object_key(object_url), file_path, content_type)
|
|
55
|
+
|
|
56
|
+
def get(self, object_url: str) -> bytes:
|
|
57
|
+
with _missing_as_not_found(object_url):
|
|
58
|
+
return Path(self._from_url(object_url)).read_bytes()
|
|
59
|
+
|
|
60
|
+
def download_to_file(self, object_url: str, dest_path: str) -> None:
|
|
61
|
+
source = self._from_url(object_url)
|
|
62
|
+
dest = Path(dest_path)
|
|
63
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
try:
|
|
65
|
+
shutil.copyfile(source, dest)
|
|
66
|
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as e:
|
|
67
|
+
if e.filename != source:
|
|
68
|
+
raise
|
|
69
|
+
raise ObjectNotFoundError(f"No object at {object_url}.") from e
|
|
70
|
+
|
|
71
|
+
def open(self, object_url: str) -> BinaryIO:
|
|
72
|
+
with _missing_as_not_found(object_url):
|
|
73
|
+
return Path(self._from_url(object_url)).open("rb")
|
|
74
|
+
|
|
75
|
+
def get_object_metadata(self, key: str) -> ObjectMetadata | None:
|
|
76
|
+
path = self._resolve(key)
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
return None
|
|
79
|
+
st = path.stat()
|
|
80
|
+
return ObjectMetadata(content_type=None, size=st.st_size, last_modified=datetime.fromtimestamp(st.st_mtime, tz=timezone.utc))
|
|
81
|
+
|
|
82
|
+
def get_object_metadata_at(self, object_url: str) -> ObjectMetadata | None:
|
|
83
|
+
return self.get_object_metadata(self.get_object_key(object_url))
|
|
84
|
+
|
|
85
|
+
def list(self, prefix: str) -> list[str]:
|
|
86
|
+
keys = []
|
|
87
|
+
for path in self._root.rglob("*"):
|
|
88
|
+
if not path.is_file() or path == self._root / ".gitignore":
|
|
89
|
+
continue
|
|
90
|
+
key = path.relative_to(self._root).as_posix()
|
|
91
|
+
if key.startswith(prefix):
|
|
92
|
+
keys.append(key)
|
|
93
|
+
return keys
|
|
94
|
+
|
|
95
|
+
def list_at(self, url_prefix: str) -> list[str]:
|
|
96
|
+
base = url_prefix if url_prefix.endswith("/") else url_prefix + "/"
|
|
97
|
+
dir_path = self._resolve(self.get_object_key(url_prefix))
|
|
98
|
+
if not dir_path.is_dir():
|
|
99
|
+
return []
|
|
100
|
+
return [
|
|
101
|
+
base + p.relative_to(dir_path).as_posix()
|
|
102
|
+
for p in sorted(dir_path.rglob("*"))
|
|
103
|
+
if p.is_file() and p != self._root.resolve() / ".gitignore"
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
def object_url(self, key: str) -> str:
|
|
107
|
+
return self._to_url(self._resolve(key))
|
|
108
|
+
|
|
109
|
+
def get_object_key(self, object_url: str) -> str:
|
|
110
|
+
if not object_url.startswith("file://"):
|
|
111
|
+
raise ValueError(f"{object_url!r} is not a file:// url of this store.")
|
|
112
|
+
root = self._root.resolve()
|
|
113
|
+
try:
|
|
114
|
+
path = Path(object_url[len("file://"):]).resolve()
|
|
115
|
+
except (OSError, RuntimeError) as e:
|
|
116
|
+
raise ValueError(f"{object_url!r} is not an object in {root}.") from e
|
|
117
|
+
if path != root and root not in path.parents:
|
|
118
|
+
raise ValueError(f"{object_url!r} is not an object in {root}.")
|
|
119
|
+
return path.relative_to(root).as_posix()
|
|
120
|
+
|
|
121
|
+
def _resolve(self, key: str) -> Path:
|
|
122
|
+
resolved = (self._root / key).resolve()
|
|
123
|
+
if not resolved.is_relative_to(self._root.resolve()):
|
|
124
|
+
raise ValueError(f"Key {key!r} escapes the store root.")
|
|
125
|
+
if resolved == self._root.resolve() / ".gitignore":
|
|
126
|
+
raise ValueError(f"Key {key!r} is reserved: the store keeps its .gitignore there.")
|
|
127
|
+
return resolved
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _to_url(path: Path) -> str:
|
|
131
|
+
return f"file://{path.resolve()}"
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _from_url(object_url: str) -> str:
|
|
135
|
+
return object_url[len("file://"):] if object_url.startswith("file://") else object_url
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@contextlib.contextmanager
|
|
139
|
+
def _missing_as_not_found(object_url: str) -> Iterator[None]:
|
|
140
|
+
try:
|
|
141
|
+
yield
|
|
142
|
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as e:
|
|
143
|
+
raise ObjectNotFoundError(f"No object at {object_url}.") from e
|