plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.7__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.
plato/cli/world.py ADDED
@@ -0,0 +1,250 @@
1
+ """World CLI commands for Plato."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from plato.cli.utils import console, require_api_key
12
+
13
+ world_app = typer.Typer(help="Manage and deploy worlds")
14
+
15
+
16
+ def _get_module_name(pkg_path: Path, package_name: str) -> str:
17
+ """Get the actual importable module name from pyproject.toml or package name."""
18
+ try:
19
+ import tomli
20
+
21
+ pyproject_file = pkg_path / "pyproject.toml"
22
+ if pyproject_file.exists():
23
+ with open(pyproject_file, "rb") as f:
24
+ pyproject = tomli.load(f)
25
+
26
+ # Check hatch config for packages
27
+ packages = (
28
+ pyproject.get("tool", {})
29
+ .get("hatch", {})
30
+ .get("build", {})
31
+ .get("targets", {})
32
+ .get("wheel", {})
33
+ .get("packages", [])
34
+ )
35
+ if packages:
36
+ # Extract module name from path like "src/code_world"
37
+ module_path = packages[0]
38
+ return module_path.split("/")[-1]
39
+
40
+ # Check setuptools config
41
+ packages = pyproject.get("tool", {}).get("setuptools", {}).get("packages", [])
42
+ if packages:
43
+ return packages[0]
44
+ except Exception:
45
+ pass
46
+
47
+ # Fall back to normalized package name
48
+ return package_name.replace("-", "_")
49
+
50
+
51
+ def _extract_schema_from_wheel(wheel_path: Path, module_name: str) -> dict | None:
52
+ """Extract schema.json from a built wheel file."""
53
+ try:
54
+ with zipfile.ZipFile(wheel_path, "r") as zf:
55
+ # Look for schema.json in the module directory
56
+ schema_path = f"{module_name}/schema.json"
57
+ if schema_path in zf.namelist():
58
+ with zf.open(schema_path) as f:
59
+ return json.load(f)
60
+ except Exception as e:
61
+ console.print(f"[yellow]Warning: Could not read schema from wheel: {e}[/yellow]")
62
+ return None
63
+
64
+
65
+ @world_app.command(name="publish")
66
+ def world_publish(
67
+ path: str = typer.Argument(".", help="Path to the world package directory (default: current directory)"),
68
+ dry_run: bool = typer.Option(False, "--dry-run", help="Build without uploading"),
69
+ ):
70
+ """Build and publish a world package to the Plato worlds repository.
71
+
72
+ Reads pyproject.toml for package info, builds with 'uv build', extracts the
73
+ config schema from schema.json in the wheel, and uploads to the Plato worlds
74
+ repository via uv publish.
75
+
76
+ The schema.json is automatically generated during build by a hatch build hook
77
+ that calls the world's get_schema() method.
78
+
79
+ Arguments:
80
+ path: Path to the world package directory containing pyproject.toml
81
+ (default: current directory)
82
+
83
+ Options:
84
+ --dry-run: Build the package and show schema without uploading
85
+
86
+ Requires PLATO_API_KEY environment variable for upload.
87
+ """
88
+ try:
89
+ import tomli
90
+ except ImportError:
91
+ console.print("[red]Error: tomli is not installed[/red]")
92
+ console.print("\n[yellow]Install with:[/yellow]")
93
+ console.print(" pip install tomli")
94
+ raise typer.Exit(1) from None
95
+
96
+ # Get API key (skip check for dry_run)
97
+ api_key = None
98
+ if not dry_run:
99
+ api_key = require_api_key()
100
+
101
+ # Get base URL (default to production)
102
+ base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
103
+ base_url = base_url.rstrip("/")
104
+ if base_url.endswith("/api"):
105
+ base_url = base_url[:-4]
106
+ api_url = f"{base_url}/api"
107
+
108
+ # Resolve package path
109
+ pkg_path = Path(path).resolve()
110
+ if not pkg_path.exists():
111
+ console.print(f"[red]Error: Path does not exist: {pkg_path}[/red]")
112
+ raise typer.Exit(1)
113
+
114
+ # Load pyproject.toml
115
+ pyproject_file = pkg_path / "pyproject.toml"
116
+ if not pyproject_file.exists():
117
+ console.print(f"[red]Error: No pyproject.toml found at {pkg_path}[/red]")
118
+ raise typer.Exit(1)
119
+
120
+ try:
121
+ with open(pyproject_file, "rb") as f:
122
+ pyproject = tomli.load(f)
123
+ except Exception as e:
124
+ console.print(f"[red]Error reading pyproject.toml: {e}[/red]")
125
+ raise typer.Exit(1) from e
126
+
127
+ # Extract package info
128
+ project = pyproject.get("project", {})
129
+ package_name = project.get("name")
130
+ version = project.get("version")
131
+
132
+ if not package_name:
133
+ console.print("[red]Error: No package name in pyproject.toml[/red]")
134
+ raise typer.Exit(1)
135
+ if not version:
136
+ console.print("[red]Error: No version in pyproject.toml[/red]")
137
+ raise typer.Exit(1)
138
+
139
+ console.print(f"[cyan]Package:[/cyan] {package_name}")
140
+ console.print(f"[cyan]Version:[/cyan] {version}")
141
+ console.print("[cyan]Repository:[/cyan] worlds")
142
+ console.print(f"[cyan]Path:[/cyan] {pkg_path}")
143
+ console.print()
144
+
145
+ # Build package (this will trigger the schema generation hook)
146
+ console.print("[cyan]Building package...[/cyan]")
147
+ try:
148
+ result = subprocess.run(
149
+ ["uv", "build"],
150
+ cwd=pkg_path,
151
+ capture_output=True,
152
+ text=True,
153
+ )
154
+ if result.returncode != 0:
155
+ console.print("[red]Build failed:[/red]")
156
+ console.print(result.stderr)
157
+ raise typer.Exit(1)
158
+ console.print("[green]Build successful[/green]")
159
+ except FileNotFoundError:
160
+ console.print("[red]Error: uv not found. Install with: pip install uv[/red]")
161
+ raise typer.Exit(1) from None
162
+
163
+ # Find built wheel
164
+ dist_dir = pkg_path / "dist"
165
+ if not dist_dir.exists():
166
+ console.print("[red]Error: dist/ directory not found after build[/red]")
167
+ raise typer.Exit(1)
168
+
169
+ normalized_name = package_name.replace("-", "_")
170
+ wheel_files = list(dist_dir.glob(f"{normalized_name}-{version}-*.whl"))
171
+
172
+ if not wheel_files:
173
+ wheel_files = list(dist_dir.glob("*.whl"))
174
+
175
+ if not wheel_files:
176
+ console.print(f"[red]Error: No wheel file found in {dist_dir}[/red]")
177
+ raise typer.Exit(1)
178
+
179
+ wheel_file = wheel_files[0]
180
+ console.print(f"[cyan]Built:[/cyan] {wheel_file.name}")
181
+
182
+ # Extract schema from the wheel
183
+ module_name = _get_module_name(pkg_path, package_name)
184
+ schema_data = _extract_schema_from_wheel(wheel_file, module_name)
185
+ if schema_data:
186
+ props = schema_data.get("properties", {})
187
+ agents = schema_data.get("agents", [])
188
+ secrets = schema_data.get("secrets", [])
189
+ console.print(
190
+ f"[green]Schema found:[/green] {len(props)} properties, {len(agents)} agents, {len(secrets)} secrets"
191
+ )
192
+ console.print(f"[dim] Properties: {', '.join(props.keys()) if props else 'none'}[/dim]")
193
+ if agents:
194
+ console.print(f"[dim] Agents: {', '.join(a.get('name', '?') for a in agents)}[/dim]")
195
+ else:
196
+ console.print("[red]Error: No schema.json found in wheel[/red]")
197
+ console.print("[dim] Add a hatch build hook to generate schema.json from get_schema()[/dim]")
198
+ console.print("[dim] See: https://docs.plato.so/worlds/publishing#schema-generation[/dim]")
199
+ raise SystemExit(1)
200
+
201
+ if dry_run:
202
+ console.print("\n[yellow]Dry run - skipping upload[/yellow]")
203
+ if schema_data:
204
+ console.print("\n[bold]Schema:[/bold]")
205
+ console.print(json.dumps(schema_data, indent=2))
206
+ return
207
+
208
+ # Upload wheel using uv publish
209
+ upload_url = f"{api_url}/v2/pypi/worlds/"
210
+ console.print(f"\n[cyan]Uploading to {upload_url}...[/cyan]")
211
+
212
+ # api_key is guaranteed to be set (checked earlier when not dry_run)
213
+ assert api_key is not None, "api_key must be set when not in dry_run mode"
214
+ try:
215
+ result = subprocess.run(
216
+ [
217
+ "uv",
218
+ "publish",
219
+ "--publish-url",
220
+ upload_url,
221
+ "--username",
222
+ "__token__",
223
+ "--password",
224
+ api_key,
225
+ str(wheel_file),
226
+ ],
227
+ capture_output=True,
228
+ text=True,
229
+ check=False,
230
+ )
231
+
232
+ if result.returncode == 0:
233
+ console.print("[green]Upload successful![/green]")
234
+ else:
235
+ console.print("[red]Upload failed:[/red]")
236
+ if result.stdout:
237
+ console.print(result.stdout)
238
+ if result.stderr:
239
+ console.print(result.stderr)
240
+ raise typer.Exit(1)
241
+
242
+ except FileNotFoundError:
243
+ console.print("[red]Error: uv not found[/red]")
244
+ raise typer.Exit(1) from None
245
+ except Exception as e:
246
+ console.print(f"[red]Upload error: {e}[/red]")
247
+ raise typer.Exit(1) from e
248
+
249
+ console.print("\n[bold]Install with:[/bold]")
250
+ console.print(f" uv add {package_name} --index-url {api_url}/v2/pypi/worlds/simple/")
plato/v1/cli/pm.py CHANGED
@@ -389,7 +389,7 @@ def review_base(
389
389
  browser = await playwright.chromium.launch(headless=False)
390
390
 
391
391
  # Install fake clock if requested
392
- fake_time = None
392
+ fake_time: datetime | None = None
393
393
  if clock:
394
394
  # Parse clock option: ISO format or offset like '-30d'
395
395
  if clock.startswith("-") and clock[-1] in "dhms":
@@ -404,10 +404,13 @@ def review_base(
404
404
  fake_time = datetime.now() - timedelta(minutes=amount)
405
405
  elif unit == "s":
406
406
  fake_time = datetime.now() - timedelta(seconds=amount)
407
+ else:
408
+ raise ValueError(f"Invalid clock offset unit: {unit}")
407
409
  else:
408
410
  # ISO format
409
411
  fake_time = datetime.fromisoformat(clock)
410
412
 
413
+ assert fake_time is not None, f"Failed to parse clock value: {clock}"
411
414
  console.print(f"[cyan]Setting fake browser time to:[/cyan] {fake_time.isoformat()}")
412
415
 
413
416
  if local:
plato/v2/__init__.py CHANGED
@@ -24,6 +24,7 @@ from plato.v2.sync.chronos import Chronos, ChronosSession
24
24
  from plato.v2.sync.client import Plato
25
25
  from plato.v2.sync.environment import Environment
26
26
  from plato.v2.sync.flow_executor import FlowExecutionError, FlowExecutor
27
+ from plato.v2.sync.sandbox import SandboxClient
27
28
  from plato.v2.sync.session import LoginResult, Session
28
29
 
29
30
  # Helper types
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "ArtifactInfoResponse",
47
48
  "Chronos",
48
49
  "ChronosSession",
50
+ "SandboxClient",
49
51
  # Async
50
52
  "AsyncPlato",
51
53
  "AsyncSession",
plato/v2/models.py CHANGED
@@ -63,3 +63,45 @@ class EnvOption(BaseModel):
63
63
  alias=alias,
64
64
  sim_config=SimConfig(cpus=cpus, memory=memory, disk=disk),
65
65
  )
66
+
67
+
68
+ class SandboxState(BaseModel):
69
+ """Schema for the sandbox state file (.plato/state.yaml).
70
+
71
+ All fields that can be persisted in the state file.
72
+ """
73
+
74
+ # Core identifiers
75
+ session_id: str
76
+ job_id: str
77
+ public_url: str | None = None
78
+
79
+ # Mode and service
80
+ mode: str # "blank", "config", "artifact", "simulator"
81
+
82
+ # Blank mode fields
83
+ dataset: str | None = None
84
+ cpus: int | None = None
85
+ memory: int | None = None
86
+ disk: int | None = None
87
+ app_port: int | None = None
88
+ messaging_port: int | None = None
89
+
90
+ # Config mode fields
91
+ plato_config_path: str | None = None
92
+
93
+ # Simulator/artifact mode fields
94
+ simulator_name: str | None = None
95
+ artifact_id: str | None = None
96
+ tag: str | None = None
97
+
98
+ # SSH configuration
99
+ ssh_config_path: str | None = None
100
+ ssh_host: str | None = None
101
+ ssh_command: str | None = None # Full SSH command for copy-paste
102
+
103
+ # Process management
104
+ heartbeat_pid: int | None = None
105
+
106
+ # Network
107
+ network_connected: bool = False
plato/v2/sync/__init__.py CHANGED
@@ -4,6 +4,10 @@ from plato._generated.models import ArtifactInfoResponse
4
4
  from plato.v2.sync.chronos import Chronos, ChronosSession
5
5
  from plato.v2.sync.client import Plato
6
6
  from plato.v2.sync.environment import Environment
7
+ from plato.v2.sync.sandbox import (
8
+ SandboxClient,
9
+ SandboxState,
10
+ )
7
11
  from plato.v2.sync.session import Session
8
12
 
9
13
  __all__ = [
@@ -13,4 +17,6 @@ __all__ = [
13
17
  "Environment",
14
18
  "Chronos",
15
19
  "ChronosSession",
20
+ "SandboxClient",
21
+ "SandboxState",
16
22
  ]
plato/v2/sync/client.py CHANGED
@@ -126,6 +126,7 @@ class Plato:
126
126
  api_key: str | None = None,
127
127
  base_url: str | None = None,
128
128
  timeout: float = DEFAULT_TIMEOUT,
129
+ http_client: httpx.Client | None = None,
129
130
  ):
130
131
  self.api_key = api_key or os.environ.get("PLATO_API_KEY")
131
132
  if not self.api_key:
@@ -138,7 +139,8 @@ class Plato:
138
139
  self.base_url = url.rstrip("/")
139
140
  self.timeout = timeout
140
141
 
141
- self._http = httpx.Client(
142
+ self._owns_http = http_client is None
143
+ self._http = http_client or httpx.Client(
142
144
  base_url=self.base_url,
143
145
  timeout=httpx.Timeout(timeout),
144
146
  )
@@ -147,5 +149,6 @@ class Plato:
147
149
  self.artifacts = ArtifactManager(self._http, self.api_key)
148
150
 
149
151
  def close(self) -> None:
150
- """Close the underlying HTTP client."""
151
- self._http.close()
152
+ """Close the underlying HTTP client (if we own it)."""
153
+ if self._owns_http:
154
+ self._http.close()