Skip to content

Commit 9a67a2b

Browse files
xsh310Isaac
andauthored
Extract shared skill-download core and add selective download (#578)
## What PR C of the `ug skill add` interactive-picker stack. It reshapes `skills_download.py` so the download mechanics become one reusable core, and adds the (still unwired) functions the picker will call in PR D. **No behavior changes on any existing path.** Design doc: `ug_skill_add_download_interactive_picker_design.md` (PR table, row C). ## Changes - **`SkillRef` carries its full location.** New `catalog`/`schema` fields plus an `fqn` property, parsed once from the API `name` in `_skill_ref`, so a ref is self-contained and can be downloaded on its own. - **Extract `_download_refs(refs, roots) -> (written, total)`**, the shared core: collision dedup, overwrite prompt, parallel fetch, write. `should_download_skill` and `_reject_bundle_name_collisions` now read the source off the ref (`ref.fqn`) instead of an explicit `location` arg, which produces byte-identical messages and lets the core serve a flat, cross-schema set. - **`_fetch_bundles` keys results by FQN** (a securable name is unique only within its schema) and takes its progress-bar label from the caller. - **Rename `download_skills` to `download_skills_from_schema_locations`**, now calling `_download_refs` per location and keeping the exact per-location `Downloaded k/n from <location>` summary. - **Add `get_skill(fqn)`** (GetSkill at `/2.1/unity-catalog/skills/<fqn>`, reusing `_skill_ref`; NOT_FOUND to `None`) and **`download_selected_skills(fqns, path)`** (one `_download_refs` pass over the flat selection). Both are dead code until PR D wires the picker. ## Deviations from the design doc - `download_managed_skills_on_launch` is a **second caller of `_fetch_bundles`** the doc did not mention; it is updated to the new keying (behavior-preserving). - The doc lists `should_download_skill` / `_reject_bundle_name_collisions` as reused unchanged, but making the core location-agnostic means they read the source off the ref. The emitted messages are identical (`<catalog>.<schema>.<securable>` equals `ref.fqn`). ## Testing `uv run ruff check .`, `ruff format --check src tests`, and `uv run pytest` all pass (the 2 pre-existing e2e failures in `test_e2e_user_agent` and `test_claude_smart_routing_v2` are unrelated and also fail on the base branch). New unit tests cover `_download_refs` (including cross-schema dedup), `get_skill` (NOT_FOUND to `None`, unfinalized to `None`), and `download_selected_skills`. Stacked on #574 (`skills-picker-walk`). This pull request and its description were written by Isaac. --------- Co-authored-by: Isaac <no-reply@databricks.com>
1 parent a03df6d commit 9a67a2b

2 files changed

Lines changed: 364 additions & 102 deletions

File tree

src/ucode/skills_download.py

Lines changed: 136 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,37 @@
3838

3939
@dataclass(frozen=True)
4040
class SkillRef:
41-
"""A downloadable skill's two names, which are not interchangeable.
42-
43-
``securable_name`` is the UC leaf of ``skills/<cat>.<sch>.<leaf>`` and is the
44-
only name the Files API resolves, so it addresses the bytes and identifies the
45-
skill. ``bundle_name`` is the ``name:`` an agent reads from the bundle's
46-
SKILL.md frontmatter, so it names the on-disk directory. Finalize does not
47-
require the two to match, so a skill created under a securable that differs
48-
from its frontmatter carries both.
41+
"""A downloadable skill's UC location plus its two non-interchangeable names.
42+
43+
``catalog``/``schema``/``securable_name`` are the parts of ``skills/<cat>.<sch>.<leaf>``:
44+
``securable_name`` is the leaf, the only name the Files API resolves, and the
45+
three together fully qualify the skill (``fqn``). ``bundle_name`` is the
46+
``name:`` an agent reads from the bundle's SKILL.md frontmatter, so it names
47+
the on-disk directory. Finalize does not require the securable and bundle name
48+
to match, so a skill created under a securable that differs from its
49+
frontmatter carries both.
4950
"""
5051

52+
catalog: str
53+
schema: str
5154
securable_name: str
5255
bundle_name: str
5356

57+
@property
58+
def fqn(self) -> str:
59+
return f"{self.catalog}.{self.schema}.{self.securable_name}"
60+
5461

5562
def _non_empty_str(value: object) -> str | None:
5663
"""``value`` when it is a non-empty string, else None."""
5764
return value if isinstance(value, str) and value else None
5865

5966

67+
def _is_safe_bundle_name(bundle_name: str) -> bool:
68+
path = Path(bundle_name)
69+
return len(path.parts) == 1 and path.parts[0] != ".." and not path.is_absolute()
70+
71+
6072
def _skill_ref(skill: dict) -> SkillRef | None:
6173
"""A finalized skill's ``SkillRef``, or None if it cannot be downloaded.
6274
@@ -86,7 +98,18 @@ def _skill_ref(skill: dict) -> SkillRef | None:
8698
)
8799
return None
88100

