diff --git a/packages/uipath/docs/core/agents.md b/packages/uipath/docs/core/agents.md index 21b812647..5621d864e 100644 --- a/packages/uipath/docs/core/agents.md +++ b/packages/uipath/docs/core/agents.md @@ -119,7 +119,7 @@ version = "0.1.0" description = "..." authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.11" -dependencies = ["uipath>=2.0", "uipath-langchain>=2.0"] +dependencies = ["uipath>=2.0", "uipath-langchain>=0.16"] ``` Standard metadata plus the framework dependency (`uipath-langchain` here). The framework graph file and this dependency identify the project as a coded agent — `pyproject.toml` needs no UiPath-specific entries, and `uipath.json` carries no agent entry. diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 51e7e3431..ac00d3736 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.8" +version = "2.14.9" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -81,6 +81,7 @@ dev = [ "types-toml>=0.10.8", "types-PyYAML>=6.0", "pytest-timeout>=2.4.0", + "packaging>=24.0", "uipath-ipc>=2.5.1,<2.6.0", ] diff --git a/packages/uipath/src/uipath/_cli/cli_new.py b/packages/uipath/src/uipath/_cli/cli_new.py index 0a7723d05..692eee452 100644 --- a/packages/uipath/src/uipath/_cli/cli_new.py +++ b/packages/uipath/src/uipath/_cli/cli_new.py @@ -1,6 +1,8 @@ import json import os +import re import shutil +import uuid import click @@ -8,10 +10,39 @@ from ._telemetry import track_command from ._utils._console import ConsoleLogger +from ._utils._project_files import resolve_existing_project_id from .middlewares import Middlewares console = ConsoleLogger() +FALLBACK_UIPATH_MINOR = "2.14" + + +def _minor_range_spec(major: int, minor: int) -> str: + return f"uipath>={major}.{minor}.0, <{major}.{minor + 1}.0" + + +def _fallback_uipath_dependency_spec() -> str: + major, minor = (int(part) for part in FALLBACK_UIPATH_MINOR.split(".")) + return _minor_range_spec(major, minor) + + +def _uipath_dependency_spec() -> str: + from . import _get_safe_version + + installed = _get_safe_version() + # Strip pre-release/dev/local suffixes: only the leading "major.minor" matters. + match = re.match(r"^(\d+)\.(\d+)", installed) + if match is None: + fallback = _fallback_uipath_dependency_spec() + console.warning( + f"Could not determine the installed 'uipath' version ('{installed}'); " + f"falling back to '{fallback}'. Pin it manually if needed." + ) + return fallback + + return _minor_range_spec(int(match.group(1)), int(match.group(2))) + def generate_script(target_directory): template_path = os.path.join( @@ -30,7 +61,7 @@ def generate_pyproject(target_directory, project_name): description = "{project_name}" authors = [{{ name = "John Doe", email = "john.doe@myemail.com" }}] dependencies = [ - "uipath>=2.10.0, <2.11.0" + "{_uipath_dependency_spec()}" ] requires-python = ">=3.11" """ @@ -41,7 +72,8 @@ def generate_pyproject(target_directory, project_name): def generate_uipath_json(target_directory): uipath_json_path = os.path.join(target_directory, UIPATH_CONFIG_FILE) - uipath_config = {"functions": {"main": "main.py:main"}} + project_id = resolve_existing_project_id(target_directory) or str(uuid.uuid4()) + uipath_config = {"id": project_id, "functions": {"main": "main.py:main"}} with open(uipath_json_path, "w") as f: json.dump(uipath_config, f, indent=2) @@ -81,7 +113,7 @@ def new(name: str): console.success(f"Created '{UIPATH_CONFIG_FILE}' file.") init_command = """uipath init""" run_command = """uipath run main '{"message": "Hello World!"}'""" - console.hint(f""" Initialize project: {click.style(init_command, fg="cyan")}""") + console.hint(f"""Initialize project: {click.style(init_command, fg="cyan")}""") console.hint(f"""Run project: {click.style(run_command, fg="cyan")}""") diff --git a/packages/uipath/tests/cli/test_new.py b/packages/uipath/tests/cli/test_new.py index 41941bcee..02ce357d7 100644 --- a/packages/uipath/tests/cli/test_new.py +++ b/packages/uipath/tests/cli/test_new.py @@ -1,7 +1,12 @@ +import json import os +import re +import uuid +from importlib.metadata import version from unittest.mock import patch from click.testing import CliRunner +from packaging.specifiers import SpecifierSet from uipath._cli import cli from uipath._cli.middlewares import MiddlewareResult @@ -17,6 +22,18 @@ def test_new_project_creation(self, runner: CliRunner, temp_dir: str) -> None: assert os.path.exists("main.py") assert os.path.exists("pyproject.toml") + def test_new_project_writes_uipath_json_id( + self, runner: CliRunner, temp_dir: str + ) -> None: + """uipath.json gets a GUID id up front so later commands don't warn.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + result = runner.invoke(cli, ["new", "my_project"]) + assert result.exit_code == 0 + with open("uipath.json") as f: + config = json.load(f) + uuid.UUID(config["id"]) + assert config["functions"] == {"main": "main.py:main"} + def test_new_project_without_name(self, runner: CliRunner, temp_dir: str) -> None: """Test creating a new project without specifying a name.""" with runner.isolated_filesystem(temp_dir=temp_dir): @@ -79,3 +96,72 @@ def test_new_project_error_handling(self, runner: CliRunner, temp_dir: str) -> N result = runner.invoke(cli, ["new", "my_project"]) assert result.exit_code == 1 assert "Created 'main.py' file." not in result.output + + +class TestUipathDependencySpec: + """The scaffolded pin must follow the installed uipath version.""" + + def _written_pin(self, temp_dir: str) -> str: + from uipath._cli.cli_new import generate_pyproject + + generate_pyproject(temp_dir, "demo") + with open(os.path.join(temp_dir, "pyproject.toml")) as f: + content = f.read() + match = re.search(r'"(uipath[^"]*)"', content) + assert match is not None, content + return match.group(1) + + def test_pin_derived_from_installed_version(self, temp_dir: str) -> None: + with patch("uipath._cli._get_safe_version", return_value="2.14.7"): + assert self._written_pin(temp_dir) == "uipath>=2.14.0, <2.15.0" + + def test_pin_strips_prerelease_suffix(self, temp_dir: str) -> None: + with patch("uipath._cli._get_safe_version", return_value="2.15.0rc1"): + assert self._written_pin(temp_dir) == "uipath>=2.15.0, <2.16.0" + + def test_pin_falls_back_when_version_unknown(self, temp_dir: str) -> None: + from uipath._cli.cli_new import _fallback_uipath_dependency_spec, console + + # _get_safe_version() returns "unknown" on PackageNotFoundError. + with ( + patch("uipath._cli._get_safe_version", return_value="unknown"), + patch.object(console, "warning") as mock_warning, + ): + assert self._written_pin(temp_dir) == _fallback_uipath_dependency_spec() + mock_warning.assert_called_once() + assert ( + "Could not determine the installed 'uipath' version" + in (mock_warning.call_args.args[0]) + ) + + def test_fallback_pin_admits_installed_uipath(self) -> None: + """Guard: a release PR that forgets to bump FALLBACK_UIPATH_MINOR fails CI.""" + from uipath._cli.cli_new import _fallback_uipath_dependency_spec + + spec = _fallback_uipath_dependency_spec().removeprefix("uipath") + installed = version("uipath") + assert SpecifierSet(spec).contains(installed, prereleases=True), ( + f"fallback pin '{spec}' does not admit installed uipath {installed}; " + "bump FALLBACK_UIPATH_MINOR in cli_new.py" + ) + + def test_scaffolded_pin_contains_installed_uipath( + self, runner: CliRunner, temp_dir: str + ) -> None: + """Regression guard: runs against the real installed package, not a mock. + + A stale hard-coded range would make ``uv sync`` downgrade the project's + venv right after ``uipath new``. + """ + with runner.isolated_filesystem(temp_dir=temp_dir): + result = runner.invoke(cli, ["new", "demo"]) + assert result.exit_code == 0 + with open("pyproject.toml") as f: + content = f.read() + match = re.search(r'"uipath([^"]*)"', content) + assert match is not None, content + pin = SpecifierSet(match.group(1)) + installed = version("uipath") + assert pin.contains(installed, prereleases=True), ( + f"scaffolded pin '{pin}' does not contain installed uipath {installed}" + ) diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9dc12f220..030c1b50f 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.8" +version = "2.14.9" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2642,6 +2642,7 @@ dev = [ { name = "mkdocs-simple-hooks" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2699,6 +2700,7 @@ dev = [ { name = "mkdocs-simple-hooks", specifier = ">=0.1.5" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" }, { name = "mypy", specifier = ">=1.14.1" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=7.4.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" },