arraylake 1.0.3a1__tar.gz → 1.1.0__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 (69) hide show
  1. {arraylake-1.0.3a1 → arraylake-1.1.0}/PKG-INFO +1 -1
  2. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/_credential_cache.py +55 -14
  3. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/_version.py +2 -2
  4. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/main.py +2 -0
  5. arraylake-1.1.0/arraylake/cli/skills.py +208 -0
  6. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/client.py +187 -70
  7. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/compute/types.py +9 -1
  8. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/repos/icechunk/types.py +1 -0
  9. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/repos/icechunk/virtual.py +3 -3
  10. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/types.py +5 -0
  11. {arraylake-1.0.3a1 → arraylake-1.1.0}/pyproject.toml +2 -2
  12. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/conftest.py +10 -0
  13. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/icechunk/test_virtual.py +15 -0
  14. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_credential_cache.py +121 -8
  15. arraylake-1.1.0/tests/test_skills.py +111 -0
  16. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_virtual_chunks.py +96 -0
  17. arraylake-1.1.0/uv.lock +2802 -0
  18. arraylake-1.0.3a1/uv.lock +0 -2795
  19. {arraylake-1.0.3a1 → arraylake-1.1.0}/.gitignore +0 -0
  20. {arraylake-1.0.3a1 → arraylake-1.1.0}/README.md +0 -0
  21. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/__init__.py +0 -0
  22. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/__main__.py +0 -0
  23. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/api_utils.py +0 -0
  24. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/asyn.py +0 -0
  25. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/__init__.py +0 -0
  26. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/auth.py +0 -0
  27. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/compute.py +0 -0
  28. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/config.py +0 -0
  29. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/repo.py +0 -0
  30. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/cli/utils.py +0 -0
  31. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/compute/doctor.py +0 -0
  32. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/compute/http_client.py +0 -0
  33. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/compute/services.py +0 -0
  34. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/config.py +0 -0
  35. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/config.yaml +0 -0
  36. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/credentials.py +0 -0
  37. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/diagnostics.py +0 -0
  38. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/display/__init__.py +0 -0
  39. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/display/repo.py +0 -0
  40. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/display/repolist.py +0 -0
  41. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/exceptions.py +0 -0
  42. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/log_util.py +0 -0
  43. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/metastore/__init__.py +0 -0
  44. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/metastore/abc.py +0 -0
  45. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/metastore/http_metastore.py +0 -0
  46. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/py.typed +0 -0
  47. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/repos/__init__.py +0 -0
  48. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/repos/icechunk/__init__.py +0 -0
  49. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/repos/icechunk/storage.py +0 -0
  50. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/token.py +0 -0
  51. {arraylake-1.0.3a1 → arraylake-1.1.0}/arraylake/tuning.py +0 -0
  52. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/__init__.py +0 -0
  53. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/compute/test_client.py +0 -0
  54. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/compute/test_doctor.py +0 -0
  55. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/config.yaml +0 -0
  56. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/icechunk/__init__.py +0 -0
  57. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/icechunk/conftest.py +0 -0
  58. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/icechunk/test_storage.py +0 -0
  59. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_api_utils.py +0 -0
  60. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_asyn.py +0 -0
  61. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_cli.py +0 -0
  62. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_client.py +0 -0
  63. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_config.py +0 -0
  64. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_credentials.py +0 -0
  65. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_diagnostics.py +0 -0
  66. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_performance.py +0 -0
  67. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_subscriptions.py +0 -0
  68. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_token_handler.py +0 -0
  69. {arraylake-1.0.3a1 → arraylake-1.1.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 1.0.3a1
3
+ Version: 1.1.0
4
4
  Summary: Python client for ArrayLake
5
5
  Project-URL: Documentation, https://docs.earthmover.io/
6
6
  Project-URL: Changelog, https://docs.earthmover.io/changelog
@@ -10,17 +10,24 @@ Validity follows ``Cache-Control: max-age`` semantics: cached creds are
10
10
  served while ``expires_at - CLOCK_SKEW_SAFETY > now``. The 30s safety only
11
11
  protects against worker/server NTP-level clock drift; it is not a
12
12
  pre-refresh window.
13
+
14
+ The cache is loop-agnostic: callers may live on any asyncio event loop in
15
+ the process. In-flight refreshes are coalesced via the singleflight
16
+ pattern using a single ``concurrent.futures.Future`` per key as the shared
17
+ result channel; each waiter bridges into its own loop via
18
+ ``asyncio.wrap_future``. The race for who runs the fetcher is resolved by
19
+ ``dict.setdefault``, which is atomic under the CPython GIL.
13
20
  """
14
21
 
15
22
  from __future__ import annotations
16
23
 
17
24
  import asyncio
25
+ import concurrent.futures
18
26
  from collections.abc import Awaitable, Callable
19
- from dataclasses import dataclass, field
27
+ from dataclasses import dataclass
20
28
  from datetime import UTC, datetime, timedelta
21
29
  from typing import Literal
22
30
 
23
- from arraylake.asyn import get_background_loop
24
31
  from arraylake.log_util import get_logger
25
32
  from arraylake.types import OrgName, Platform, TempCredentials
26
33
 
@@ -34,7 +41,7 @@ CLOCK_SKEW_SAFETY = timedelta(seconds=30)
34
41
  class CredentialCacheKey:
35
42
  api_url: str
36
43
  auth_key: int
37
- scope: Literal["repo", "bucket"]
44
+ scope: Literal["repo", "bucket", "vcc"]
38
45
  org: OrgName
39
46
  identifier: str
40
47
  platform: Platform
@@ -43,12 +50,20 @@ class CredentialCacheKey:
43
50
 
44
51
  @dataclass
45
52
  class _Entry:
46
- lock: asyncio.Lock = field(default_factory=asyncio.Lock)
47
- creds: TempCredentials | None = None
48
- expires_at: datetime | None = None
53
+ creds: TempCredentials
54
+ expires_at: datetime | None
49
55
 
50
56
 
51
57
  _CACHE: dict[CredentialCacheKey, _Entry] = {}
58
+ _INFLIGHT: dict[CredentialCacheKey, concurrent.futures.Future[TempCredentials]] = {}
59
+
60
+
61
+ def _fresh(entry: _Entry | None, now: datetime) -> TempCredentials | None:
62
+ if entry is None or entry.expires_at is None:
63
+ return None
64
+ if entry.expires_at - CLOCK_SKEW_SAFETY > now:
65
+ return entry.creds
66
+ return None
52
67
 
53
68
 
54
69
  async def get_or_refresh(
@@ -57,18 +72,29 @@ async def get_or_refresh(
57
72
  *,
58
73
  use_cache: bool = True,
59
74
  ) -> TempCredentials:
60
- assert asyncio.get_running_loop() is get_background_loop(), "credential cache must be used from the arraylake background loop"
61
75
  if not use_cache:
62
76
  return await fetcher()
63
- entry = _CACHE.setdefault(key, _Entry())
64
- async with entry.lock:
65
- now = datetime.now(UTC)
66
- if entry.creds is not None and entry.expires_at is not None and entry.expires_at - CLOCK_SKEW_SAFETY > now:
67
- return entry.creds
77
+
78
+ cached = _fresh(_CACHE.get(key), datetime.now(UTC))
79
+ if cached is not None:
80
+ return cached
81
+
82
+ my_future: concurrent.futures.Future[TempCredentials] = concurrent.futures.Future()
83
+ existing = _INFLIGHT.setdefault(key, my_future)
84
+ if existing is not my_future:
85
+ return await asyncio.wrap_future(existing)
86
+
87
+ try:
88
+ cached = _fresh(_CACHE.get(key), datetime.now(UTC))
89
+ if cached is not None:
90
+ my_future.set_result(cached)
91
+ return cached
92
+
68
93
  creds = await fetcher()
69
94
  exp = creds.expiration
70
95
  if exp is not None and exp.tzinfo is None:
71
96
  exp = exp.replace(tzinfo=UTC)
97
+ now = datetime.now(UTC)
72
98
  if exp is not None and exp - CLOCK_SKEW_SAFETY <= now:
73
99
  logger.warning(
74
100
  "delegated credentials returned with lifetime <= clock-skew safety; server contract violation",
@@ -77,10 +103,25 @@ async def get_or_refresh(
77
103
  org=key.org,
78
104
  identifier=key.identifier,
79
105
  )
80
- entry.creds = creds
81
- entry.expires_at = exp
106
+ _CACHE[key] = _Entry(creds=creds, expires_at=exp)
107
+ my_future.set_result(creds)
82
108
  return creds
109
+ except BaseException as e:
110
+ my_future.set_exception(e)
111
+ raise
112
+ finally:
113
+ _INFLIGHT.pop(key, None)
114
+
115
+
116
+ def populate(key: CredentialCacheKey, creds: TempCredentials) -> None:
117
+ exp = creds.expiration
118
+ if exp is not None and exp.tzinfo is None:
119
+ exp = exp.replace(tzinfo=UTC)
120
+ if _fresh(_CACHE.get(key), datetime.now(UTC)) is not None:
121
+ return
122
+ _CACHE[key] = _Entry(creds=creds, expires_at=exp)
83
123
 
84
124
 
85
125
  def clear() -> None:
86
126
  _CACHE.clear()
127
+ _INFLIGHT.clear()
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.0.3a1'
22
- __version_tuple__ = version_tuple = (1, 0, 3, 'a1')
21
+ __version__ = version = '1.1.0'
22
+ __version_tuple__ = version_tuple = (1, 1, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -6,6 +6,7 @@ from arraylake.cli.auth import auth
6
6
  from arraylake.cli.compute import app as compute_app
7
7
  from arraylake.cli.config import app as config_app
8
8
  from arraylake.cli.repo import app as repo_app
9
+ from arraylake.cli.skills import app as skills_app
9
10
  from arraylake.cli.utils import rich_console
10
11
  from arraylake.diagnostics import get_diagnostics
11
12
 
@@ -44,3 +45,4 @@ app.add_typer(auth, name="auth")
44
45
  app.add_typer(repo_app, name="repo")
45
46
  app.add_typer(config_app, name="config")
46
47
  app.add_typer(compute_app, name="compute")
48
+ app.add_typer(skills_app, name="skills")
@@ -0,0 +1,208 @@
1
+ """Install Arraylake Agent Skills into agent-specific skill directories.
2
+
3
+ Skills follow the open Agent Skills spec (https://agentskills.io) and are
4
+ hosted as static content on the Earthmover docs site. This command fetches
5
+ the manifest, downloads each skill's files, and writes them into the
6
+ directories agent skill loaders read from:
7
+
8
+ - `~/.claude/skills/` (Claude Code, opencode via compat shim)
9
+ - `~/.agents/skills/` (Codex, Cursor, Gemini CLI, and others)
10
+
11
+ With `--project`, installs into `.claude/skills/` and `.agents/skills/`
12
+ under the current working directory instead.
13
+
14
+ Skill content ships independently of the `arraylake` client release. Re-run
15
+ `arraylake skills install` to pick up new content; no `pip install -U`
16
+ required.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import shutil
22
+ import tempfile
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import httpx
27
+ import typer
28
+ from packaging.version import InvalidVersion, Version
29
+
30
+ import arraylake
31
+ from arraylake.cli.utils import rich_console
32
+ from arraylake.config import config
33
+
34
+ app = typer.Typer(help="Install Arraylake Agent Skills for AI coding agents")
35
+
36
+ DEFAULT_SKILLS_URI = "https://docs.earthmover.io/skills"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SkillEntry:
41
+ name: str
42
+ version: str
43
+ min_client_version: str | None
44
+ description: str
45
+ files: tuple[str, ...]
46
+
47
+
48
+ def _target_dirs(project: bool) -> list[Path]:
49
+ base = Path.cwd() if project else Path.home()
50
+ return [base / ".claude" / "skills", base / ".agents" / "skills"]
51
+
52
+
53
+ def _skills_uri() -> str:
54
+ uri: str = config.get("skills.uri", DEFAULT_SKILLS_URI)
55
+ return uri.rstrip("/")
56
+
57
+
58
+ def _client_version_ok(min_required: str | None) -> bool:
59
+ if not min_required:
60
+ return True
61
+ try:
62
+ return Version(arraylake.__version__) >= Version(min_required)
63
+ except InvalidVersion:
64
+ # Treat unparseable client versions (e.g. local dev builds without
65
+ # a tag) as "good enough" — don't block users on tooling weirdness.
66
+ return True
67
+
68
+
69
+ def _parse_manifest(payload: dict) -> list[SkillEntry]:
70
+ entries: list[SkillEntry] = []
71
+ for raw in payload.get("skills", []):
72
+ entries.append(
73
+ SkillEntry(
74
+ name=raw["name"],
75
+ version=str(raw.get("version", "0.0")),
76
+ min_client_version=raw.get("min_client_version"),
77
+ description=str(raw.get("description", "")),
78
+ files=tuple(raw.get("files", [])),
79
+ )
80
+ )
81
+ return entries
82
+
83
+
84
+ def _fetch_manifest(uri: str) -> list[SkillEntry]:
85
+ url = f"{uri}/manifest.json"
86
+ with httpx.Client(timeout=20.0, follow_redirects=True) as client:
87
+ resp = client.get(url)
88
+ resp.raise_for_status()
89
+ return _parse_manifest(resp.json())
90
+
91
+
92
+ def _empty_temporary_sibling(dest: Path, prefix: str) -> Path:
93
+ path = Path(tempfile.mkdtemp(prefix=prefix, dir=dest))
94
+ path.rmdir()
95
+ return path
96
+
97
+
98
+ def _remove_path(path: Path) -> None:
99
+ if path.is_symlink() or path.is_file():
100
+ path.unlink()
101
+ elif path.exists():
102
+ shutil.rmtree(path)
103
+
104
+
105
+ def _install_skill(uri: str, entry: SkillEntry, dest: Path) -> str:
106
+ """Download all of `entry.files` into `dest/<name>/...`.
107
+
108
+ Returns one-word status: written | conflict.
109
+ """
110
+ skill_root = dest / entry.name
111
+ if skill_root.exists() and not skill_root.is_dir():
112
+ return "conflict"
113
+
114
+ tmp_root = Path(tempfile.mkdtemp(prefix=".arraylake-skill-", dir=dest))
115
+ backup_root: Path | None = None
116
+
117
+ try:
118
+ with httpx.Client(timeout=30.0, follow_redirects=True) as client:
119
+ for rel in entry.files:
120
+ if ".." in rel.split("/") or rel.startswith("/"):
121
+ raise RuntimeError(f"Refusing to write file outside skill root: {rel}")
122
+ file_url = f"{uri}/{entry.name}/{rel}"
123
+ resp = client.get(file_url)
124
+ resp.raise_for_status()
125
+ target = tmp_root / rel
126
+ target.parent.mkdir(parents=True, exist_ok=True)
127
+ target.write_bytes(resp.content)
128
+
129
+ if skill_root.exists() or skill_root.is_symlink():
130
+ # Pre-existing install, including legacy wheel-bundled symlinks.
131
+ # Move it aside only after every replacement file has downloaded.
132
+ backup_root = _empty_temporary_sibling(dest, ".arraylake-skill-backup-")
133
+ skill_root.replace(backup_root)
134
+
135
+ tmp_root.replace(skill_root)
136
+ if backup_root is not None:
137
+ _remove_path(backup_root)
138
+ backup_root = None
139
+ return "written"
140
+ except Exception:
141
+ if backup_root is not None and (backup_root.exists() or backup_root.is_symlink()):
142
+ if skill_root.exists() or skill_root.is_symlink():
143
+ _remove_path(skill_root)
144
+ backup_root.replace(skill_root)
145
+ backup_root = None
146
+ raise
147
+ finally:
148
+ if tmp_root.exists() or tmp_root.is_symlink():
149
+ _remove_path(tmp_root)
150
+
151
+
152
+ @app.command()
153
+ def install(
154
+ project: bool = typer.Option(False, "--project", help="Install into the current directory instead of $HOME."),
155
+ url: str | None = typer.Option(
156
+ None,
157
+ "--url",
158
+ help=f"Override the skills server URL. Default: skills.uri config or {DEFAULT_SKILLS_URI}.",
159
+ ),
160
+ ) -> None:
161
+ """**Install** Arraylake skills into agent skill directories.
162
+
163
+ Default: user scope (`~/.claude/skills/` and `~/.agents/skills/`).
164
+ With `--project`: project scope under the current working directory.
165
+
166
+ Fetches the latest published skills from the Earthmover docs site
167
+ (configurable via `--url` or the `skills.uri` config key). Re-run this
168
+ to pick up content updates — they ship independently of the `arraylake`
169
+ package release cycle.
170
+ """
171
+ uri = (url or _skills_uri()).rstrip("/")
172
+ rich_console.print(f"Fetching skill manifest from [cyan]{uri}/manifest.json[/cyan]")
173
+ try:
174
+ entries = _fetch_manifest(uri)
175
+ except httpx.HTTPError as err:
176
+ rich_console.print(f"[red]Failed to fetch skills from {uri}: {err}[/red]")
177
+ raise typer.Exit(code=1) from err
178
+
179
+ if not entries:
180
+ rich_console.print("[yellow]Manifest contained no skills; nothing to install.[/yellow]")
181
+ return
182
+
183
+ ok = True
184
+ for target in _target_dirs(project):
185
+ target.mkdir(parents=True, exist_ok=True)
186
+ for entry in entries:
187
+ if not _client_version_ok(entry.min_client_version):
188
+ rich_console.print(
189
+ f"[yellow]skip[/yellow] {target / entry.name} "
190
+ f"(skill requires arraylake>={entry.min_client_version}, "
191
+ f"installed {arraylake.__version__})"
192
+ )
193
+ continue
194
+ try:
195
+ status = _install_skill(uri, entry, target)
196
+ except httpx.HTTPError as err:
197
+ rich_console.print(f"[red]error[/red] {target / entry.name}: {err}")
198
+ ok = False
199
+ continue
200
+ if status == "conflict":
201
+ ok = False
202
+ rich_console.print(f"[yellow]skip[/yellow] {target / entry.name} (path exists and is not a directory)")
203
+ else:
204
+ rich_console.print(f"[green]{status:8}[/green]{target / entry.name}")
205
+
206
+ if not ok:
207
+ rich_console.print("\n[yellow]One or more skills were skipped. Resolve the conflicts above and re-run.[/yellow]")
208
+ raise typer.Exit(code=1)