diff --git a/src/cpl-cli/cpl.project.json b/src/cpl-cli/cpl.project.json index 36b88951..edaa4ca7 100644 --- a/src/cpl-cli/cpl.project.json +++ b/src/cpl-cli/cpl.project.json @@ -11,7 +11,7 @@ "click": "~8.3.0" }, "devDependencies": { - "black": "25.1.0" + "black": "~25.9" }, "references": [ "../cpl/cpl.project.json" diff --git a/src/cpl-cli/cpl/cli/command/install.py b/src/cpl-cli/cpl/cli/command/install.py index 807b2c4f..ae5108dd 100644 --- a/src/cpl-cli/cpl/cli/command/install.py +++ b/src/cpl-cli/cpl/cli/command/install.py @@ -10,6 +10,7 @@ from cpl.core.console import Console @cli.command("install", aliases=["i"]) +@click.argument("package", type=click.STRING, required=False) @click.option( "--path", "project_path", @@ -20,8 +21,26 @@ from cpl.core.console import Console @click.option("--name", "project_name", help="Project name (lookup via workspace)") @click.option("--dev", is_flag=True, help="Include dev dependencies") @click.option("--verbose", is_flag=True, help="Enable verbose output") -def install(project_path: str, project_name: str, dev: bool, verbose: bool): +def install(package: str, project_path: str, project_name: str, dev: bool, verbose: bool): project = resolve_project(Path(project_path), project_name) + if package is not None: + Console.write_line(f"Installing {package} to '{project.name}':") + try: + Pip.command("install", package, verbose=verbose, path=project_path) + except subprocess.CalledProcessError as e: + Console.error(f"Failed to install {package}: exit code {e.returncode}") + + package_name = Pip.get_package_without_version(package) + installed_version = Pip.get_package_version(package_name, path=project_path) + if installed_version is None: + Console.error(f"Package '{package_name}' not found after installation.") + return + + project.dependencies[package_name] = Pip.apply_prefix(installed_version, Pip.get_package_full_version(package)) + project.save() + Console.write_line(f"Added {package_name}~{installed_version} to project dependencies.") + return + deps: dict = project.dependencies if dev: deps.update(project.dev_dependencies) @@ -36,6 +55,6 @@ def install(project_path: str, project_name: str, dev: bool, verbose: bool): dep = Pip.normalize_dep(name, version) Console.write_line(f" -> {dep}") try: - Pip.command(project_path, "install", dep, verbose=verbose) + Pip.command("install", dep, verbose=verbose, path=project_path) except subprocess.CalledProcessError as e: Console.error(f"Failed to install {dep}: exit code {e.returncode}") diff --git a/src/cpl-cli/cpl/cli/command/uninstall.py b/src/cpl-cli/cpl/cli/command/uninstall.py index a2a7bfe1..68e7a17e 100644 --- a/src/cpl-cli/cpl/cli/command/uninstall.py +++ b/src/cpl-cli/cpl/cli/command/uninstall.py @@ -29,9 +29,10 @@ def uninstall(package: str, project_path: str, project_name: str, dev: bool, ver deps = project.dependencies if not dev else project.dev_dependencies try: - Pip.command(project_path, "install", package, verbose=verbose) + Pip.command("install", package, verbose=verbose, path=project_path) except subprocess.CalledProcessError as e: Console.error(f"Failed to uninstall {package}: exit code {e.returncode}") + return if package in deps: del deps[package] diff --git a/src/cpl-cli/cpl/cli/command/update.py b/src/cpl-cli/cpl/cli/command/update.py new file mode 100644 index 00000000..a10206a0 --- /dev/null +++ b/src/cpl-cli/cpl/cli/command/update.py @@ -0,0 +1,81 @@ +import subprocess +from pathlib import Path + +import click + +from cpl.cli.cli import cli +from cpl.cli.utils.pip import Pip +from cpl.cli.utils.structure import resolve_project +from cpl.core.console import Console + + +@cli.command("update", aliases=["u"]) +@click.argument("package", type=click.STRING, required=False) +@click.option( + "--path", + "project_path", + type=click.Path(file_okay=False, exists=False), + default=".", + help="Path to project directory", +) +@click.option("--name", "project_name", help="Project name (lookup via workspace)") +@click.option("--dev", is_flag=True, help="Include dev dependencies") +@click.option("--verbose", is_flag=True, help="Enable verbose output") +def update(package: str, project_path: str, project_name: str, dev: bool, verbose: bool): + project = resolve_project(Path(project_path), project_name) + deps: dict = project.dependencies + if dev: + deps = project.dev_dependencies + + if package is not None: + if package not in deps: + Console.error(f"Package '{package}' not installed.") + return + + old_spec = deps[package] + + Console.write_line(f"Updating {package} to '{project.name}':") + try: + Pip.command( + "install --upgrade", + f"{Pip.normalize_dep(package, old_spec)}", + verbose=verbose, + path=project_path, + ) + except subprocess.CalledProcessError as e: + Console.error(f"Failed to install {package}: exit code {e.returncode}") + return + + installed_version = Pip.get_package_version(package, path=project_path) + if installed_version is None: + Console.error(f"Package '{package}' not found after update.") + return + + deps[package] = Pip.apply_prefix(installed_version, old_spec) + project.save() + Console.write_line(f"Updated {package} to {deps[package]}") + return + + if not deps: + Console.error("No dependencies to install.") + return + + Console.write_line(f"Updating dependencies for '{project.name}':") + + for name, version in list(deps.items()): + dep = Pip.normalize_dep(name, version) + Console.write_line(f" -> {dep}") + try: + Pip.command("install --upgrade", dep, verbose=verbose, path=project_path) + except subprocess.CalledProcessError as e: + Console.error(f"Failed to update {dep}: exit code {e.returncode}") + return + + installed_version = Pip.get_package_version(name, path=project_path) + if installed_version is None: + Console.error(f"Package '{name}' not found after update.") + continue + + deps[name] = Pip.apply_prefix(installed_version, version) + + project.save() diff --git a/src/cpl-cli/cpl/cli/main.py b/src/cpl-cli/cpl/cli/main.py index 3d8f2bea..5f7357de 100644 --- a/src/cpl-cli/cpl/cli/main.py +++ b/src/cpl-cli/cpl/cli/main.py @@ -5,6 +5,7 @@ from cpl.cli.cli import cli from cpl.cli.command.init import init from cpl.cli.command.install import install from cpl.cli.command.uninstall import uninstall +from cpl.cli.command.update import update from cpl.cli.model.workspace import Workspace from cpl.cli.utils.custom_command import script_command from cpl.core.configuration import Configuration @@ -44,6 +45,7 @@ def configure(): cli.add_command(init) cli.add_command(install) cli.add_command(uninstall) + cli.add_command(update) def main(): diff --git a/src/cpl-cli/cpl/cli/utils/pip.py b/src/cpl-cli/cpl/cli/utils/pip.py index 5c51dd16..aa3da223 100644 --- a/src/cpl-cli/cpl/cli/utils/pip.py +++ b/src/cpl-cli/cpl/cli/utils/pip.py @@ -1,3 +1,4 @@ +import re import subprocess from pathlib import Path @@ -6,6 +7,7 @@ from cpl.core.console import Console class Pip: + _ANY_PREFIX_RE = re.compile(r"(===|~=|==|!=|>=|<=|>>|<<|>|<|!|~|=|\^)") @staticmethod def normalize_dep(name: str, raw: str) -> str: @@ -27,14 +29,75 @@ class Pip: for prefix, pip_op in table.items(): if raw.startswith(prefix): op = pip_op - raw = raw[len(prefix) :] + raw = raw[len(prefix):] break return f"{name}{op}{raw}" + @classmethod + def apply_prefix(cls, installed: str, spec: str = None) -> str: + if spec is None or not spec.strip(): + return f"~{installed}" + + s = spec.strip() + if "," in s: + return s + + m = cls._ANY_PREFIX_RE.search(s) + if not m: + return f"~{installed}" + + op = m.group(1) + rest = s[m.end():].strip() + if "," in rest: + rest = rest.split(",", 1)[0].strip() + if " " in rest: + rest = rest.split()[0] + + orig_version = rest + + installed_parts = [p for p in installed.split(".") if p != ""] + if orig_version: + orig_parts = [p for p in orig_version.split(".") if p != ""] + trimmed_installed = ".".join(installed_parts[:len(orig_parts)]) or installed + else: + trimmed_installed = installed + + pip_to_cpl = { + "==": "", + "!=": "!", + ">=": ">", + ">": ">>", + "<=": "<", + "<": "<<", + "~=": "~", + "===": "=", + "^": "~", + } + + if op in pip_to_cpl: + cpl_op = pip_to_cpl[op] + else: + cpl_op = op + + return f"{cpl_op}{trimmed_installed}" + + @classmethod + def get_package_without_version(cls, spec: str) -> str: + for sep in ["==", ">=", "<=", ">", "<", "~=", "!="]: + if sep in spec: + return spec.split(sep, 1)[0].strip() or None + + return spec.strip() or None + + @classmethod + def get_package_full_version(cls, spec: str) -> str | None: + package_name = cls.get_package_without_version(spec) + return spec.replace(package_name, "").strip() or None + @staticmethod - def command(project_path: str, command: str, *args,verbose:bool=False): - venv_path = ensure_venv(Path(project_path)) + def command(command: str, *args,verbose:bool=False, path: str=None): + venv_path = ensure_venv(Path(path or './')) pip = get_venv_pip(venv_path) if verbose: @@ -47,4 +110,26 @@ class Pip: stdin=subprocess.DEVNULL if not verbose else None, stdout=subprocess.DEVNULL if not verbose else None, stderr=subprocess.STDOUT if not verbose else None, - ) \ No newline at end of file + ) + + @staticmethod + def get_package_version(package: str, path: str=None) -> str | None: + venv_path = ensure_venv(Path(path or './')) + pip = get_venv_pip(venv_path) + + try: + result = subprocess.run( + f"{pip} show {package}", + shell=True, + check=True, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + ) + for line in result.stdout.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + except subprocess.CalledProcessError: + return None + + return None \ No newline at end of file diff --git a/src/cpl-cli/requirements.txt b/src/cpl-cli/requirements.txt index a8244b30..838c3e83 100644 --- a/src/cpl-cli/requirements.txt +++ b/src/cpl-cli/requirements.txt @@ -1 +1,2 @@ -cpl-core \ No newline at end of file +cpl-core +click==8.3.0 \ No newline at end of file diff --git a/src/cpl-core/cpl.project.json b/src/cpl-core/cpl.project.json index 66ebca33..7a3bd3b2 100644 --- a/src/cpl-core/cpl.project.json +++ b/src/cpl-core/cpl.project.json @@ -16,7 +16,7 @@ "croniter": "~6.0.0" }, "devDependencies": { - "black": "25.1.0" + "black": "~25.9" }, "references": [], "main": null, diff --git a/src/cpl-core/cpl/core/utils/__init__.py b/src/cpl-core/cpl/core/utils/__init__.py index c5a89180..6cad83e0 100644 --- a/src/cpl-core/cpl/core/utils/__init__.py +++ b/src/cpl-core/cpl/core/utils/__init__.py @@ -1,6 +1,5 @@ from .base64 import Base64 from .credential_manager import CredentialManager from .json_processor import JSONProcessor -from .pip import Pip from .string import String from .get_value import get_value diff --git a/src/cpl-core/cpl/core/utils/pip.py b/src/cpl-core/cpl/core/utils/pip.py deleted file mode 100644 index bc626e16..00000000 --- a/src/cpl-core/cpl/core/utils/pip.py +++ /dev/null @@ -1,130 +0,0 @@ -import os -import subprocess -import sys -from contextlib import suppress -from typing import Optional - - -class Pip: - r"""Executes pip commands""" - - _executable = sys.executable - _env = os.environ - - """Getter""" - - @classmethod - def get_executable(cls) -> str: - return cls._executable - - """Setter""" - - @classmethod - def set_executable(cls, executable: str): - r"""Sets the executable - - Parameter: - executable: :class:`str` - The python command - """ - if executable is None or executable == sys.executable: - return - - cls._executable = executable - if not os.path.islink(cls._executable) or not os.path.isfile(executable): - return - - path = os.path.dirname(os.path.dirname(cls._executable)) - cls._env = os.environ - if sys.platform == "win32": - cls._env["PATH"] = f"{path}\\bin" + os.pathsep + os.environ.get("PATH", "") - else: - cls._env["PATH"] = f"{path}/bin" + os.pathsep + os.environ.get("PATH", "") - cls._env["VIRTUAL_ENV"] = path - - @classmethod - def reset_executable(cls): - r"""Resets the executable to system standard""" - cls._executable = sys.executable - - """Public utils functions""" - - @classmethod - def get_package(cls, package: str) -> Optional[str]: - r"""Gets given package py local pip list - - Parameter: - package: :class:`str` - - Returns: - The package name as string - """ - result = None - with suppress(Exception): - args = [cls._executable, "-m", "pip", "freeze", "--all"] - - result = subprocess.check_output(args, stderr=subprocess.DEVNULL, env=cls._env) - - if result is None: - return None - for p in str(result.decode()).split("\n"): - if p.startswith(package): - return p - - return None - - @classmethod - def get_outdated(cls) -> bytes: - r"""Gets table of outdated packages - - Returns: - Bytes string of the command result - """ - args = [cls._executable, "-m", "pip", "list", "--outdated"] - - return subprocess.check_output(args, env=cls._env) - - @classmethod - def install(cls, package: str, *args, source: str = None, stdout=None, stderr=None): - r"""Installs given package - - Parameter: - package: :class:`str` - The name of the package - args: :class:`list` - Arguments for the command - source: :class:`str` - Extra index URL - stdout: :class:`str` - Stdout of subprocess.run - stderr: :class:`str` - Stderr of subprocess.run - """ - pip_args = [cls._executable, "-m", "pip", "install"] - - for arg in args: - pip_args.append(arg) - - pip_args.append(package) - - if source is not None: - pip_args.append(f"--extra-index-url") - pip_args.append(source) - - subprocess.run(pip_args, stdout=stdout, stderr=stderr, env=cls._env) - - @classmethod - def uninstall(cls, package: str, stdout=None, stderr=None): - r"""Uninstalls given package - - Parameter: - package: :class:`str` - The name of the package - stdout: :class:`str` - Stdout of subprocess.run - stderr: :class:`str` - Stderr of subprocess.run - """ - args = [cls._executable, "-m", "pip", "uninstall", "--yes", package] - - subprocess.run(args, stdout=stdout, stderr=stderr, env=cls._env)