fleet-python 0.2.71__tar.gz → 0.2.72__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fleet_python-0.2.71/fleet_python.egg-info → fleet_python-0.2.72}/PKG-INFO +1 -1
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/client.py +163 -6
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/instance/client.py +19 -4
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/sqlite.py +150 -1
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/tasks.py +5 -2
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/client.py +183 -15
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/client.py +20 -5
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/sqlite.py +143 -1
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/tasks.py +5 -2
- {fleet_python-0.2.71 → fleet_python-0.2.72/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/SOURCES.txt +4 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/pyproject.toml +2 -1
- fleet_python-0.2.72/tests/test_app_method.py +85 -0
- fleet_python-0.2.72/tests/test_instance_dispatch.py +607 -0
- fleet_python-0.2.72/tests/test_sqlite_resource_dual_mode.py +263 -0
- fleet_python-0.2.72/tests/test_sqlite_shared_memory_behavior.py +117 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/LICENSE +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/README.md +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/diff_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_account.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_client.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_sync.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_task.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/openai_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/quickstart.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/base.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/base.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/config.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/env/client.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/global_client.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/models.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/types.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/db.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/scripts/unasync.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/setup.cfg +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/tests/__init__.py +0 -0
- {fleet_python-0.2.71 → fleet_python-0.2.72}/tests/test_verifier_from_string.py +0 -0
|
@@ -21,7 +21,7 @@ import httpx
|
|
|
21
21
|
import json
|
|
22
22
|
import logging
|
|
23
23
|
import os
|
|
24
|
-
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
|
24
|
+
from typing import List, Optional, Dict, Any, TYPE_CHECKING, Union
|
|
25
25
|
|
|
26
26
|
from .base import EnvironmentBase, AsyncWrapper
|
|
27
27
|
from ..models import (
|
|
@@ -49,6 +49,11 @@ from .instance import (
|
|
|
49
49
|
ResetResponse,
|
|
50
50
|
ExecuteFunctionResponse,
|
|
51
51
|
)
|
|
52
|
+
from ..instance.models import (
|
|
53
|
+
Resource as ResourceModel,
|
|
54
|
+
ResourceType,
|
|
55
|
+
ResourceMode,
|
|
56
|
+
)
|
|
52
57
|
from ..config import (
|
|
53
58
|
DEFAULT_MAX_RETRIES,
|
|
54
59
|
DEFAULT_TIMEOUT,
|
|
@@ -310,11 +315,163 @@ class AsyncFleet:
|
|
|
310
315
|
for instance_data in response.json()
|
|
311
316
|
]
|
|
312
317
|
|
|
313
|
-
async def instance(self, instance_id: str) -> AsyncEnv:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
async def instance(self, instance_id: Union[str, Dict[str, str]]) -> AsyncEnv:
|
|
319
|
+
"""Create or connect to an environment instance.
|
|
320
|
+
|
|
321
|
+
Supports three modes based on input type:
|
|
322
|
+
1. dict: Local filesystem mode - {"current": "./data.db", "seed": "./seed.db"}
|
|
323
|
+
2. str starting with http:// or https://: Localhost/URL mode
|
|
324
|
+
3. str (other): Remote cloud instance mode
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
instance_id: Instance identifier (str), URL (str starting with http://),
|
|
328
|
+
or local db mapping (dict)
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
AsyncEnv: Environment instance
|
|
332
|
+
"""
|
|
333
|
+
# Local filesystem mode - dict of resource names to file paths
|
|
334
|
+
if isinstance(instance_id, dict):
|
|
335
|
+
return self._create_local_instance(instance_id)
|
|
336
|
+
|
|
337
|
+
# Localhost/direct URL mode - string starting with http:// or https://
|
|
338
|
+
elif isinstance(instance_id, str) and instance_id.startswith(("http://", "https://")):
|
|
339
|
+
return self._create_url_instance(instance_id)
|
|
340
|
+
|
|
341
|
+
# Remote mode - existing behavior
|
|
342
|
+
else:
|
|
343
|
+
response = await self.client.request("GET", f"/v1/env/instances/{instance_id}")
|
|
344
|
+
instance = AsyncEnv(client=self.client, **response.json())
|
|
345
|
+
await instance.instance.load()
|
|
346
|
+
return instance
|
|
347
|
+
|
|
348
|
+
def _create_url_instance(self, base_url: str) -> AsyncEnv:
|
|
349
|
+
"""Create instance connected to a direct URL (localhost or custom).
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
base_url: URL of the instance manager API
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
AsyncEnv: Environment instance configured for URL mode
|
|
356
|
+
"""
|
|
357
|
+
instance_client = AsyncInstanceClient(url=base_url, httpx_client=self._httpx_client)
|
|
358
|
+
|
|
359
|
+
# Create a minimal environment for URL mode
|
|
360
|
+
env = AsyncEnv(
|
|
361
|
+
client=self.client,
|
|
362
|
+
instance_id=base_url,
|
|
363
|
+
env_key="localhost",
|
|
364
|
+
version="",
|
|
365
|
+
status="running",
|
|
366
|
+
subdomain="localhost",
|
|
367
|
+
created_at="",
|
|
368
|
+
updated_at="",
|
|
369
|
+
terminated_at=None,
|
|
370
|
+
team_id="",
|
|
371
|
+
region="localhost",
|
|
372
|
+
env_variables=None,
|
|
373
|
+
data_key=None,
|
|
374
|
+
data_version=None,
|
|
375
|
+
urls=None,
|
|
376
|
+
health=None,
|
|
377
|
+
)
|
|
378
|
+
env._instance = instance_client
|
|
379
|
+
return env
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _normalize_db_path(path: str) -> tuple[str, bool]:
|
|
383
|
+
"""Normalize database path and detect if it's in-memory.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
path: Database path - can be:
|
|
387
|
+
- File path: "./data.db"
|
|
388
|
+
- Plain memory: ":memory:"
|
|
389
|
+
- Named memory: ":memory:namespace"
|
|
390
|
+
- URI: "file:name?mode=memory&cache=shared"
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Tuple of (normalized_path, is_memory)
|
|
394
|
+
"""
|
|
395
|
+
import uuid
|
|
396
|
+
import sqlite3
|
|
397
|
+
|
|
398
|
+
if path == ":memory:":
|
|
399
|
+
# Plain :memory: - create unique namespace
|
|
400
|
+
name = f"mem_{uuid.uuid4().hex[:8]}"
|
|
401
|
+
return f"file:{name}?mode=memory&cache=shared", True
|
|
402
|
+
elif path.startswith(":memory:"):
|
|
403
|
+
# Named memory: :memory:current -> file:current?mode=memory&cache=shared
|
|
404
|
+
namespace = path[8:] # Remove ":memory:" prefix
|
|
405
|
+
return f"file:{namespace}?mode=memory&cache=shared", True
|
|
406
|
+
elif "mode=memory" in path:
|
|
407
|
+
# Already a proper memory URI
|
|
408
|
+
return path, True
|
|
409
|
+
else:
|
|
410
|
+
# Regular file path
|
|
411
|
+
return path, False
|
|
412
|
+
|
|
413
|
+
def _create_local_instance(self, dbs: Dict[str, str]) -> AsyncEnv:
|
|
414
|
+
"""Create instance with local file-based or in-memory SQLite resources.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
dbs: Map of resource names to paths (e.g., {"current": "./data.db"} or
|
|
418
|
+
{"current": ":memory:current"})
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
AsyncEnv: Environment instance configured for local mode
|
|
422
|
+
"""
|
|
423
|
+
import sqlite3
|
|
424
|
+
|
|
425
|
+
instance_client = AsyncInstanceClient(url="local://", httpx_client=None)
|
|
426
|
+
instance_client._resources = [] # Mark as loaded
|
|
427
|
+
instance_client._memory_anchors = {} # Store anchor connections for in-memory DBs
|
|
428
|
+
|
|
429
|
+
# Store creation parameters for local AsyncSQLiteResources
|
|
430
|
+
# This allows db() to create new instances each time (matching HTTP mode behavior)
|
|
431
|
+
for name, path in dbs.items():
|
|
432
|
+
# Normalize path and detect if it's in-memory
|
|
433
|
+
normalized_path, is_memory = self._normalize_db_path(path)
|
|
434
|
+
|
|
435
|
+
# Create anchor connection for in-memory databases
|
|
436
|
+
# This keeps the database alive as long as the env exists
|
|
437
|
+
if is_memory:
|
|
438
|
+
anchor_conn = sqlite3.connect(normalized_path, uri=True)
|
|
439
|
+
instance_client._memory_anchors[name] = anchor_conn
|
|
440
|
+
|
|
441
|
+
resource_model = ResourceModel(
|
|
442
|
+
name=name,
|
|
443
|
+
type=ResourceType.db,
|
|
444
|
+
mode=ResourceMode.rw,
|
|
445
|
+
label=f"Local: {path}",
|
|
446
|
+
)
|
|
447
|
+
instance_client._resources_state[ResourceType.db.value][name] = {
|
|
448
|
+
'type': 'local',
|
|
449
|
+
'resource_model': resource_model,
|
|
450
|
+
'db_path': normalized_path,
|
|
451
|
+
'is_memory': is_memory
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# Create a minimal environment for local mode
|
|
455
|
+
env = AsyncEnv(
|
|
456
|
+
client=self.client,
|
|
457
|
+
instance_id="local",
|
|
458
|
+
env_key="local",
|
|
459
|
+
version="",
|
|
460
|
+
status="running",
|
|
461
|
+
subdomain="local",
|
|
462
|
+
created_at="",
|
|
463
|
+
updated_at="",
|
|
464
|
+
terminated_at=None,
|
|
465
|
+
team_id="",
|
|
466
|
+
region="local",
|
|
467
|
+
env_variables=None,
|
|
468
|
+
data_key=None,
|
|
469
|
+
data_version=None,
|
|
470
|
+
urls=None,
|
|
471
|
+
health=None,
|
|
472
|
+
)
|
|
473
|
+
env._instance = instance_client
|
|
474
|
+
return env
|
|
318
475
|
|
|
319
476
|
async def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
|
|
320
477
|
return await _check_bundle_exists(self.client, bundle_hash)
|
|
@@ -85,9 +85,17 @@ class AsyncInstanceClient:
|
|
|
85
85
|
Returns:
|
|
86
86
|
An SQLite database resource for the given database name
|
|
87
87
|
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
88
|
+
resource_info = self._resources_state[ResourceType.db.value][name]
|
|
89
|
+
# Local mode - resource_info is a dict with creation parameters
|
|
90
|
+
if isinstance(resource_info, dict) and resource_info.get('type') == 'local':
|
|
91
|
+
# Create new instance each time (matching HTTP mode behavior)
|
|
92
|
+
return AsyncSQLiteResource(
|
|
93
|
+
resource_info['resource_model'],
|
|
94
|
+
client=None,
|
|
95
|
+
db_path=resource_info['db_path']
|
|
96
|
+
)
|
|
97
|
+
# HTTP mode - resource_info is a ResourceModel, create new wrapper
|
|
98
|
+
return AsyncSQLiteResource(resource_info, self.client)
|
|
91
99
|
|
|
92
100
|
def browser(self, name: str) -> AsyncBrowserResource:
|
|
93
101
|
return AsyncBrowserResource(
|
|
@@ -177,10 +185,17 @@ class AsyncInstanceClient:
|
|
|
177
185
|
response = await self.client.request("GET", "/health")
|
|
178
186
|
return HealthResponse(**response.json())
|
|
179
187
|
|
|
188
|
+
def close(self):
|
|
189
|
+
"""Close anchor connections for in-memory databases."""
|
|
190
|
+
if hasattr(self, '_memory_anchors'):
|
|
191
|
+
for conn in self._memory_anchors.values():
|
|
192
|
+
conn.close()
|
|
193
|
+
self._memory_anchors.clear()
|
|
194
|
+
|
|
180
195
|
async def __aenter__(self):
|
|
181
196
|
"""Async context manager entry."""
|
|
182
197
|
return self
|
|
183
198
|
|
|
184
199
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
185
200
|
"""Async context manager exit."""
|
|
186
|
-
|
|
201
|
+
self.close()
|
|
@@ -6,6 +6,7 @@ from datetime import datetime
|
|
|
6
6
|
import tempfile
|
|
7
7
|
import sqlite3
|
|
8
8
|
import os
|
|
9
|
+
import asyncio
|
|
9
10
|
|
|
10
11
|
from typing import TYPE_CHECKING
|
|
11
12
|
|
|
@@ -679,17 +680,100 @@ class AsyncQueryBuilder:
|
|
|
679
680
|
|
|
680
681
|
|
|
681
682
|
class AsyncSQLiteResource(Resource):
|
|
682
|
-
def __init__(
|
|
683
|
+
def __init__(
|
|
684
|
+
self,
|
|
685
|
+
resource: ResourceModel,
|
|
686
|
+
client: Optional["AsyncWrapper"] = None,
|
|
687
|
+
db_path: Optional[str] = None,
|
|
688
|
+
):
|
|
683
689
|
super().__init__(resource)
|
|
684
690
|
self.client = client
|
|
691
|
+
self.db_path = db_path
|
|
692
|
+
self._mode = "direct" if db_path else "http"
|
|
693
|
+
|
|
694
|
+
@property
|
|
695
|
+
def mode(self) -> str:
|
|
696
|
+
"""Return the mode of this resource: 'direct' (local file) or 'http' (remote API)."""
|
|
697
|
+
return self._mode
|
|
685
698
|
|
|
686
699
|
async def describe(self) -> DescribeResponse:
|
|
687
700
|
"""Describe the SQLite database schema."""
|
|
701
|
+
if self._mode == "direct":
|
|
702
|
+
return await self._describe_direct()
|
|
703
|
+
else:
|
|
704
|
+
return await self._describe_http()
|
|
705
|
+
|
|
706
|
+
async def _describe_http(self) -> DescribeResponse:
|
|
707
|
+
"""Describe database schema via HTTP API."""
|
|
688
708
|
response = await self.client.request(
|
|
689
709
|
"GET", f"/resources/sqlite/{self.resource.name}/describe"
|
|
690
710
|
)
|
|
691
711
|
return DescribeResponse(**response.json())
|
|
692
712
|
|
|
713
|
+
async def _describe_direct(self) -> DescribeResponse:
|
|
714
|
+
"""Describe database schema from local file or in-memory database."""
|
|
715
|
+
def _sync_describe():
|
|
716
|
+
try:
|
|
717
|
+
# Check if we need URI mode (for shared memory databases)
|
|
718
|
+
use_uri = 'mode=memory' in self.db_path
|
|
719
|
+
conn = sqlite3.connect(self.db_path, uri=use_uri)
|
|
720
|
+
cursor = conn.cursor()
|
|
721
|
+
|
|
722
|
+
# Get all tables
|
|
723
|
+
cursor.execute(
|
|
724
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
725
|
+
)
|
|
726
|
+
table_names = [row[0] for row in cursor.fetchall()]
|
|
727
|
+
|
|
728
|
+
tables = []
|
|
729
|
+
for table_name in table_names:
|
|
730
|
+
# Get table info
|
|
731
|
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
|
732
|
+
columns = cursor.fetchall()
|
|
733
|
+
|
|
734
|
+
# Get CREATE TABLE SQL
|
|
735
|
+
cursor.execute(
|
|
736
|
+
f"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
|
737
|
+
(table_name,)
|
|
738
|
+
)
|
|
739
|
+
sql_row = cursor.fetchone()
|
|
740
|
+
create_sql = sql_row[0] if sql_row else ""
|
|
741
|
+
|
|
742
|
+
table_schema = {
|
|
743
|
+
"name": table_name,
|
|
744
|
+
"sql": create_sql,
|
|
745
|
+
"columns": [
|
|
746
|
+
{
|
|
747
|
+
"name": col[1],
|
|
748
|
+
"type": col[2],
|
|
749
|
+
"notnull": bool(col[3]),
|
|
750
|
+
"default_value": col[4],
|
|
751
|
+
"primary_key": col[5] > 0,
|
|
752
|
+
}
|
|
753
|
+
for col in columns
|
|
754
|
+
],
|
|
755
|
+
}
|
|
756
|
+
tables.append(table_schema)
|
|
757
|
+
|
|
758
|
+
conn.close()
|
|
759
|
+
|
|
760
|
+
return DescribeResponse(
|
|
761
|
+
success=True,
|
|
762
|
+
resource_name=self.resource.name,
|
|
763
|
+
tables=tables,
|
|
764
|
+
message="Schema retrieved from local file",
|
|
765
|
+
)
|
|
766
|
+
except Exception as e:
|
|
767
|
+
return DescribeResponse(
|
|
768
|
+
success=False,
|
|
769
|
+
resource_name=self.resource.name,
|
|
770
|
+
tables=None,
|
|
771
|
+
error=str(e),
|
|
772
|
+
message=f"Failed to describe database: {str(e)}",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
return await asyncio.to_thread(_sync_describe)
|
|
776
|
+
|
|
693
777
|
async def query(
|
|
694
778
|
self, query: str, args: Optional[List[Any]] = None
|
|
695
779
|
) -> QueryResponse:
|
|
@@ -701,6 +785,15 @@ class AsyncSQLiteResource(Resource):
|
|
|
701
785
|
async def _query(
|
|
702
786
|
self, query: str, args: Optional[List[Any]] = None, read_only: bool = True
|
|
703
787
|
) -> QueryResponse:
|
|
788
|
+
if self._mode == "direct":
|
|
789
|
+
return await self._query_direct(query, args, read_only)
|
|
790
|
+
else:
|
|
791
|
+
return await self._query_http(query, args, read_only)
|
|
792
|
+
|
|
793
|
+
async def _query_http(
|
|
794
|
+
self, query: str, args: Optional[List[Any]] = None, read_only: bool = True
|
|
795
|
+
) -> QueryResponse:
|
|
796
|
+
"""Execute query via HTTP API."""
|
|
704
797
|
request = QueryRequest(query=query, args=args, read_only=read_only)
|
|
705
798
|
response = await self.client.request(
|
|
706
799
|
"POST",
|
|
@@ -709,6 +802,62 @@ class AsyncSQLiteResource(Resource):
|
|
|
709
802
|
)
|
|
710
803
|
return QueryResponse(**response.json())
|
|
711
804
|
|
|
805
|
+
async def _query_direct(
|
|
806
|
+
self, query: str, args: Optional[List[Any]] = None, read_only: bool = True
|
|
807
|
+
) -> QueryResponse:
|
|
808
|
+
"""Execute query directly on local SQLite file or in-memory database."""
|
|
809
|
+
def _sync_query():
|
|
810
|
+
try:
|
|
811
|
+
# Check if we need URI mode (for shared memory databases)
|
|
812
|
+
use_uri = 'mode=memory' in self.db_path
|
|
813
|
+
conn = sqlite3.connect(self.db_path, uri=use_uri)
|
|
814
|
+
cursor = conn.cursor()
|
|
815
|
+
|
|
816
|
+
# Execute the query
|
|
817
|
+
if args:
|
|
818
|
+
cursor.execute(query, args)
|
|
819
|
+
else:
|
|
820
|
+
cursor.execute(query)
|
|
821
|
+
|
|
822
|
+
# For write operations, commit the transaction
|
|
823
|
+
if not read_only:
|
|
824
|
+
conn.commit()
|
|
825
|
+
|
|
826
|
+
# Get column names if available
|
|
827
|
+
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
|
828
|
+
|
|
829
|
+
# Fetch results for SELECT queries
|
|
830
|
+
rows = []
|
|
831
|
+
rows_affected = 0
|
|
832
|
+
last_insert_id = None
|
|
833
|
+
|
|
834
|
+
if cursor.description: # SELECT query
|
|
835
|
+
rows = cursor.fetchall()
|
|
836
|
+
else: # INSERT/UPDATE/DELETE
|
|
837
|
+
rows_affected = cursor.rowcount
|
|
838
|
+
last_insert_id = cursor.lastrowid if cursor.lastrowid else None
|
|
839
|
+
|
|
840
|
+
conn.close()
|
|
841
|
+
|
|
842
|
+
return QueryResponse(
|
|
843
|
+
success=True,
|
|
844
|
+
columns=columns if columns else None,
|
|
845
|
+
rows=rows if rows else None,
|
|
846
|
+
rows_affected=rows_affected if rows_affected > 0 else None,
|
|
847
|
+
last_insert_id=last_insert_id,
|
|
848
|
+
message="Query executed successfully",
|
|
849
|
+
)
|
|
850
|
+
except Exception as e:
|
|
851
|
+
return QueryResponse(
|
|
852
|
+
success=False,
|
|
853
|
+
columns=None,
|
|
854
|
+
rows=None,
|
|
855
|
+
error=str(e),
|
|
856
|
+
message=f"Query failed: {str(e)}",
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
return await asyncio.to_thread(_sync_query)
|
|
860
|
+
|
|
712
861
|
def table(self, table_name: str) -> AsyncQueryBuilder:
|
|
713
862
|
"""Create a query builder for the specified table."""
|
|
714
863
|
return AsyncQueryBuilder(self, table_name)
|
|
@@ -306,8 +306,11 @@ def verifier_from_string(
|
|
|
306
306
|
# Remove lines like: @verifier(key="...")
|
|
307
307
|
cleaned_code = re.sub(r"@verifier\([^)]*\)\s*\n", "", verifier_func)
|
|
308
308
|
# Also remove the verifier import if present
|
|
309
|
-
|
|
310
|
-
cleaned_code = re.sub(r"import.*verifier
|
|
309
|
+
# Use MULTILINE flag to match beginning of lines with ^
|
|
310
|
+
cleaned_code = re.sub(r"^from fleet\.verifiers.*import.*verifier.*$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
311
|
+
cleaned_code = re.sub(r"^from fleet import verifier.*$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
312
|
+
cleaned_code = re.sub(r"^import fleet\.verifiers.*$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
313
|
+
cleaned_code = re.sub(r"^import fleet$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
311
314
|
|
|
312
315
|
# Define helper functions for verifier execution
|
|
313
316
|
_TRANSLATOR = str.maketrans(string.punctuation, " " * len(string.punctuation))
|
|
@@ -21,7 +21,8 @@ import httpx
|
|
|
21
21
|
import json
|
|
22
22
|
import logging
|
|
23
23
|
import os
|
|
24
|
-
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
|
24
|
+
from typing import List, Optional, Dict, Any, TYPE_CHECKING, Union
|
|
25
|
+
from urllib.parse import urlparse
|
|
25
26
|
|
|
26
27
|
from .base import EnvironmentBase, SyncWrapper
|
|
27
28
|
from .models import (
|
|
@@ -49,6 +50,11 @@ from .instance import (
|
|
|
49
50
|
ResetResponse,
|
|
50
51
|
ExecuteFunctionResponse,
|
|
51
52
|
)
|
|
53
|
+
from .instance.models import (
|
|
54
|
+
Resource as ResourceModel,
|
|
55
|
+
ResourceType,
|
|
56
|
+
ResourceMode,
|
|
57
|
+
)
|
|
52
58
|
from .config import (
|
|
53
59
|
DEFAULT_MAX_RETRIES,
|
|
54
60
|
DEFAULT_TIMEOUT,
|
|
@@ -71,6 +77,14 @@ class SyncEnv(EnvironmentBase):
|
|
|
71
77
|
self._client = client
|
|
72
78
|
self._apps: Dict[str, InstanceClient] = {}
|
|
73
79
|
self._instance: Optional[InstanceClient] = None
|
|
80
|
+
self._manager_url_override: Optional[str] = None # For URL mode
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def manager_url(self) -> str:
|
|
84
|
+
"""Override to support URL mode where urls is None."""
|
|
85
|
+
if self._manager_url_override is not None:
|
|
86
|
+
return self._manager_url_override
|
|
87
|
+
return super().manager_url
|
|
74
88
|
|
|
75
89
|
@property
|
|
76
90
|
def instance(self) -> InstanceClient:
|
|
@@ -82,17 +96,17 @@ class SyncEnv(EnvironmentBase):
|
|
|
82
96
|
|
|
83
97
|
def app(self, name: str) -> InstanceClient:
|
|
84
98
|
if name not in self._apps:
|
|
85
|
-
# Extract
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
# Extract scheme://netloc from manager_url, then construct /{name}/api/v1/env
|
|
100
|
+
# Supports all URL formats:
|
|
101
|
+
# https://host/api/v1/env -> https://host/{name}/api/v1/env
|
|
102
|
+
# https://host/sentry/api/v1/env -> https://host/{name}/api/v1/env
|
|
103
|
+
# http://localhost:8080/api/v1/env -> http://localhost:8080/{name}/api/v1/env
|
|
104
|
+
parsed = urlparse(self.manager_url)
|
|
105
|
+
root = f"{parsed.scheme}://{parsed.netloc}"
|
|
106
|
+
new_url = f"{root}/{name}/api/v1/env"
|
|
93
107
|
|
|
94
108
|
self._apps[name] = InstanceClient(
|
|
95
|
-
|
|
109
|
+
new_url,
|
|
96
110
|
self._client.httpx_client if self._client else None,
|
|
97
111
|
)
|
|
98
112
|
return self._apps[name]
|
|
@@ -310,11 +324,165 @@ class Fleet:
|
|
|
310
324
|
for instance_data in response.json()
|
|
311
325
|
]
|
|
312
326
|
|
|
313
|
-
def instance(self, instance_id: str) -> SyncEnv:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
327
|
+
def instance(self, instance_id: Union[str, Dict[str, str]]) -> SyncEnv:
|
|
328
|
+
"""Create or connect to an environment instance.
|
|
329
|
+
|
|
330
|
+
Supports three modes based on input type:
|
|
331
|
+
1. dict: Local filesystem mode - {"current": "./data.db", "seed": "./seed.db"}
|
|
332
|
+
2. str starting with http:// or https://: Localhost/URL mode
|
|
333
|
+
3. str (other): Remote cloud instance mode
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
instance_id: Instance identifier (str), URL (str starting with http://),
|
|
337
|
+
or local db mapping (dict)
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
SyncEnv: Environment instance
|
|
341
|
+
"""
|
|
342
|
+
# Local filesystem mode - dict of resource names to file paths
|
|
343
|
+
if isinstance(instance_id, dict):
|
|
344
|
+
return self._create_local_instance(instance_id)
|
|
345
|
+
|
|
346
|
+
# Localhost/direct URL mode - string starting with http:// or https://
|
|
347
|
+
elif isinstance(instance_id, str) and instance_id.startswith(("http://", "https://")):
|
|
348
|
+
return self._create_url_instance(instance_id)
|
|
349
|
+
|
|
350
|
+
# Remote mode - existing behavior
|
|
351
|
+
else:
|
|
352
|
+
response = self.client.request("GET", f"/v1/env/instances/{instance_id}")
|
|
353
|
+
instance = SyncEnv(client=self.client, **response.json())
|
|
354
|
+
instance.instance.load()
|
|
355
|
+
return instance
|
|
356
|
+
|
|
357
|
+
def _create_url_instance(self, base_url: str) -> SyncEnv:
|
|
358
|
+
"""Create instance connected to a direct URL (localhost or custom).
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
base_url: URL of the instance manager API
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
SyncEnv: Environment instance configured for URL mode
|
|
365
|
+
"""
|
|
366
|
+
instance_client = InstanceClient(url=base_url, httpx_client=self._httpx_client)
|
|
367
|
+
|
|
368
|
+
# Create a minimal environment for URL mode
|
|
369
|
+
env = SyncEnv(
|
|
370
|
+
client=self.client,
|
|
371
|
+
instance_id=base_url,
|
|
372
|
+
env_key="localhost",
|
|
373
|
+
version="",
|
|
374
|
+
status="running",
|
|
375
|
+
subdomain="localhost",
|
|
376
|
+
created_at="",
|
|
377
|
+
updated_at="",
|
|
378
|
+
terminated_at=None,
|
|
379
|
+
team_id="",
|
|
380
|
+
region="localhost",
|
|
381
|
+
env_variables=None,
|
|
382
|
+
data_key=None,
|
|
383
|
+
data_version=None,
|
|
384
|
+
urls=None,
|
|
385
|
+
health=None,
|
|
386
|
+
)
|
|
387
|
+
env._instance = instance_client
|
|
388
|
+
env._manager_url_override = base_url # Set manager_url for URL mode
|
|
389
|
+
return env
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _normalize_db_path(path: str) -> tuple[str, bool]:
|
|
393
|
+
"""Normalize database path and detect if it's in-memory.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
path: Database path - can be:
|
|
397
|
+
- File path: "./data.db"
|
|
398
|
+
- Plain memory: ":memory:"
|
|
399
|
+
- Named memory: ":memory:namespace"
|
|
400
|
+
- URI: "file:name?mode=memory&cache=shared"
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Tuple of (normalized_path, is_memory)
|
|
404
|
+
"""
|
|
405
|
+
import uuid
|
|
406
|
+
import sqlite3
|
|
407
|
+
|
|
408
|
+
if path == ":memory:":
|
|
409
|
+
# Plain :memory: - create unique namespace
|
|
410
|
+
name = f"mem_{uuid.uuid4().hex[:8]}"
|
|
411
|
+
return f"file:{name}?mode=memory&cache=shared", True
|
|
412
|
+
elif path.startswith(":memory:"):
|
|
413
|
+
# Named memory: :memory:current -> file:current?mode=memory&cache=shared
|
|
414
|
+
namespace = path[8:] # Remove ":memory:" prefix
|
|
415
|
+
return f"file:{namespace}?mode=memory&cache=shared", True
|
|
416
|
+
elif "mode=memory" in path:
|
|
417
|
+
# Already a proper memory URI
|
|
418
|
+
return path, True
|
|
419
|
+
else:
|
|
420
|
+
# Regular file path
|
|
421
|
+
return path, False
|
|
422
|
+
|
|
423
|
+
def _create_local_instance(self, dbs: Dict[str, str]) -> SyncEnv:
|
|
424
|
+
"""Create instance with local file-based or in-memory SQLite resources.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
dbs: Map of resource names to paths (e.g., {"current": "./data.db"} or
|
|
428
|
+
{"current": ":memory:current"})
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
SyncEnv: Environment instance configured for local mode
|
|
432
|
+
"""
|
|
433
|
+
import sqlite3
|
|
434
|
+
|
|
435
|
+
instance_client = InstanceClient(url="local://", httpx_client=None)
|
|
436
|
+
instance_client._resources = [] # Mark as loaded
|
|
437
|
+
instance_client._memory_anchors = {} # Store anchor connections for in-memory DBs
|
|
438
|
+
|
|
439
|
+
# Store creation parameters for local SQLiteResources
|
|
440
|
+
# This allows db() to create new instances each time (matching HTTP mode behavior)
|
|
441
|
+
for name, path in dbs.items():
|
|
442
|
+
# Normalize path and detect if it's in-memory
|
|
443
|
+
normalized_path, is_memory = self._normalize_db_path(path)
|
|
444
|
+
|
|
445
|
+
# Create anchor connection for in-memory databases
|
|
446
|
+
# This keeps the database alive as long as the env exists
|
|
447
|
+
if is_memory:
|
|
448
|
+
anchor_conn = sqlite3.connect(normalized_path, uri=True)
|
|
449
|
+
instance_client._memory_anchors[name] = anchor_conn
|
|
450
|
+
|
|
451
|
+
resource_model = ResourceModel(
|
|
452
|
+
name=name,
|
|
453
|
+
type=ResourceType.db,
|
|
454
|
+
mode=ResourceMode.rw,
|
|
455
|
+
label=f"Local: {path}",
|
|
456
|
+
)
|
|
457
|
+
instance_client._resources_state[ResourceType.db.value][name] = {
|
|
458
|
+
'type': 'local',
|
|
459
|
+
'resource_model': resource_model,
|
|
460
|
+
'db_path': normalized_path,
|
|
461
|
+
'is_memory': is_memory
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
# Create a minimal environment for local mode
|
|
465
|
+
env = SyncEnv(
|
|
466
|
+
client=self.client,
|
|
467
|
+
instance_id="local",
|
|
468
|
+
env_key="local",
|
|
469
|
+
version="",
|
|
470
|
+
status="running",
|
|
471
|
+
subdomain="local",
|
|
472
|
+
created_at="",
|
|
473
|
+
updated_at="",
|
|
474
|
+
terminated_at=None,
|
|
475
|
+
team_id="",
|
|
476
|
+
region="local",
|
|
477
|
+
env_variables=None,
|
|
478
|
+
data_key=None,
|
|
479
|
+
data_version=None,
|
|
480
|
+
urls=None,
|
|
481
|
+
health=None,
|
|
482
|
+
)
|
|
483
|
+
env._instance = instance_client
|
|
484
|
+
env._manager_url_override = "local://" # Set manager_url for local mode
|
|
485
|
+
return env
|
|
318
486
|
|
|
319
487
|
def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
|
|
320
488
|
return _check_bundle_exists(self.client, bundle_hash)
|