Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.3.0"
version = "0.3.1"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand All @@ -12,6 +12,7 @@ dependencies = [
"uipath-core>=0.5.34, <0.6.0",
"pydantic-function-models>=0.1.11",
"sqlparse>=0.6.0, <0.7.0",
"pyyaml>=6.0",
]
classifiers = [
"Intended Audience :: Developers",
Expand Down Expand Up @@ -48,6 +49,7 @@ dev = [
"pytest-cov>=4.1.0",
"pytest-mock>=3.11.1",
"pre-commit>=4.1.0",
"types-PyYAML>=6.0",
]

[tool.hatch.build.targets.wheel]
Expand Down
5 changes: 5 additions & 0 deletions packages/uipath-platform/src/uipath/platform/_uipath.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .pii_detection import PiiDetectionService
from .resource_catalog import ResourceCatalogService
from .semantic_proxy import SemanticProxyService
from .skills import SkillsService


def _has_valid_client_credentials(
Expand Down Expand Up @@ -203,6 +204,10 @@ def pii_detection(self) -> PiiDetectionService:
def semantic_proxy(self) -> SemanticProxyService:
return SemanticProxyService(self._config, self._execution_context)

@cached_property
def skills(self) -> SkillsService:
return SkillsService(self._config, self._execution_context)

@property
def automation_tracker(self) -> AutomationTrackerService:
return AutomationTrackerService(self._config, self._execution_context)
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ResourceOverwrite,
ResourceOverwriteParser,
ResourceOverwritesContext,
SkillBundleResourceOverwrite,
resource_override,
)
from ._config import UiPathApiConfig, UiPathConfig, resolve_coded_agenthub_config
Expand Down Expand Up @@ -132,6 +133,7 @@
"ResourceOverwrite",
"ResourceOverwriteParser",
"ResourceOverwritesContext",
"SkillBundleResourceOverwrite",
"ReferenceEntry",
"ReferenceContext",
"ReferenceContextAccessor",
Expand Down
59 changes: 58 additions & 1 deletion packages/uipath-platform/src/uipath/platform/common/_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigDict,
Field,
TypeAdapter,
field_validator,
model_validator,
)

Expand Down Expand Up @@ -121,9 +122,65 @@ def folder_identifier(self) -> str:
return self.folder_key


class SkillBundleResourceOverwrite(ResourceOverwrite):
"""A deploy-time override of an agent's skill bundle binding.

Like the other bindings, an entry replaces the whole binding: it carries
``name``, ``folderPath`` and ``versionMode``, plus ``version`` when pinned.
Its ``name`` retargets the bundle that is looked up; the agent-side name of
the binding stays what the agent declared. Apply it with
``uipath.platform.skills._overwrites.apply_skill_bundle_overwrite``.
"""

resource_type: Literal["skillBundle"]
name: str = Field(alias="name")
folder_path: str = Field(alias="folderPath")
# Keep in sync with uipath.platform.skills.VersionMode.
version_mode: Literal["pinned", "latest"] = Field(alias="versionMode")
version: Optional[str] = Field(default=None, alias="version")

model_config = ConfigDict(populate_by_name=True, extra="ignore")

@field_validator("name", "folder_path")
@classmethod
def _not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError(
"a skillBundle overwrite needs a non-blank name and folderPath"
)
return value

@field_validator("version", mode="before")
@classmethod
def _blank_is_unset(cls, value: Any) -> Any:
# A cleared form field arrives as "", which is no version.
if isinstance(value, str) and not value.strip():
return None
return value

@model_validator(mode="after")
def _version_matches_mode(self) -> "SkillBundleResourceOverwrite":
if self.version_mode == "latest":
self.version = None
elif not self.version:
raise ValueError("a pinned skillBundle overwrite needs a version")
return self

@property
def resource_identifier(self) -> str:
return self.name

@property
def folder_identifier(self) -> str:
return self.folder_path


ResourceOverwriteUnion = Annotated[
Union[
GenericResourceOverwrite, EntityResourceOverwrite, ConnectionResourceOverwrite
GenericResourceOverwrite,
EntityResourceOverwrite,
ConnectionResourceOverwrite,
SkillBundleResourceOverwrite,
],
Field(discriminator="resource_type"),
]
Expand Down
38 changes: 38 additions & 0 deletions packages/uipath-platform/src/uipath/platform/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Skill bundles for coded agents: references, resolved bundles, the manifest, and the service that loads them."""

from ._constants import MANIFEST_FILE_NAME
from ._skills_service import SkillBundleLoadError, SkillsService
from ._sources import (
BundleSourceError,
LocalFolderBundleSource,
OrchestratorBundleSource,
SkillBundleSource,
)
from .skills import (
BundleFailure,
FailureReason,
InvokedBy,
Skill,
SkillBundle,
SkillBundleRef,
SkillSet,
VersionMode,
)

__all__ = [
"BundleFailure",
"BundleSourceError",
"FailureReason",
"InvokedBy",
"LocalFolderBundleSource",
"MANIFEST_FILE_NAME",
"OrchestratorBundleSource",
"Skill",
"SkillBundle",
"SkillBundleLoadError",
"SkillBundleRef",
"SkillBundleSource",
"SkillSet",
"SkillsService",
"VersionMode",
]
29 changes: 29 additions & 0 deletions packages/uipath-platform/src/uipath/platform/skills/_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Names shared by the skills package and its call sites."""

