oasr 0.3.4__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.
registry.py ADDED
@@ -0,0 +1,173 @@
1
+ """Registry management for ~/.oasr/registry.toml."""
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ import tomli as tomllib
11
+
12
+ import tomli_w
13
+
14
+ from config import OASR_DIR, ensure_skills_dir
15
+ from manifest import create_manifest, delete_manifest, save_manifest
16
+
17
+ REGISTRY_FILE = OASR_DIR / "registry.toml"
18
+
19
+
20
+ @dataclass
21
+ class SkillEntry:
22
+ """A registered skill entry."""
23
+
24
+ path: str
25
+ name: str
26
+ description: str
27
+
28
+ def to_dict(self) -> dict[str, str]:
29
+ """Convert to dictionary for TOML serialization."""
30
+ return {
31
+ "path": self.path,
32
+ "name": self.name,
33
+ "description": self.description,
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict[str, str]) -> "SkillEntry":
38
+ """Create from dictionary."""
39
+ return cls(
40
+ path=data.get("path", ""),
41
+ name=data.get("name", ""),
42
+ description=data.get("description", ""),
43
+ )
44
+
45
+
46
+ def load_registry(registry_path: Path | None = None) -> list[SkillEntry]:
47
+ """Load registry from TOML file.
48
+
49
+ Args:
50
+ registry_path: Override registry file path. Defaults to ~/.oasr/registry.toml.
51
+
52
+ Returns:
53
+ List of registered skill entries.
54
+ """
55
+ path = registry_path or REGISTRY_FILE
56
+
57
+ if not path.exists():
58
+ return []
59
+
60
+ with open(path, "rb") as f:
61
+ data = tomllib.load(f)
62
+
63
+ skills = data.get("skill", [])
64
+ return [SkillEntry.from_dict(s) for s in skills]
65
+
66
+
67
+ def save_registry(entries: list[SkillEntry], registry_path: Path | None = None) -> None:
68
+ """Save registry to TOML file.
69
+
70
+ Args:
71
+ entries: List of skill entries to save.
72
+ registry_path: Override registry file path. Defaults to ~/.skills/registry.toml.
73
+ """
74
+ path = registry_path or REGISTRY_FILE
75
+ ensure_skills_dir()
76
+
77
+ data = {"skill": [e.to_dict() for e in entries]}
78
+
79
+ with open(path, "wb") as f:
80
+ tomli_w.dump(data, f)
81
+
82
+
83
+ def add_skill(
84
+ entry: SkillEntry,
85
+ registry_path: Path | None = None,
86
+ create_manifest_artifact: bool = True,
87
+ ) -> bool:
88
+ """Add or update a skill in the registry.
89
+
90
+ Args:
91
+ entry: Skill entry to add.
92
+ registry_path: Override registry file path.
93
+ create_manifest_artifact: Whether to create/update the manifest artifact.
94
+
95
+ Returns:
96
+ True if skill was added, False if it was updated (already existed).
97
+ """
98
+ entries = load_registry(registry_path)
99
+ is_new = True
100
+
101
+ for i, existing in enumerate(entries):
102
+ if existing.name == entry.name or existing.path == entry.path:
103
+ entries[i] = entry
104
+ is_new = False
105
+ break
106
+
107
+ if is_new:
108
+ entries.append(entry)
109
+
110
+ save_registry(entries, registry_path)
111
+
112
+ if create_manifest_artifact:
113
+ manifest = create_manifest(
114
+ name=entry.name,
115
+ source_path=entry.path, # Keep as string (can be URL or path)
116
+ description=entry.description,
117
+ )
118
+ save_manifest(manifest)
119
+
120
+ return is_new
121
+
122
+
123
+ def remove_skill(
124
+ name_or_path: str,
125
+ registry_path: Path | None = None,
126
+ delete_manifest_artifact: bool = True,
127
+ ) -> bool:
128
+ """Remove a skill from the registry by name or path.
129
+
130
+ Args:
131
+ name_or_path: Skill name or path to remove.
132
+ registry_path: Override registry file path.
133
+ delete_manifest_artifact: Whether to delete the manifest artifact.
134
+
135
+ Returns:
136
+ True if skill was removed, False if not found.
137
+ """
138
+ entries = load_registry(registry_path)
139
+ removed_name = None
140
+
141
+ new_entries = []
142
+ for e in entries:
143
+ if e.name == name_or_path or e.path == name_or_path:
144
+ removed_name = e.name
145
+ else:
146
+ new_entries.append(e)
147
+
148
+ if removed_name:
149
+ save_registry(new_entries, registry_path)
150
+ if delete_manifest_artifact:
151
+ delete_manifest(removed_name)
152
+ return True
153
+
154
+ return False
155
+
156
+
157
+ def find_skill(name_or_path: str, registry_path: Path | None = None) -> SkillEntry | None:
158
+ """Find a skill by name or path.
159
+
160
+ Args:
161
+ name_or_path: Skill name or path to find.
162
+ registry_path: Override registry file path.
163
+
164
+ Returns:
165
+ Skill entry if found, None otherwise.
166
+ """
167
+ entries = load_registry(registry_path)
168
+
169
+ for entry in entries:
170
+ if entry.name == name_or_path or entry.path == name_or_path:
171
+ return entry
172
+
173
+ return None
remote.py ADDED
@@ -0,0 +1,482 @@
1
+ """Remote skill fetching from GitHub and GitLab.
2
+
3
+ Provides low-level operations for parsing URLs, fetching from APIs,
4
+ and managing remote skill content.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import tempfile
12
+ from pathlib import Path
13
+ from urllib import error as urlerror
14
+ from urllib import parse as urlparse
15
+ from urllib import request
16
+
17
+
18
+ # Custom exceptions
19
+ class RemoteSkillError(Exception):
20
+ """Base exception for remote skill operations."""
21
+
22
+ pass
23
+
24
+
25
+ class RemoteSkillNotFoundError(RemoteSkillError):
26
+ """Remote skill or repository not found."""
27
+
28
+ pass
29
+
30
+
31
+ class RemoteRateLimitError(RemoteSkillError):
32
+ """API rate limit exceeded."""
33
+
34
+ pass
35
+
36
+
37
+ class RemoteNetworkError(RemoteSkillError):
38
+ """Network error during remote operation."""
39
+
40
+ pass
41
+
42
+
43
+ class InvalidRemoteUrlError(RemoteSkillError):
44
+ """Invalid remote URL format."""
45
+
46
+ pass
47
+
48
+
49
+ # URL patterns
50
+ GITHUB_PATTERN = re.compile(r"^https?://github\.com/([^/]+)/([^/]+)(?:/(?:tree|blob)/([^/]+)/(.+))?/?$")
51
+ GITLAB_PATTERN = re.compile(r"^https?://gitlab\.com/([^/]+)/([^/]+)(?:/(?:-/)?tree/([^/]+)/(.+))?/?$")
52
+
53
+
54
+ def parse_github_url(url: str) -> dict | None:
55
+ """Parse a GitHub URL into components.
56
+
57
+ Args:
58
+ url: GitHub URL (e.g., https://github.com/user/repo/tree/main/path)
59
+
60
+ Returns:
61
+ Dictionary with 'owner', 'repo', 'ref', 'path' keys, or None if invalid
62
+ """
63
+ match = GITHUB_PATTERN.match(url)
64
+ if not match:
65
+ return None
66
+
67
+ owner, repo, ref, path = match.groups()
68
+
69
+ return {
70
+ "owner": owner,
71
+ "repo": repo,
72
+ "ref": ref or "main", # Default to main branch
73
+ "path": path or "",
74
+ }
75
+
76
+
77
+ def parse_gitlab_url(url: str) -> dict | None:
78
+ """Parse a GitLab URL into components.
79
+
80
+ Args:
81
+ url: GitLab URL (e.g., https://gitlab.com/user/project/tree/main/path)
82
+
83
+ Returns:
84
+ Dictionary with 'owner', 'repo', 'ref', 'path' keys, or None if invalid
85
+ """
86
+ match = GITLAB_PATTERN.match(url)
87
+ if not match:
88
+ return None
89
+
90
+ owner, repo, ref, path = match.groups()
91
+
92
+ return {
93
+ "owner": owner,
94
+ "repo": repo,
95
+ "ref": ref or "main",
96
+ "path": path or "",
97
+ }
98
+
99
+
100
+ def derive_skill_name(url: str) -> str:
101
+ """Derive a skill name from a remote URL.
102
+
103
+ Extracts repo name and skill path to create a kebab-case name.
104
+ Format: {repo}-{skill-identifier}
105
+
106
+ Args:
107
+ url: Remote URL
108
+
109
+ Returns:
110
+ Derived skill name in kebab-case
111
+
112
+ Raises:
113
+ InvalidRemoteUrlError: If URL cannot be parsed
114
+ """
115
+ parsed = parse_github_url(url) or parse_gitlab_url(url)
116
+
117
+ if not parsed:
118
+ raise InvalidRemoteUrlError(f"Cannot parse URL: {url}")
119
+
120
+ repo = parsed["repo"]
121
+ path = parsed["path"]
122
+
123
+ # Get last component of path as skill identifier
124
+ if path:
125
+ skill_id = Path(path).name
126
+ else:
127
+ skill_id = ""
128
+
129
+ # Combine repo and skill identifier
130
+ if skill_id:
131
+ name = f"{repo}-{skill_id}"
132
+ else:
133
+ name = repo
134
+
135
+ # Ensure kebab-case (lowercase with hyphens)
136
+ name = name.lower().replace("_", "-")
137
+
138
+ return name
139
+
140
+
141
+ def validate_remote_url(url: str) -> tuple[bool, str]:
142
+ """Validate a remote URL format.
143
+
144
+ Args:
145
+ url: URL to validate
146
+
147
+ Returns:
148
+ Tuple of (is_valid, error_message)
149
+ """
150
+ if not url:
151
+ return False, "URL is empty"
152
+
153
+ if not (url.startswith("http://") or url.startswith("https://")):
154
+ return False, "URL must start with http:// or https://"
155
+
156
+ if parse_github_url(url):
157
+ return True, ""
158
+
159
+ if parse_gitlab_url(url):
160
+ return True, ""
161
+
162
+ return False, "URL must be a valid GitHub or GitLab repository URL"
163
+
164
+
165
+ def _get_auth_token(platform: str) -> str | None:
166
+ """Get authentication token from environment.
167
+
168
+ Args:
169
+ platform: 'github' or 'gitlab'
170
+
171
+ Returns:
172
+ Token string or None
173
+ """
174
+ if platform == "github":
175
+ return os.environ.get("GITHUB_TOKEN")
176
+ elif platform == "gitlab":
177
+ return os.environ.get("GITLAB_TOKEN")
178
+ return None
179
+
180
+
181
+ def fetch_github_directory(
182
+ owner: str,
183
+ repo: str,
184
+ path: str,
185
+ ref: str = "main",
186
+ token: str | None = None,
187
+ ) -> list[dict]:
188
+ """Fetch directory contents from GitHub API.
189
+
190
+ Args:
191
+ owner: Repository owner
192
+ repo: Repository name
193
+ path: Path within repository
194
+ ref: Branch/tag/commit reference
195
+ token: Optional authentication token
196
+
197
+ Returns:
198
+ List of file/directory entries
199
+
200
+ Raises:
201
+ RemoteSkillNotFoundError: If repository or path not found
202
+ RemoteRateLimitError: If rate limit exceeded
203
+ RemoteNetworkError: On network errors
204
+ """
205
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}"
206
+ if ref:
207
+ api_url += f"?ref={ref}"
208
+
209
+ headers = {"Accept": "application/vnd.github.v3+json"}
210
+ if token:
211
+ headers["Authorization"] = f"token {token}"
212
+
213
+ req = request.Request(api_url, headers=headers)
214
+
215
+ try:
216
+ with request.urlopen(req) as response:
217
+ data = json.loads(response.read().decode("utf-8"))
218
+ return data if isinstance(data, list) else [data]
219
+ except urlerror.HTTPError as e:
220
+ if e.code == 404:
221
+ raise RemoteSkillNotFoundError(f"Not found: {owner}/{repo}/{path}")
222
+ elif e.code == 403:
223
+ raise RemoteRateLimitError("GitHub API rate limit exceeded. Set GITHUB_TOKEN environment variable.")
224
+ else:
225
+ raise RemoteNetworkError(f"HTTP error {e.code}: {e.reason}")
226
+ except urlerror.URLError as e:
227
+ raise RemoteNetworkError(f"Network error: {e.reason}")
228
+
229
+
230
+ def fetch_gitlab_directory(
231
+ owner: str,
232
+ repo: str,
233
+ path: str,
234
+ ref: str = "main",
235
+ token: str | None = None,
236
+ ) -> list[dict]:
237
+ """Fetch directory contents from GitLab API.
238
+
239
+ Args:
240
+ owner: Project owner/group
241
+ repo: Repository name
242
+ path: Path within repository
243
+ ref: Branch/tag/commit reference
244
+ token: Optional authentication token
245
+
246
+ Returns:
247
+ List of file/directory entries
248
+
249
+ Raises:
250
+ RemoteSkillNotFoundError: If repository or path not found
251
+ RemoteRateLimitError: If rate limit exceeded
252
+ RemoteNetworkError: On network errors
253
+ """
254
+ project_id = f"{owner}%2F{repo}"
255
+ api_url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/tree"
256
+ params = {"ref": ref, "path": path, "recursive": "false"}
257
+ full_url = f"{api_url}?{urlparse.urlencode(params)}"
258
+
259
+ headers = {}
260
+ if token:
261
+ headers["PRIVATE-TOKEN"] = token
262
+
263
+ req = request.Request(full_url, headers=headers)
264
+
265
+ try:
266
+ with request.urlopen(req) as response:
267
+ data = json.loads(response.read().decode("utf-8"))
268
+ return data if isinstance(data, list) else []
269
+ except urlerror.HTTPError as e:
270
+ if e.code == 404:
271
+ raise RemoteSkillNotFoundError(f"Not found: {owner}/{repo}/{path}")
272
+ elif e.code == 429:
273
+ raise RemoteRateLimitError("GitLab API rate limit exceeded. Set GITLAB_TOKEN environment variable.")
274
+ else:
275
+ raise RemoteNetworkError(f"HTTP error {e.code}: {e.reason}")
276
+ except urlerror.URLError as e:
277
+ raise RemoteNetworkError(f"Network error: {e.reason}")
278
+
279
+
280
+ def _download_file(url: str, dest: Path, token: str | None = None, platform: str = "github"):
281
+ """Download a file from URL to destination.
282
+
283
+ Args:
284
+ url: File download URL
285
+ dest: Destination file path
286
+ token: Optional authentication token
287
+ platform: 'github' or 'gitlab'
288
+ """
289
+ headers = {}
290
+ if token:
291
+ if platform == "github":
292
+ headers["Authorization"] = f"token {token}"
293
+ elif platform == "gitlab":
294
+ headers["PRIVATE-TOKEN"] = token
295
+
296
+ req = request.Request(url, headers=headers)
297
+
298
+ dest.parent.mkdir(parents=True, exist_ok=True)
299
+
300
+ with request.urlopen(req) as response:
301
+ dest.write_bytes(response.read())
302
+
303
+
304
+ def _fetch_github_recursive(
305
+ owner: str,
306
+ repo: str,
307
+ path: str,
308
+ ref: str,
309
+ dest_dir: Path,
310
+ token: str | None = None,
311
+ ):
312
+ """Recursively fetch GitHub directory structure.
313
+
314
+ Args:
315
+ owner: Repository owner
316
+ repo: Repository name
317
+ path: Path within repository
318
+ ref: Branch/tag/commit reference
319
+ dest_dir: Destination directory
320
+ token: Optional authentication token
321
+ """
322
+ entries = fetch_github_directory(owner, repo, path, ref, token)
323
+
324
+ for entry in entries:
325
+ entry_path = entry["path"]
326
+ entry_name = entry["name"]
327
+ entry_type = entry["type"]
328
+
329
+ if entry_type == "file":
330
+ download_url = entry["download_url"]
331
+ dest_file = dest_dir / entry_name
332
+ _download_file(download_url, dest_file, token, "github")
333
+ elif entry_type == "dir":
334
+ subdir = dest_dir / entry_name
335
+ subdir.mkdir(parents=True, exist_ok=True)
336
+ _fetch_github_recursive(owner, repo, entry_path, ref, subdir, token)
337
+
338
+
339
+ def _fetch_gitlab_recursive(
340
+ owner: str,
341
+ repo: str,
342
+ path: str,
343
+ ref: str,
344
+ dest_dir: Path,
345
+ token: str | None = None,
346
+ ):
347
+ """Recursively fetch GitLab directory structure.
348
+
349
+ Args:
350
+ owner: Project owner/group
351
+ repo: Repository name
352
+ path: Path within repository
353
+ ref: Branch/tag/commit reference
354
+ dest_dir: Destination directory
355
+ token: Optional authentication token
356
+ """
357
+ entries = fetch_gitlab_directory(owner, repo, path, ref, token)
358
+
359
+ for entry in entries:
360
+ entry_path = entry["path"]
361
+ entry["name"]
362
+ entry_type = entry["type"]
363
+
364
+ if entry_type == "blob": # File
365
+ # Construct raw file URL
366
+ project_id = f"{owner}%2F{repo}"
367
+ file_path_encoded = urlparse.quote(entry_path, safe="")
368
+ download_url = (
369
+ f"https://gitlab.com/api/v4/projects/{project_id}/repository/files/{file_path_encoded}/raw?ref={ref}"
370
+ )
371
+
372
+ # Calculate relative path from base path
373
+ rel_path = Path(entry_path).relative_to(path) if path else Path(entry_path)
374
+ dest_file = dest_dir / rel_path
375
+ _download_file(download_url, dest_file, token, "gitlab")
376
+ elif entry_type == "tree": # Directory
377
+ rel_path = Path(entry_path).relative_to(path) if path else Path(entry_path)
378
+ subdir = dest_dir / rel_path
379
+ subdir.mkdir(parents=True, exist_ok=True)
380
+ _fetch_gitlab_recursive(owner, repo, entry_path, ref, subdir, token)
381
+
382
+
383
+ def fetch_remote_to_temp(url: str) -> Path:
384
+ """Fetch a remote skill to a temporary directory.
385
+
386
+ Args:
387
+ url: Remote skill URL
388
+
389
+ Returns:
390
+ Path to temporary directory containing fetched skill
391
+
392
+ Raises:
393
+ InvalidRemoteUrlError: If URL is invalid
394
+ RemoteSkillNotFoundError: If skill not found
395
+ RemoteNetworkError: On network errors
396
+ """
397
+ # Parse URL
398
+ github_parsed = parse_github_url(url)
399
+ gitlab_parsed = parse_gitlab_url(url)
400
+
401
+ if github_parsed:
402
+ parsed = github_parsed
403
+ platform = "github"
404
+ elif gitlab_parsed:
405
+ parsed = gitlab_parsed
406
+ platform = "gitlab"
407
+ else:
408
+ raise InvalidRemoteUrlError(f"Invalid URL: {url}")
409
+
410
+ owner = parsed["owner"]
411
+ repo = parsed["repo"]
412
+ ref = parsed["ref"]
413
+ path = parsed["path"]
414
+ token = _get_auth_token(platform)
415
+
416
+ # Create temporary directory
417
+ temp_dir = Path(tempfile.mkdtemp(prefix="asr-remote-"))
418
+
419
+ try:
420
+ if platform == "github":
421
+ _fetch_github_recursive(owner, repo, path, ref, temp_dir, token)
422
+ elif platform == "gitlab":
423
+ _fetch_gitlab_recursive(owner, repo, path, ref, temp_dir, token)
424
+
425
+ return temp_dir
426
+ except Exception:
427
+ # Clean up on error
428
+ shutil.rmtree(temp_dir, ignore_errors=True)
429
+ raise
430
+
431
+
432
+ def check_remote_reachability(url: str) -> tuple[bool, int, str]:
433
+ """Check if a remote URL is reachable.
434
+
435
+ Args:
436
+ url: Remote URL to check
437
+
438
+ Returns:
439
+ Tuple of (is_reachable, status_code, message)
440
+ """
441
+ # Parse URL to get API endpoint for the specific path
442
+ github_parsed = parse_github_url(url)
443
+ gitlab_parsed = parse_gitlab_url(url)
444
+
445
+ if github_parsed:
446
+ parsed = github_parsed
447
+ # Check the specific path, not just the repo
448
+ path = parsed["path"] or ""
449
+ api_url = f"https://api.github.com/repos/{parsed['owner']}/{parsed['repo']}/contents/{path}"
450
+ if parsed["ref"]:
451
+ api_url += f"?ref={parsed['ref']}"
452
+ token = _get_auth_token("github")
453
+ headers = {}
454
+ if token:
455
+ headers["Authorization"] = f"token {token}"
456
+ elif gitlab_parsed:
457
+ parsed = gitlab_parsed
458
+ project_id = f"{parsed['owner']}%2F{parsed['repo']}"
459
+ path = parsed["path"] or ""
460
+ # Check the specific path, not just the project
461
+ api_url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/tree?path={path}"
462
+ if parsed["ref"]:
463
+ api_url += f"&ref={parsed['ref']}"
464
+ token = _get_auth_token("gitlab")
465
+ headers = {}
466
+ if token:
467
+ headers["PRIVATE-TOKEN"] = token
468
+ else:
469
+ return False, 0, "Invalid URL format"
470
+
471
+ req = request.Request(api_url, headers=headers, method="HEAD")
472
+
473
+ try:
474
+ with request.urlopen(req) as response:
475
+ return True, response.status, "Reachable"
476
+ except urlerror.HTTPError as e:
477
+ if e.code in (404, 410):
478
+ return False, e.code, f"Not found (HTTP {e.code})"
479
+ else:
480
+ return False, e.code, f"HTTP error: {e.reason}"
481
+ except urlerror.URLError as e:
482
+ return False, 0, f"Network error: {e.reason}"