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.
Files changed (89) hide show
  1. {fleet_python-0.2.71/fleet_python.egg-info → fleet_python-0.2.72}/PKG-INFO +1 -1
  2. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/client.py +163 -6
  3. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/instance/client.py +19 -4
  4. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/sqlite.py +150 -1
  5. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/tasks.py +5 -2
  6. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/client.py +183 -15
  7. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/client.py +20 -5
  8. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/sqlite.py +143 -1
  9. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/tasks.py +5 -2
  10. {fleet_python-0.2.71 → fleet_python-0.2.72/fleet_python.egg-info}/PKG-INFO +1 -1
  11. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/SOURCES.txt +4 -0
  12. {fleet_python-0.2.71 → fleet_python-0.2.72}/pyproject.toml +2 -1
  13. fleet_python-0.2.72/tests/test_app_method.py +85 -0
  14. fleet_python-0.2.72/tests/test_instance_dispatch.py +607 -0
  15. fleet_python-0.2.72/tests/test_sqlite_resource_dual_mode.py +263 -0
  16. fleet_python-0.2.72/tests/test_sqlite_shared_memory_behavior.py +117 -0
  17. {fleet_python-0.2.71 → fleet_python-0.2.72}/LICENSE +0 -0
  18. {fleet_python-0.2.71 → fleet_python-0.2.72}/README.md +0 -0
  19. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/diff_example.py +0 -0
  20. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/dsl_example.py +0 -0
  21. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example.py +0 -0
  22. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/exampleResume.py +0 -0
  23. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_account.py +0 -0
  24. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_action_log.py +0 -0
  25. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_client.py +0 -0
  26. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_mcp_anthropic.py +0 -0
  27. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_mcp_openai.py +0 -0
  28. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_sync.py +0 -0
  29. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_task.py +0 -0
  30. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_tasks.py +0 -0
  31. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/example_verifier.py +0 -0
  32. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/export_tasks.py +0 -0
  33. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/gemini_example.py +0 -0
  34. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/import_tasks.py +0 -0
  35. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/json_tasks_example.py +0 -0
  36. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/nova_act_example.py +0 -0
  37. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/openai_example.py +0 -0
  38. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/openai_simple_example.py +0 -0
  39. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/query_builder_example.py +0 -0
  40. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/quickstart.py +0 -0
  41. {fleet_python-0.2.71 → fleet_python-0.2.72}/examples/test_cdp_logging.py +0 -0
  42. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/__init__.py +0 -0
  43. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/__init__.py +0 -0
  44. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/base.py +0 -0
  45. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/env/__init__.py +0 -0
  46. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/env/client.py +0 -0
  47. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/exceptions.py +0 -0
  48. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/global_client.py +0 -0
  49. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/instance/__init__.py +0 -0
  50. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/instance/base.py +0 -0
  51. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/models.py +0 -0
  52. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/__init__.py +0 -0
  53. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/base.py +0 -0
  54. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/browser.py +0 -0
  55. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/resources/mcp.py +0 -0
  56. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/verifiers/__init__.py +0 -0
  57. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/verifiers/bundler.py +0 -0
  58. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/_async/verifiers/verifier.py +0 -0
  59. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/base.py +0 -0
  60. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/config.py +0 -0
  61. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/env/__init__.py +0 -0
  62. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/env/client.py +0 -0
  63. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/exceptions.py +0 -0
  64. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/global_client.py +0 -0
  65. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/__init__.py +0 -0
  66. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/base.py +0 -0
  67. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/instance/models.py +0 -0
  68. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/models.py +0 -0
  69. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/__init__.py +0 -0
  70. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/base.py +0 -0
  71. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/browser.py +0 -0
  72. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/resources/mcp.py +0 -0
  73. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/types.py +0 -0
  74. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/__init__.py +0 -0
  75. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/bundler.py +0 -0
  76. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/code.py +0 -0
  77. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/db.py +0 -0
  78. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/decorator.py +0 -0
  79. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/parse.py +0 -0
  80. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/sql_differ.py +0 -0
  81. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet/verifiers/verifier.py +0 -0
  82. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/dependency_links.txt +0 -0
  83. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/requires.txt +0 -0
  84. {fleet_python-0.2.71 → fleet_python-0.2.72}/fleet_python.egg-info/top_level.txt +0 -0
  85. {fleet_python-0.2.71 → fleet_python-0.2.72}/scripts/fix_sync_imports.py +0 -0
  86. {fleet_python-0.2.71 → fleet_python-0.2.72}/scripts/unasync.py +0 -0
  87. {fleet_python-0.2.71 → fleet_python-0.2.72}/setup.cfg +0 -0
  88. {fleet_python-0.2.71 → fleet_python-0.2.72}/tests/__init__.py +0 -0
  89. {fleet_python-0.2.71 → fleet_python-0.2.72}/tests/test_verifier_from_string.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.71
3
+ Version: 0.2.72
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.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
- response = await self.client.request("GET", f"/v1/env/instances/{instance_id}")
315
- instance = AsyncEnv(client=self.client, **response.json())
316
- await instance.instance.load()
317
- return instance
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
- return AsyncSQLiteResource(
89
- self._resources_state[ResourceType.db.value][name], self.client
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
- await self.close()
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__(self, resource: ResourceModel, client: "AsyncWrapper"):
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
- cleaned_code = re.sub(r"from fleet import.*verifier.*\n", "", cleaned_code)
310
- cleaned_code = re.sub(r"import.*verifier.*\n", "", cleaned_code)
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 base URL by removing the current app path (e.g., /sentry/api/v1/env)
86
- # manager_url looks like: https://xxx.fleetai.com/sentry/api/v1/env
87
- base_url = self.manager_url.split("/api/v1/env")[0]
88
- # Remove the current app name (e.g., /sentry) to get the root
89
- if "/" in base_url:
90
- parts = base_url.rsplit("/", 1)
91
- if len(parts) == 2 and parts[0] != "https:/":
92
- base_url = parts[0]
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
- f"{base_url}/{name}/api/v1/env",
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
- response = self.client.request("GET", f"/v1/env/instances/{instance_id}")
315
- instance = SyncEnv(client=self.client, **response.json())
316
- instance.instance.load()
317
- return instance
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)