SKILLS_DIR_NAME = "skills"
"""Folder that holds skill bundles, in a project and in an agent's virtual filesystem."""

MANIFEST_FILE_NAME = ".uipath-skills-manifest.json"

OVERWRITE_TYPE = "skillBundle"
"""Prefix of a bundle entry in ``resourceOverwrites`` (``skillBundle.<name>``)."""

BUNDLE_FILE_NAME = "bundle.json"
SKILL_FILE_NAME = "SKILL.md"
MAX_FILE_BYTES = 512 * 1024
MAX_BUNDLE_BYTES = 24 * 1024 * 1024
BUNDLE_RESOLVE_TIMEOUT_SECONDS = 20.0
"""Per-bundle wall clock for resolving skills at agent start.

After the first timeout the remaining bundles are skipped. The local source
copies on a worker thread, so it times out too, stopping before its next file.
"""

LOCAL_VERSION = "local"
"""The version of a bundle read from a folder without a ``bundle.json`` version."""

DEFAULT_SOURCE_KIND = "local"
"""The source used when nothing selects one: bundles read from a local folder."""

ENV_SKILLS_SOURCE = "UIPATH_SKILLS_SOURCE"
ENV_SKILLS_LOCAL_ROOT = "UIPATH_SKILLS_LOCAL_ROOT"
126 changes: 126 additions & 0 deletions packages/uipath-platform/src/uipath/platform/skills/_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""The on-disk bundle layout, ``<bundle>/<skill>/SKILL.md`` plus an optional ``bundle.json``."""

from __future__ import annotations

import json
import logging
from pathlib import Path

from ._constants import BUNDLE_FILE_NAME, LOCAL_VERSION, SKILL_FILE_NAME

logger = logging.getLogger(__name__)


def read_bundle_version(bundle_dir: Path) -> str:
"""The ``version`` from ``bundle.json``, as a string.

Args:
bundle_dir: The bundle folder.

Returns:
The version, or ``LOCAL_VERSION`` when ``bundle.json`` is missing,
unreadable, not a JSON object, or has an empty or null ``version``.
"""
try:
data = json.loads((bundle_dir / BUNDLE_FILE_NAME).read_text(encoding="utf-8"))
except FileNotFoundError:
return LOCAL_VERSION
except (ValueError, OSError) as e:
logger.warning(
"Ignoring unreadable %s in %s: %s", BUNDLE_FILE_NAME, bundle_dir, e
)
return LOCAL_VERSION
if not isinstance(data, dict):
logger.warning(
"Ignoring %s in %s: not a JSON object", BUNDLE_FILE_NAME, bundle_dir
)
return LOCAL_VERSION
version = data.get("version")
if version is None or version == "":
return LOCAL_VERSION
return str(version)


def is_plain_name(name: str) -> bool:
r"""Whether ``name`` is one visible path segment.

Args:
name: A file or folder name, or one segment of a relative path.

Returns:
``False`` when it is empty, hidden (which covers ``.`` and ``..``), or
contains a separator, a drive colon or a NUL byte (``/``, ``\``, ``:``,
``\0``). A NUL is refused because ``Path.resolve`` raises ``ValueError``
for it on Windows, which no caller expects from a path.
"""
return (
bool(name)
and not name.startswith(".")
and not any(c in name for c in "/\\:\x00")
)


def is_skill_md(path: str) -> bool:
"""Whether ``path``, relative to a skill folder, names its ``SKILL.md`` in any case.

Args:
path: A file path relative to the skill folder, in POSIX form.

Returns:
``True`` for ``SKILL.md``, ``skill.md``, ``Skill.md`` and so on, at the
top of the skill folder only.
"""
return path.casefold() == SKILL_FILE_NAME.casefold()


def find_skill_md(path: Path) -> Path | None:
"""The ``SKILL.md`` of a skill folder, matched case-insensitively.

A skill folder is a plain-named folder holding ``SKILL.md`` in any case
(``skill.md`` counts). On a case-sensitive file system it can hold several
spellings; ``SKILL.md`` itself wins, then the first in code-point order.

Args:
path: A child of a bundle folder.

Returns:
The file, or ``None`` when ``path`` is not a skill folder.
"""
if not path.is_dir() or not is_plain_name(path.name):
return None
matches = [p for p in path.iterdir() if is_skill_md(p.name) and p.is_file()]
return min(matches, key=lambda p: (p.name != SKILL_FILE_NAME, p.name), default=None)


def skill_md_first(path: str) -> tuple[bool, str]:
"""Sort key that puts ``SKILL.md``, in any case, before the other files of a skill.

Args:
path: A file path relative to the skill folder, in POSIX form.

Returns:
A key that orders ``SKILL.md`` first and the rest alphabetically.
"""
return (not is_skill_md(path), path)


def iter_skill_files(skill_dir: Path) -> list[str]:
"""The files of a skill, relative to ``skill_dir`` in POSIX form.

Args:
skill_dir: A skill folder.

Returns:
Sorted paths with ``SKILL.md`` first; a path with any component that is
not a plain name (hidden files and folders, for instance) is left out.
"""
return sorted(
(
rel.as_posix()
for rel in (
p.relative_to(skill_dir) for p in skill_dir.rglob("*") if p.is_file()
)
if all(is_plain_name(part) for part in rel.parts)
),
key=skill_md_first,
)
Loading
Loading