89-
return SkillRef(securable_name=name.rsplit(".", 1)[-1], bundle_name=bundle_name)
101+
if not _is_safe_bundle_name(bundle_name):
102+
print_warning(f"Skipping `{name}`: unsafe bundle name `{bundle_name}`.")
103+
return None
104+
105+
parts = name.split("/", 1)[-1].split(".")
106+
if len(parts) != 3:
107+
print_warning(f"Skipping `{name}`: expected a `catalog.schema.name` skill name.")
108+
return None
109+
catalog, schema, securable_name = parts
110+
return SkillRef(
111+
catalog=catalog, schema=schema, securable_name=securable_name, bundle_name=bundle_name
112+
)
90113

91114

92115
def list_schema_skills(
@@ -241,20 +264,18 @@ def existing_skill_on_disk(roots: list[Path], bundle_name: str) -> bool:
241264
return any((root / bundle_name).exists() for root in roots)
242265

243266

244-
def should_download_skill(roots: list[Path], ref: SkillRef, *, location: str) -> bool:
267+
def should_download_skill(roots: list[Path], ref: SkillRef) -> bool:
245268
"""Whether ``ref`` should be fetched and written into ``roots``.
246269
247270
Applies the disk-only check that needs no bundle bytes: prompts before
248-
overwriting a skill already on disk (``location`` is the source
249-
``<catalog>.<schema>`` shown in that prompt), so a declined skill is never
250-
fetched. Dedup keys on the bundle name, since that is the directory an agent
251-
would load. Name validity is the server's job -- FinalizeSkill enforces the
252-
Agent Skills naming rules on ``bundle_name`` before we ever see it -- so
253-
ucode does not re-check it here.
271+
overwriting a skill already on disk (naming the source by ``ref.fqn``), so a
272+
declined skill is never fetched. Dedup keys on the bundle name, since that is
273+
the directory an agent would load. Name validity is the server's job --
274+
FinalizeSkill enforces the Agent Skills naming rules on ``bundle_name`` before
275+
we ever see it -- so ucode does not re-check it here.
254276
"""
255277
if existing_skill_on_disk(roots, ref.bundle_name) and not prompt_yes_no(
256-
f"A skill named `{ref.bundle_name}` already exists. "
257-
f"Overwrite it with `{location}.{ref.securable_name}`?"
278+
f"A skill named `{ref.bundle_name}` already exists. Overwrite it with `{ref.fqn}`?"
258279
):
259280
print_note(f"Kept existing `{ref.bundle_name}`.")
260281
return False
@@ -276,23 +297,25 @@ def write_skill(roots: list[Path], ref: SkillRef, files: dict[str, bytes]) -> No
276297

277298

278299
def _fetch_bundles(
279-
workspace: str, token: str, catalog: str, schema: str, refs: list[SkillRef]
300+
workspace: str, token: str, refs: list[SkillRef], *, label: str
280301
) -> dict[str, tuple[dict[str, bytes] | None, str | None]]:
281-
"""Fetch every skill's bundle concurrently, keyed by securable leaf.
302+
"""Fetch every skill's bundle concurrently, keyed by FQN.
282303
283-
Renders a ``k/n`` progress bar that advances as each fetch completes.
304+
Renders a ``k/n`` progress bar labeled ``label`` that advances as each fetch
305+
completes. Keying on the FQN keeps a cross-schema batch's securables apart,
306+
since a securable name is unique only within its own schema.
284307
"""
285308
if not refs:
286309
return {}
287310
results: dict[str, tuple[dict[str, bytes] | None, str | None]] = {}
288311
with (
289-
progress_bar(f"Fetching skills from {catalog}.{schema}", len(refs)) as advance,
312+
progress_bar(label, len(refs)) as advance,
290313
ThreadPoolExecutor(max_workers=min(_MAX_FETCH_WORKERS, len(refs))) as pool,
291314
):
292315
futures = {
293316
pool.submit(
294-
fetch_skill_bundle, workspace, token, catalog, schema, ref.securable_name
295-
): ref.securable_name
317+
fetch_skill_bundle, workspace, token, ref.catalog, ref.schema, ref.securable_name
318+
): ref.fqn
296319
for ref in refs
297320
}
298321
for future in as_completed(futures):
@@ -301,32 +324,58 @@ def _fetch_bundles(
301324
return results
302325

303326

304-
def _reject_bundle_name_collisions(refs: list[SkillRef], *, location: str) -> list[SkillRef]:
327+
def _reject_bundle_name_collisions(refs: list[SkillRef]) -> list[SkillRef]:
305328
"""``refs`` with any later skill that repeats an earlier one's bundle name dropped.
306329
307330
Only the securable name is unique within a schema; ``bundle_name`` comes from
308331
each bundle's SKILL.md frontmatter and is never checked against its siblings,
309-
so one schema can hold two skills claiming the same directory. Writing both
310-
would land them on top of each other, leaving whichever finished last with no
311-
sign the other was lost, so keep the first and warn about the rest.
332+
so two skills can claim the same directory. Writing both would land them on top
333+
of each other, leaving whichever finished last with no sign the other was lost,
334+
so keep the first and warn about the rest by FQN.
312335
"""
313336
kept: list[SkillRef] = []
314-
claimed: dict[str, str] = {}
337+
claimed: dict[str, SkillRef] = {}
315338
for ref in refs:
316339
winner = claimed.get(ref.bundle_name)
317340
if winner is not None:
318341
print_warning(
319-
f"Skipping `{location}.{ref.securable_name}`: its bundle name "
320-
f"`{ref.bundle_name}` is already claimed by `{location}.{winner}`. "
321-
"Rename one skill's SKILL.md `name:` to download both."
342+
f"Skipping `{ref.fqn}`: its bundle name `{ref.bundle_name}` is already "
343+
f"claimed by `{winner.fqn}`. Rename one skill's SKILL.md `name:` to download both."
322344
)
323345
continue
324-
claimed[ref.bundle_name] = ref.securable_name
346+
claimed[ref.bundle_name] = ref
325347
kept.append(ref)
326348
return kept
327349

328350

329-
def download_skills(
351+
def _download_refs(
352+
workspace: str, token: str, refs: list[SkillRef], roots: list[Path], *, label: str
353+
) -> tuple[int, int]:
354+
"""Fetch and write ``refs`` into ``roots``, returning ``(written, total)``.
355+
356+
The shared download core: drop siblings claiming one directory
357+
(``_reject_bundle_name_collisions``), prompt before overwriting a skill already
358+
on disk (``should_download_skill``, so a declined skill is never fetched), then
359+
fetch the survivors' bundles concurrently and write them. ``total`` is the
360+
count that could reach disk (dropped siblings excluded), so a caller's summary
361+
denominator is right. A per-skill fetch failure warns and skips only that skill.
362+
"""
363+
refs = _reject_bundle_name_collisions(refs)
364+
to_download = [ref for ref in refs if should_download_skill(roots, ref)]
365+
bundles = _fetch_bundles(workspace, token, to_download, label=label)
366+
written = 0
367+
for ref in to_download:
368+
files, reason = bundles[ref.fqn]
369+
if reason or files is None:
370+
print_warning(f"Skipping `{ref.fqn}`: {reason}.")
371+
continue
372+
write_skill(roots, ref, files)
373+
written += 1
374+
console.print()
375+
return written, len(refs)
376+
377+
378+
def download_skills_from_schema_locations(
330379
workspace: str,
331380
token: str,
332381
locations: list[str],
@@ -335,22 +384,13 @@ def download_skills(
335384
) -> None:
336385
"""Download every skill in each ``<catalog>.<schema>`` location to disk.
337386
338-
Locations are processed one at a time, and each runs three stages:
339-
340-
1. **List** the schema's finalized skills. When ``skills`` is given, restrict
341-
to those securable names (the name that identifies a skill in UC); names
342-
absent from the schema warn and are skipped, and ``None`` keeps the whole
343-
schema. Siblings claiming one directory are then reduced to the first (see
344-
``_reject_bundle_name_collisions``).
345-
2. **Decide** which to download via ``should_download_skill`` (prompts before
346-
overwriting a skill already on disk), so a declined skill is never fetched.
347-
3. **Fetch** the survivors' bundles concurrently (with a progress bar) and
348-
**write** them.
349-
350-
Finishing one location before starting the next means a skill written for an
351-
earlier location is already on disk when a same-named skill in a later
352-
location reaches its decide stage, so the overwrite prompt still fires. A
353-
failure on one skill warns and skips it without aborting the batch.
387+
Locations are processed one at a time. Each lists the schema's finalized
388+
skills, applies the optional ``skills`` filter (securable names -- the name
389+
that identifies a skill in UC; unknown ones warn, ``None`` keeps the whole
390+
schema), then hands the refs to ``_download_refs`` and prints a per-location
391+
summary. Finishing one location before the next means a skill written for an
392+
earlier location is already on disk when a same-named skill in a later location
393+
reaches the overwrite prompt, so the prompt still fires.
354394
"""
355395
roots = skill_dir_roots(path)
356396
roots_display = " and ".join(str(root) for root in roots)
@@ -374,28 +414,50 @@ def download_skills(
374414
if not refs:
375415
print_note(f"No skills found in `{location}`.")
376416
continue
377-
# Before the decide stage, so a dropped sibling is never fetched and the
378-
# summary's denominator counts only skills that can reach disk.
379-
refs = _reject_bundle_name_collisions(refs, location=location)
380-
381-
to_download = [ref for ref in refs if should_download_skill(roots, ref, location=location)]
382-
bundles = _fetch_bundles(workspace, token, catalog, schema, to_download)
383-
written = 0
384-
for ref in to_download:
385-
files, reason = bundles[ref.securable_name]
386-
if reason or files is None:
387-
print_warning(f"Skipping `{location}.{ref.securable_name}`: {reason}.")
388-
continue
389-
write_skill(roots, ref, files)
390-
written += 1
391-
console.print()
392-
total = len(refs)
417+
written, total = _download_refs(
418+
workspace, token, refs, roots, label=f"Fetching skills from {location}"
419+
)
393420
skipped = f"; {total - written} skipped" if written < total else ""
394421
print_success(
395422
f"Downloaded {written}/{total} skill(s){skipped} from `{location}` in {roots_display}."
396423
)
397424

398425

426+
def get_skill(workspace: str, token: str, fqn: str) -> SkillRef | None:
427+
"""The finalized skill named by ``fqn``, or None if it cannot be downloaded.
428+
429+
``GetSkill`` returns the same shape as a ``ListSkills`` entry, so the response
430+
runs through ``_skill_ref``; a missing, unfinalized, or malformed skill is None.
431+
"""
432+
hostname = workspace_hostname(workspace)
433+
payload, _ = _http_get_json(
434+
f"https://{hostname}/api/2.1/unity-catalog/skills/{fqn}", token, timeout=30
435+
)
436+
return _skill_ref(payload) if isinstance(payload, dict) else None
437+
438+
439+
def download_selected_skills(workspace: str, token: str, fqns: list[str], path: str | None) -> None:
440+
"""Download the skills named by ``fqns`` (``<catalog>.<schema>.<name>``) to disk.
441+
442+
Resolves each FQN with ``GetSkill`` (a skill that cannot be downloaded warns and
443+
is skipped), then hands the flat, possibly cross-schema set to ``_download_refs``
444+
in one pass, so collisions are deduped across the whole selection under a single
445+
summary.
446+
"""
447+
roots = skill_dir_roots(path)
448+
roots_display = " and ".join(str(root) for root in roots)
449+
refs: list[SkillRef] = []
450+
for fqn in fqns:
451+
ref = get_skill(workspace, token, fqn)
452+
if ref is None:
453+
print_warning(f"Skipping `{fqn}`: not a downloadable skill.")
454+
continue
455+
refs.append(ref)
456+
written, total = _download_refs(workspace, token, refs, roots, label="Fetching selected skills")
457+
skipped = f"; {total - written} skipped" if written < total else ""
458+
print_success(f"Downloaded {written}/{total} skill(s){skipped} in {roots_display}.")
459+
460+
399461
def download_managed_skills_on_launch(
400462
workspace: str, token: str, locations: list[str], path: str | None = None
401463
) -> list[str]:
@@ -418,15 +480,17 @@ def download_managed_skills_on_launch(
418480
if reason:
419481
print_warning(f"Could not list workspace skills in `{location}`: {reason}.")
420482
continue
421-
refs = _reject_bundle_name_collisions(refs, location=location)
483+
refs = _reject_bundle_name_collisions(refs)
422484
missing = [ref for ref in refs if not existing_skill_on_disk(roots, ref.bundle_name)]
423485
if not missing:
424486
continue
425-
bundles = _fetch_bundles(workspace, token, catalog, schema, missing)
487+
bundles = _fetch_bundles(
488+
workspace, token, missing, label=f"Fetching skills from {location}"
489+
)
426490
for ref in missing:
427-
files, reason = bundles[ref.securable_name]
491+
files, reason = bundles[ref.fqn]
428492
if reason or files is None:
429-
print_warning(f"Skipping `{location}.{ref.securable_name}`: {reason}.")
493+
print_warning(f"Skipping `{ref.fqn}`: {reason}.")
430494
continue
431495
write_skill(roots, ref, files)
432496
written.append(ref.bundle_name)
@@ -441,12 +505,12 @@ def configure_skills_download_command(
441505
Downloads to ``path`` (or the home dir when None), then registers/keeps the
442506
schema-less MCP connection. ``skill_locations`` is never touched, so a prior
443507
``--mcp`` set survives a download run. ``skills`` narrows the download (see
444-
``download_skills``)."""
508+
``download_skills_from_schema_locations``)."""
445509
state = load_state()
446510
workspace, profile, clients = setup_mcp_clients(state, "Skills")
447511
token = get_databricks_token(workspace, profile)
448512

449-
download_skills(workspace, token, locations, path, skills)
513+
download_skills_from_schema_locations(workspace, token, locations, path, skills)
450514

451515
register_schemaless_skills_connection(state, workspace, profile, clients)
452516
return 0

0 commit comments

Comments
 (0)