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,295 @@
|
|
|
1
|
+
"""Artifact store for versioned documents and binary objects via the pluggable stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import TYPE_CHECKING, Optional, Self, TypeVar
|
|
10
|
+
|
|
11
|
+
from agent_env.config import get_config
|
|
12
|
+
from agent_env.plugins import _registration
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
from agent_env.artifact.registry import canonical_type, equivalent_types, get_artifact_registry
|
|
16
|
+
from agent_env.store.base import NotFoundError
|
|
17
|
+
from agent_env.store.document_store import (
|
|
18
|
+
DuplicateKeyError,
|
|
19
|
+
Filter,
|
|
20
|
+
In,
|
|
21
|
+
UpdateSpec,
|
|
22
|
+
VersionedEntityStore,
|
|
23
|
+
VersionedEntityStoreCache,
|
|
24
|
+
)
|
|
25
|
+
from agent_env.store.ids import key_segment
|
|
26
|
+
from agent_env.store.query import QueryBuilder, to_document_query
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from agent_env.artifact.artifact import Artifact
|
|
30
|
+
|
|
31
|
+
_A = TypeVar("_A", bound="Artifact")
|
|
32
|
+
|
|
33
|
+
ARTIFACTS_COLLECTION = "artifacts"
|
|
34
|
+
|
|
35
|
+
# Retry cap for the version-allocation race in put_document.
|
|
36
|
+
_MAX_VERSION_RETRIES = 3
|
|
37
|
+
|
|
38
|
+
_write_locks: dict[str, threading.Lock] = {}
|
|
39
|
+
_write_locks_guard = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def artifact_write_lock(artifact_id: str) -> threading.Lock:
|
|
43
|
+
"""A lock for writing ``artifact_id`` from this process. A write that allocates a version, puts
|
|
44
|
+
objects at paths named by it and then records the document takes the same version as any
|
|
45
|
+
other write of that id running alongside it; hold this around the whole write to take turns."""
|
|
46
|
+
with _write_locks_guard:
|
|
47
|
+
return _write_locks.setdefault(artifact_id, threading.Lock())
|
|
48
|
+
|
|
49
|
+
# Fields that address the document rather than describe it, so `set_field` must not touch them.
|
|
50
|
+
_IDENTITY_FIELDS = frozenset({"id", "version", "type"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Hex digits of the attempt part of an object prefix: enough that two attempts never meet.
|
|
54
|
+
_ATTEMPT_ID_LENGTH = 8
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ArtifactQuery(QueryBuilder["Artifact"]):
|
|
58
|
+
"""Chainable query builder for artifacts.
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
artifact = Artifact.query().id("my_artifact").latest().first()
|
|
62
|
+
artifacts = Artifact.query().type("docker_image").execute()
|
|
63
|
+
artifacts = Artifact.query().version_gte(2).execute()
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, store: Optional["ArtifactStore"] = None) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self._store = store
|
|
69
|
+
|
|
70
|
+
def _clone(self) -> Self:
|
|
71
|
+
clone = ArtifactQuery(self._store)
|
|
72
|
+
clone._filters = self._filters.copy()
|
|
73
|
+
clone._sort_field = self._sort_field
|
|
74
|
+
clone._sort_desc = self._sort_desc
|
|
75
|
+
clone._limit_value = self._limit_value
|
|
76
|
+
clone._offset_value = self._offset_value
|
|
77
|
+
return clone
|
|
78
|
+
|
|
79
|
+
def type(self, artifact_type: str) -> Self:
|
|
80
|
+
spellings = equivalent_types(artifact_type)
|
|
81
|
+
if len(spellings) == 1:
|
|
82
|
+
return self._add_filter("type", artifact_type)
|
|
83
|
+
return self._add_filter("type_in", spellings)
|
|
84
|
+
|
|
85
|
+
def execute(self) -> list["Artifact"]:
|
|
86
|
+
if self._store is None:
|
|
87
|
+
self._store = get_artifact_store()
|
|
88
|
+
return self._store.execute_query(self)
|
|
89
|
+
|
|
90
|
+
def _execute_count(self) -> int:
|
|
91
|
+
if self._store is None:
|
|
92
|
+
self._store = get_artifact_store()
|
|
93
|
+
return self._store.execute_count(self)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ArtifactStore:
|
|
97
|
+
"""Store for artifact persistence in MongoDB and S3."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._versioned_cache: VersionedEntityStoreCache["Artifact"] = VersionedEntityStoreCache(
|
|
101
|
+
ARTIFACTS_COLLECTION, None, self._deserialize, secondary_indexes=[["type"]],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def _doc_store(self):
|
|
106
|
+
return get_config().get_document_store()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def _versioned(self) -> VersionedEntityStore["Artifact"]:
|
|
110
|
+
return self._versioned_cache.for_store(self._doc_store)
|
|
111
|
+
|
|
112
|
+
def get(self, id: str, version: Optional[int] = None) -> "Artifact":
|
|
113
|
+
"""Get an artifact by id. Returns latest version if version not specified."""
|
|
114
|
+
artifact = self._versioned.get(id, version)
|
|
115
|
+
if artifact is None:
|
|
116
|
+
version_str = f" version={version}" if version is not None else ""
|
|
117
|
+
raise NotFoundError(f"Artifact {id}{version_str} not found")
|
|
118
|
+
return artifact
|
|
119
|
+
|
|
120
|
+
def next_version(self, id: str) -> int:
|
|
121
|
+
# Every put asks for its version before writing an object or pushing an image, so an id
|
|
122
|
+
# the store refuses, or a bare one while an @local task runs, is refused here rather than
|
|
123
|
+
# after those side effects, in put_document.
|
|
124
|
+
self._doc_store.check_id(id)
|
|
125
|
+
get_config().check_local_run_write(id)
|
|
126
|
+
return self._versioned.next_version(id)
|
|
127
|
+
|
|
128
|
+
def attempt_prefix(self, artifact_type: str, id: str) -> str:
|
|
129
|
+
"""An object-store prefix of its own for one write of ``id``'s next version. Objects are written
|
|
130
|
+
once, so a write that fails partway must not leave its objects where the next attempt writes."""
|
|
131
|
+
attempt = f"{self.next_version(id)}-{uuid.uuid4().hex[:_ATTEMPT_ID_LENGTH]}"
|
|
132
|
+
key = f"{get_config().get_artifact_key_prefix()}artifacts/{artifact_type}/{key_segment(id)}/{attempt}"
|
|
133
|
+
return get_config().get_object_store_for(id).object_url(key).rstrip("/") + "/"
|
|
134
|
+
|
|
135
|
+
def put_object(
|
|
136
|
+
self,
|
|
137
|
+
artifact_type: str,
|
|
138
|
+
id: str,
|
|
139
|
+
version: int,
|
|
140
|
+
object_name: str,
|
|
141
|
+
data: bytes,
|
|
142
|
+
content_type: str = "application/json",
|
|
143
|
+
allow_overwrite: bool = False,
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Upload an artifact object; return its object URL.
|
|
146
|
+
|
|
147
|
+
Write-once by default (raises ObjectAlreadyExistsError on collision);
|
|
148
|
+
set allow_overwrite=True for mutable data (e.g. rolling run snapshots).
|
|
149
|
+
"""
|
|
150
|
+
key = self._object_key(artifact_type, id, version, object_name)
|
|
151
|
+
return get_config().get_object_store_for(id).put(key, data, content_type, allow_overwrite)
|
|
152
|
+
|
|
153
|
+
def put_object_file(
|
|
154
|
+
self,
|
|
155
|
+
artifact_type: str,
|
|
156
|
+
id: str,
|
|
157
|
+
version: int,
|
|
158
|
+
object_name: str,
|
|
159
|
+
file_path: str,
|
|
160
|
+
content_type: str = "application/octet-stream",
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Write-once upload of a local file (multipart for large files); return its object URL."""
|
|
163
|
+
key = self._object_key(artifact_type, id, version, object_name)
|
|
164
|
+
return get_config().get_object_store_for(id).put_file(key, file_path, content_type)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _object_key(artifact_type: str, id: str, version: int, object_name: str) -> str:
|
|
168
|
+
return f"{get_config().get_artifact_key_prefix()}artifacts/{artifact_type}/{key_segment(id)}/{version}/{object_name}"
|
|
169
|
+
|
|
170
|
+
def get_object(self, object_url: str) -> bytes:
|
|
171
|
+
"""Download an artifact object by its object_url."""
|
|
172
|
+
return get_config().get_object_store_at(object_url).get(object_url)
|
|
173
|
+
|
|
174
|
+
def put_document(self, artifact: _A) -> _A:
|
|
175
|
+
"""Insert an artifact document, retrying the version-allocation race.
|
|
176
|
+
|
|
177
|
+
Concurrent writers to one id can allocate the same next_version; the
|
|
178
|
+
unique index rejects the losers, so we re-allocate and retry rather
|
|
179
|
+
than fail the caller (which drops a pass@k rollout).
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
self._doc_store.check_id(artifact.id)
|
|
183
|
+
if artifact.version < 0:
|
|
184
|
+
raise ValueError(f"Artifact version must be non-negative, got {artifact.version}")
|
|
185
|
+
|
|
186
|
+
store = self._doc_store
|
|
187
|
+
versioned = self._versioned_cache.for_store(store)
|
|
188
|
+
version = artifact.version if artifact.version > 0 else versioned.next_version(artifact.id)
|
|
189
|
+
doc = artifact.model_dump(by_alias=True)
|
|
190
|
+
doc["created_at_utc"] = datetime.now(timezone.utc)
|
|
191
|
+
last_exc: Optional[DuplicateKeyError] = None
|
|
192
|
+
for attempt in range(_MAX_VERSION_RETRIES):
|
|
193
|
+
doc["version"] = version
|
|
194
|
+
try:
|
|
195
|
+
store.insert(ARTIFACTS_COLLECTION, dict(doc))
|
|
196
|
+
return artifact.model_copy(update={"version": version})
|
|
197
|
+
except DuplicateKeyError as e:
|
|
198
|
+
last_exc = e
|
|
199
|
+
if attempt == _MAX_VERSION_RETRIES - 1:
|
|
200
|
+
break
|
|
201
|
+
version = versioned.next_version(artifact.id)
|
|
202
|
+
logger.warning(
|
|
203
|
+
"artifact %s version taken (concurrent write); retrying at v%s (%s/%s)",
|
|
204
|
+
artifact.id, version, attempt + 2, _MAX_VERSION_RETRIES,
|
|
205
|
+
)
|
|
206
|
+
assert last_exc is not None
|
|
207
|
+
raise last_exc
|
|
208
|
+
|
|
209
|
+
def latest_by_type(
|
|
210
|
+
self, artifact_type: str, ids: Optional[list[str]] = None, limit: int = 50
|
|
211
|
+
) -> list["Artifact"]:
|
|
212
|
+
"""Return the latest version of each artifact of a type, newest version first.
|
|
213
|
+
|
|
214
|
+
Optionally restricted to ``ids``. Groups all matching versions in Python
|
|
215
|
+
(the DocumentStore has no aggregation), so this is a browse/CLI helper,
|
|
216
|
+
not a hot path.
|
|
217
|
+
"""
|
|
218
|
+
spellings = equivalent_types(artifact_type)
|
|
219
|
+
filt = (
|
|
220
|
+
Filter.of(type=artifact_type) if len(spellings) == 1
|
|
221
|
+
else Filter({}).where("type", In(spellings))
|
|
222
|
+
)
|
|
223
|
+
if ids is not None:
|
|
224
|
+
filt = filt.where("id", In(ids))
|
|
225
|
+
latest: dict[str, dict] = {}
|
|
226
|
+
for doc in self._doc_store.query(ARTIFACTS_COLLECTION, filt):
|
|
227
|
+
current = latest.get(doc["id"])
|
|
228
|
+
if current is None or doc["version"] > current["version"]:
|
|
229
|
+
latest[doc["id"]] = doc
|
|
230
|
+
ordered = sorted(latest.values(), key=lambda d: (-d["version"], d["id"]))[:limit]
|
|
231
|
+
return [self._deserialize(d) for d in ordered]
|
|
232
|
+
|
|
233
|
+
def set_field(self, artifact_type: str, id: str, version: int, field: str, value) -> None:
|
|
234
|
+
"""Persist a single mutable side-channel field on an already-stored artifact doc.
|
|
235
|
+
|
|
236
|
+
Identity is not a side channel: `id`, `version` and `type` address the document being
|
|
237
|
+
updated, so setting one would rename a stored artifact out from under the filter that
|
|
238
|
+
found it — and would reach the store without passing the id reservation.
|
|
239
|
+
"""
|
|
240
|
+
if field in _IDENTITY_FIELDS:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"set_field cannot change {field!r}: it identifies the document being updated, "
|
|
243
|
+
f"not a side-channel field (identity: {', '.join(sorted(_IDENTITY_FIELDS))})"
|
|
244
|
+
)
|
|
245
|
+
self._doc_store.update(
|
|
246
|
+
ARTIFACTS_COLLECTION,
|
|
247
|
+
Filter.of(id=id, version=version, type=artifact_type),
|
|
248
|
+
UpdateSpec(set={field: value}),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _deserialize(self, doc: dict) -> "Artifact":
|
|
252
|
+
# Remove MongoDB-specific fields not in Pydantic model
|
|
253
|
+
doc.pop("_id", None)
|
|
254
|
+
doc.pop("created_at_utc", None)
|
|
255
|
+
|
|
256
|
+
artifact_type = canonical_type(doc["type"])
|
|
257
|
+
cls = get_artifact_registry().get(artifact_type)
|
|
258
|
+
if cls is None:
|
|
259
|
+
# A plugin that claims the name says why it is missing; the config remedies are for a name none claims.
|
|
260
|
+
if note := _registration.failure_note(_registration.ARTIFACTS, artifact_type):
|
|
261
|
+
raise ValueError(f"Unknown artifact type {doc['type']!r}{note}")
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"Unknown artifact type {doc['type']!r}; register it under [artifacts].impls "
|
|
264
|
+
f"in .agentenv/config.toml, or map a renamed type to its current spelling "
|
|
265
|
+
f"under [artifacts].type_aliases"
|
|
266
|
+
)
|
|
267
|
+
return cls.model_validate(doc)
|
|
268
|
+
|
|
269
|
+
def execute_query(self, query: ArtifactQuery) -> list["Artifact"]:
|
|
270
|
+
filt, sort = to_document_query(query)
|
|
271
|
+
return self._versioned.query(filt, sort, query._limit_value, query._offset_value)
|
|
272
|
+
|
|
273
|
+
def execute_count(self, query: ArtifactQuery) -> int:
|
|
274
|
+
filt, _ = to_document_query(query)
|
|
275
|
+
return self._versioned.count(filt)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_artifact_store: Optional[ArtifactStore] = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_artifact_store() -> ArtifactStore:
|
|
282
|
+
global _artifact_store
|
|
283
|
+
if _artifact_store is None:
|
|
284
|
+
_artifact_store = ArtifactStore()
|
|
285
|
+
return _artifact_store
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def set_artifact_store(store: ArtifactStore) -> None:
|
|
289
|
+
global _artifact_store
|
|
290
|
+
_artifact_store = store
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def reset_artifact_store() -> None:
|
|
294
|
+
global _artifact_store
|
|
295
|
+
_artifact_store = None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Universe — abstract base for artifacts that bundle other artifacts together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from agent_env.artifact.artifact import Artifact
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Universe(Artifact):
|
|
11
|
+
"""Abstract base for 'universe' artifacts that bundle other artifacts.
|
|
12
|
+
|
|
13
|
+
Cannot be instantiated directly — use a concrete subclass such as
|
|
14
|
+
``EnvironmentUniverseArtifact`` or ``FileArtifactUniverse``. A universe is
|
|
15
|
+
itself stored in the artifacts collection (so it benefits from the
|
|
16
|
+
existing versioning / query / registry machinery); its payload is a set
|
|
17
|
+
of references (IDs) to other artifacts.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# Marker flipped to False on any subclass that declares a concrete
|
|
21
|
+
# ``type`` literal default (e.g. ``type: Literal["environment_universe"] = "environment_universe"``).
|
|
22
|
+
__is_abstract__: ClassVar[bool] = True
|
|
23
|
+
|
|
24
|
+
def __init_subclass__(cls, **kwargs):
|
|
25
|
+
super().__init_subclass__(**kwargs)
|
|
26
|
+
# A subclass is concrete if its ``type`` field has a non-empty default.
|
|
27
|
+
type_field = cls.model_fields.get("type")
|
|
28
|
+
if type_field is not None and type_field.default not in (None, ""):
|
|
29
|
+
cls.__is_abstract__ = False
|
|
30
|
+
|
|
31
|
+
def __init__(self, **data):
|
|
32
|
+
if self.__class__.__is_abstract__:
|
|
33
|
+
raise TypeError(
|
|
34
|
+
f"{self.__class__.__name__} is abstract and cannot be instantiated; "
|
|
35
|
+
"use a concrete Universe subclass (e.g. EnvironmentUniverseArtifact, "
|
|
36
|
+
"FileArtifactUniverse)."
|
|
37
|
+
)
|
|
38
|
+
super().__init__(**data)
|
agent_env/attribution.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Cost-attribution dimensions carried from the caller down to a sandbox provider.
|
|
2
|
+
|
|
3
|
+
Attribution is an open ``dict[str, str]``. Core threads it without reading it; each provider
|
|
4
|
+
picks out the keys its billing backend understands and ignores the rest, so a deployment can
|
|
5
|
+
attribute on whatever dimensions and terminology it uses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
Attribution = dict[str, str]
|
|
11
|
+
|
|
12
|
+
# The one key in a step's open ``metadata`` map that is forwarded to compute as attribution.
|
|
13
|
+
ATTRIBUTION_KEY = "attribution"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Names the pipeline step that deployed a sandbox, as ``<task_id>_<step_id>``, and the run
|
|
17
|
+
# (task instance) it belongs to, so sandbox cost can be broken down per run and per step.
|
|
18
|
+
# Modal stamps both on each sandbox as tags.
|
|
19
|
+
PIPELINE_STEP_KEY = "pipeline_step"
|
|
20
|
+
RUN_ID_KEY = "run_id"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def attribution_of(step) -> Attribution:
|
|
24
|
+
"""A step's attribution: a copy of ``metadata["attribution"]`` (``{}`` when unset)."""
|
|
25
|
+
return dict((getattr(step, "metadata", None) or {}).get(ATTRIBUTION_KEY) or {})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def deploy_attribution(step, context) -> Attribution:
|
|
29
|
+
"""``attribution_of(step)`` plus ``pipeline_step`` and ``run_id`` when known.
|
|
30
|
+
|
|
31
|
+
``pipeline_step`` needs ``context.metadata["task_id"]`` (set by the hub worker and the CLI);
|
|
32
|
+
``run_id`` is ``context.instance_id``. Task-authored values win.
|
|
33
|
+
"""
|
|
34
|
+
attribution = attribution_of(step)
|
|
35
|
+
task_id = (getattr(context, "metadata", None) or {}).get("task_id")
|
|
36
|
+
if task_id and PIPELINE_STEP_KEY not in attribution:
|
|
37
|
+
attribution[PIPELINE_STEP_KEY] = f"{task_id}_{step.id}"
|
|
38
|
+
instance_id = getattr(context, "instance_id", None)
|
|
39
|
+
if instance_id and RUN_ID_KEY not in attribution:
|
|
40
|
+
attribution[RUN_ID_KEY] = instance_id
|
|
41
|
+
return attribution
|
agent_env/bundle/_fs.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Filesystem plumbing for the bundle parser: a path spelled as the filesystem stores it, the user's
|
|
2
|
+
home, a regular file read without surprises, names compared without case, and paths rendered for
|
|
3
|
+
messages."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import errno
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import unicodedata
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import fcntl
|
|
16
|
+
import pwd
|
|
17
|
+
except ImportError: # Windows, which agent-env does not target.
|
|
18
|
+
fcntl = pwd = None
|
|
19
|
+
|
|
20
|
+
# Config files are small; the cap keeps a stray large file, or a link to one, from being read whole.
|
|
21
|
+
MAX_FILE_BYTES = 8 << 20
|
|
22
|
+
_UNSHOWN = frozenset({"Cc", "Cf", "Zl", "Zp"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Unreadable(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def on_disk(path: Path) -> Path:
|
|
30
|
+
"""``path`` spelled as the filesystem stores it; a strict resolve keeps the typed case on a
|
|
31
|
+
case-insensitive filesystem. macOS answers with ``F_GETPATH``. Elsewhere each component's
|
|
32
|
+
spelling comes from its parent's listing, where the parent can be listed."""
|
|
33
|
+
if sys.platform == "darwin" and fcntl is not None:
|
|
34
|
+
try:
|
|
35
|
+
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
36
|
+
try:
|
|
37
|
+
spelled = fcntl.fcntl(fd, fcntl.F_GETPATH, bytes(1024))
|
|
38
|
+
finally:
|
|
39
|
+
os.close(fd)
|
|
40
|
+
except OSError:
|
|
41
|
+
return path
|
|
42
|
+
return Path(os.fsdecode(spelled.split(b"\0", 1)[0]))
|
|
43
|
+
spelled_path = Path(path.anchor)
|
|
44
|
+
for part in path.parts[1:]:
|
|
45
|
+
try:
|
|
46
|
+
names = os.listdir(spelled_path)
|
|
47
|
+
except OSError:
|
|
48
|
+
names = []
|
|
49
|
+
if part not in names:
|
|
50
|
+
matches = [name for name in names if fold(name) == fold(part)]
|
|
51
|
+
part = matches[0] if len(matches) == 1 else part
|
|
52
|
+
spelled_path /= part
|
|
53
|
+
return spelled_path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def home() -> Path | None:
|
|
57
|
+
"""The user's home: ``$HOME`` if it is an absolute folder other than ``/``, else the passwd
|
|
58
|
+
entry's, else none."""
|
|
59
|
+
candidates = [os.environ.get("HOME", "")]
|
|
60
|
+
if pwd is not None:
|
|
61
|
+
try:
|
|
62
|
+
candidates.append(pwd.getpwuid(os.getuid()).pw_dir)
|
|
63
|
+
except KeyError:
|
|
64
|
+
pass
|
|
65
|
+
for candidate in candidates:
|
|
66
|
+
if not candidate.startswith("/"):
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
resolved = Path(candidate).resolve(strict=True)
|
|
70
|
+
except (OSError, RuntimeError, ValueError):
|
|
71
|
+
continue
|
|
72
|
+
if resolved.is_dir() and resolved != Path(resolved.anchor):
|
|
73
|
+
return on_disk(resolved)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_regular(path: Path) -> bytes:
|
|
78
|
+
"""A regular file's bytes, opened without blocking so a FIFO can't hang the parse. Raises
|
|
79
|
+
Unreadable with the reason otherwise."""
|
|
80
|
+
try:
|
|
81
|
+
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
82
|
+
except OSError as e:
|
|
83
|
+
raise Unreadable(os_reason(e)) from None
|
|
84
|
+
try:
|
|
85
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
86
|
+
raise Unreadable("not a regular file")
|
|
87
|
+
with os.fdopen(fd, "rb", closefd=False) as f:
|
|
88
|
+
data = f.read(MAX_FILE_BYTES + 1)
|
|
89
|
+
except OSError as e:
|
|
90
|
+
raise Unreadable(os_reason(e)) from None
|
|
91
|
+
finally:
|
|
92
|
+
os.close(fd)
|
|
93
|
+
if len(data) > MAX_FILE_BYTES:
|
|
94
|
+
raise Unreadable(f"larger than the {MAX_FILE_BYTES >> 20} MiB limit for a bundle's config files")
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fold(name: str) -> str:
|
|
99
|
+
"""The canonical caseless form: casefold output isn't always NFC, so normalize both sides."""
|
|
100
|
+
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def os_reason(e: OSError) -> str:
|
|
104
|
+
if e.errno == errno.ELOOP:
|
|
105
|
+
return "a symlink loop, or too many links"
|
|
106
|
+
if isinstance(e, PermissionError):
|
|
107
|
+
return "permission denied"
|
|
108
|
+
return e.strerror or str(e)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def with_article(noun: str) -> str:
|
|
112
|
+
return f"{'an' if noun[:1] in 'aeiou' else 'a'} {noun}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def relative(root: Path, path: Path) -> str:
|
|
116
|
+
"""``path`` relative to the bundle's ``root``, for a message."""
|
|
117
|
+
return show(os.path.relpath(path, root))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def show(value) -> str:
|
|
121
|
+
"""Text for a message, with undecodable bytes, lone surrogates and control, format and
|
|
122
|
+
line-separator characters escaped, so a problem stays one printable line."""
|
|
123
|
+
text = str(value)
|
|
124
|
+
try:
|
|
125
|
+
text = os.fsencode(text).decode("utf-8", "backslashreplace")
|
|
126
|
+
except UnicodeEncodeError:
|
|
127
|
+
text = text.encode("utf-8", "backslashreplace").decode("utf-8")
|
|
128
|
+
return "".join(ascii(c)[1:-1] if unicodedata.category(c) in _UNSHOWN else c for c in text)
|