From f67365bd6ee920c464cb3fe1e280297a12b39401 Mon Sep 17 00:00:00 2001 From: Christoph Ladurner Date: Thu, 2 Jan 2025 14:01:32 +0100 Subject: [PATCH 1/3] packages: make javascript package manager configurable * bump pynpm dependency for the PNPMPackage * tell invenio about which JS package manager is actually selected currently (npm or pnpm), via the new `WEBPACKEXT_NPM_PKG_CLS` config * also, use the module_pkg to execute the install command in the target path (rather than the instance's path, which made a difference in the case of pnpm) --- invenio_cli/commands/assets.py | 21 +++++---- invenio_cli/commands/local.py | 17 ++++--- invenio_cli/helpers/cli_config.py | 29 +++++++++--- invenio_cli/helpers/package_managers.py | 59 ++++++++++++++++++++++++- setup.cfg | 2 +- 5 files changed, 103 insertions(+), 25 deletions(-) diff --git a/invenio_cli/commands/assets.py b/invenio_cli/commands/assets.py index 22c06da6..5cc13d5b 100644 --- a/invenio_cli/commands/assets.py +++ b/invenio_cli/commands/assets.py @@ -7,11 +7,9 @@ """Invenio module to ease the creation and management of applications.""" -import subprocess from pathlib import Path import click -from pynpm import NPMPackage from ..helpers import env from ..helpers.process import ProcessResponse, run_interactive @@ -26,17 +24,16 @@ def __init__(self, cli_config): """Constructor.""" super().__init__(cli_config) - @staticmethod - def _module_pkg(path): + def _module_pkg(self, path): """NPM package for the given path.""" - return NPMPackage(Path(path) / "package.json") + path = Path(path) / "package.json" + return self.cli_config.javascript_package_manager.create_pynpm_package(path) def _assets_pkg(self): """NPM package for the instance's webpack project.""" return self._module_pkg(self.cli_config.get_instance_path() / "assets") - @staticmethod - def _watch_js_module(pkg): + def _watch_js_module(self, pkg): """Watch the JS module for changes.""" click.secho("Starting watching module...", fg="green") status_code = pkg.run_script("watch") @@ -62,10 +59,12 @@ def _run_script(module_pkg): status_code=status_code, ) - @staticmethod - def _npm_install_command(path): + def _npm_install_command(self, path, module_pkg): """Run command and return a ProcessResponse.""" - status_code = subprocess.call(["npm", "install", "--prefix", path]) + install_args = self.cli_config.javascript_package_manager.install_local_package( + path + ) + status_code = module_pkg.install(" ".join(install_args)) if status_code == 0: return ProcessResponse( output="Dependent packages installed correctly", status_code=0 @@ -135,7 +134,7 @@ def link_js_module(self, path): steps = [ FunctionStep( # Install dependent packages func=self._npm_install_command, - args={"path": path}, + args={"path": path, "module_pkg": module_pkg}, message="Installing dependent packages...", ), FunctionStep( # Run build script diff --git a/invenio_cli/commands/local.py b/invenio_cli/commands/local.py index a6b96d57..a950e6d7 100644 --- a/invenio_cli/commands/local.py +++ b/invenio_cli/commands/local.py @@ -84,16 +84,17 @@ def update_statics_and_assets(self, force, debug=False, log_file=None): Needed here (parent) because is used by Assets and Install commands. """ # Commands - pkg_man = self.cli_config.python_package_manager - ops = [pkg_man.run_command("invenio", "collect", "--verbose")] + py_pkg_man = self.cli_config.python_package_manager + js_pkg_man = self.cli_config.javascript_package_manager + ops = [py_pkg_man.run_command("invenio", "collect", "--verbose")] if force: - ops.append(pkg_man.run_command("invenio", "webpack", "clean", "create")) - ops.append(pkg_man.run_command("invenio", "webpack", "install")) + ops.append(py_pkg_man.run_command("invenio", "webpack", "clean", "create")) + ops.append(py_pkg_man.run_command("invenio", "webpack", "install")) else: - ops.append(pkg_man.run_command("invenio", "webpack", "create")) + ops.append(py_pkg_man.run_command("invenio", "webpack", "create")) ops.append(self._statics) - ops.append(pkg_man.run_command("invenio", "webpack", "build")) + ops.append(py_pkg_man.run_command("invenio", "webpack", "build")) # Keep the same messages for some of the operations for backward compatibility messages = { "build": "Building assets...", @@ -108,7 +109,9 @@ def update_statics_and_assets(self, force, debug=False, log_file=None): if op[-1] in messages: click.secho(messages[op[-1]], fg="green") response = run_interactive( - op, env={"PIPENV_VERBOSITY": "-1"}, log_file=log_file + op, + env={"PIPENV_VERBOSITY": "-1", **js_pkg_man.env_overrides()}, + log_file=log_file, ) if response.status_code != 0: break diff --git a/invenio_cli/helpers/cli_config.py b/invenio_cli/helpers/cli_config.py index 46e1092f..86275cf8 100644 --- a/invenio_cli/helpers/cli_config.py +++ b/invenio_cli/helpers/cli_config.py @@ -11,11 +11,19 @@ """Invenio-cli configuration file.""" from configparser import ConfigParser +from functools import cached_property from pathlib import Path from ..errors import InvenioCLIConfigError from .filesystem import get_created_files -from .package_managers import UV, Pipenv, PythonPackageManager +from .package_managers import ( + NPM, + PNPM, + UV, + JavascriptPackageManager, + Pipenv, + PythonPackageManager, +) from .process import ProcessResponse @@ -62,12 +70,10 @@ def __init__(self, project_dir="./"): with open(self.private_config_path) as cfg_file: self.private_config.read_file(cfg_file) - @property + @cached_property def python_package_manager(self) -> PythonPackageManager: """Get python packages manager.""" - manager_name = self.config[CLIConfig.CLI_SECTION].get( - "python_package_manager", None - ) + manager_name = self.config[CLIConfig.CLI_SECTION].get("python_package_manager") if manager_name == Pipenv.name: return Pipenv() elif manager_name == UV.name: @@ -82,6 +88,19 @@ def python_package_manager(self) -> PythonPackageManager: "Could not determine the Python package manager, please configure it." ) + @cached_property + def javascript_package_manager(self) -> JavascriptPackageManager: + """Get javascript packages manager.""" + manager_name = self.config[CLIConfig.CLI_SECTION].get( + "javascript_package_manager" + ) + if manager_name == NPM.name: + return NPM() + elif manager_name == PNPM.name: + return PNPM() + + return NPM() + def get_project_dir(self): """Returns path to project directory.""" return self.config_path.parent.resolve() diff --git a/invenio_cli/helpers/package_managers.py b/invenio_cli/helpers/package_managers.py index a961e636..f5cf151e 100644 --- a/invenio_cli/helpers/package_managers.py +++ b/invenio_cli/helpers/package_managers.py @@ -9,7 +9,10 @@ import os from abc import ABC -from typing import List +from pathlib import Path +from typing import Dict, List, Union + +from pynpm import NPMPackage, PNPMPackage class PythonPackageManager(ABC): @@ -171,3 +174,57 @@ def start_activated_subshell(self) -> List[str]: # Also, it has a good chance of not properly setting a PS1... shell = os.getenv("SHELL") return [shell, "-c", f"source .venv/bin/activate; exec {shell} -i"] + + +class JavascriptPackageManager(ABC): + """Interface for creating tool-specific JS package management commands.""" + + name = None + + def create_pynpm_package(self, package_json_path: Union[Path, str]) -> NPMPackage: + """Create a variant of ``NPMPackage`` with the path to ``package.json``.""" + raise NotImplementedError() + + def install_local_package(self, path: Union[Path, str]) -> List[str]: + """Install the local JS package.""" + raise NotImplementedError() + + def env_overrides(self) -> Dict[str, str]: + """Provide environment overrides for building Invenio assets.""" + return {} + + +class NPM(JavascriptPackageManager): + """Generate ``npm`` commands for managing JS packages.""" + + name = "npm" + + def create_pynpm_package(self, package_json_path): + """Create an ``NPMPackage`` with the path to ``package.json``.""" + return NPMPackage(package_json_path) + + def install_local_package(self, path): + """Install the local JS package.""" + return ["--prefix", str(path)] + + def env_overrides(self): + """Provide environment overrides for building Invenio assets.""" + return {"INVENIO_WEBPACKEXT_NPM_PKG_CLS": "pynpm:NPMPackage"} + + +class PNPM(JavascriptPackageManager): + """Generate ``pnpm`` commands for managing JS packages.""" + + name = "pnpm" + + def create_pynpm_package(self, package_json_path): + """Create a ``PNPMPackage`` with the path to ``package.json``.""" + return PNPMPackage(package_json_path) + + def install_local_package(self, path): + """Install the local JS package.""" + return ["-C", str(path)] + + def env_overrides(self): + """Provide environment overrides for building Invenio assets.""" + return {"INVENIO_WEBPACKEXT_NPM_PKG_CLS": "pynpm:PNPMPackage"} diff --git a/setup.cfg b/setup.cfg index 7ce1a0c1..9ca57058 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,7 +35,7 @@ install_requires = pipfile>=0.0.2 pipenv>=2020.6.2 PyYAML>=5.1.2 - pynpm>=0.1.2 + pynpm>=0.3.0 virtualenv>=20.0.35 tomli>=1.1.0;python_version<"3.11" From 4950c89fadce7699bcd85d718c6900b1f6e23b79 Mon Sep 17 00:00:00 2001 From: Maximilian Moser Date: Sat, 1 Mar 2025 01:15:08 +0100 Subject: [PATCH 2/3] assets: implement js package linking for pnpm * as discussed, we implement the `pnpm link` to basically do the same thing as the current `npm link` procedure, to not require any changes in JS packages --- invenio_cli/commands/assets.py | 56 ++-------- invenio_cli/helpers/package_managers.py | 133 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 45 deletions(-) diff --git a/invenio_cli/commands/assets.py b/invenio_cli/commands/assets.py index 5cc13d5b..d7662949 100644 --- a/invenio_cli/commands/assets.py +++ b/invenio_cli/commands/assets.py @@ -45,20 +45,6 @@ def _watch_js_module(self, pkg): status_code=status_code, ) - @staticmethod - def _run_script(module_pkg): - """Run script and return a ProcessResponse.""" - status_code = module_pkg.run_script("link-dist") - if status_code == 0: - return ProcessResponse( - output="Module linked correctly to global", status_code=0 - ) - else: - return ProcessResponse( - error=f"Unable to link-dist. Got error code {status_code}", - status_code=status_code, - ) - def _npm_install_command(self, path, module_pkg): """Run command and return a ProcessResponse.""" install_args = self.cli_config.javascript_package_manager.install_local_package( @@ -88,28 +74,6 @@ def _build_script(module_pkg): status_code=status_code, ) - @staticmethod - def _assets_link(assets_pkg, module_pkg): - try: - module_name = module_pkg.package_json["name"] - except FileNotFoundError as e: - return ProcessResponse( - error="No module found on the specified path. " - f"File not found {e.filename}", - status_code=1, - ) - - status_code = assets_pkg.link(module_name) - if status_code == 0: - return ProcessResponse( - output="Global module linked correctly to local folder", status_code=0 - ) - else: - return ProcessResponse( - error=f"Unable to link module. Got error code {status_code}", - status_code=status_code, - ) - def watch_assets(self): """High-level command to watch assets for changes.""" watch_cmd = self.cli_config.python_package_manager.run_command( @@ -142,17 +106,19 @@ def link_js_module(self, path): args={"module_pkg": module_pkg}, message="Building...", ), - FunctionStep( # Create link to global folder - func=self._run_script, - args={"module_pkg": module_pkg}, - message="Linking module to global dist...", - ), - FunctionStep( # Link the global folder to the assets folder. - func=self._assets_link, + ] + + # The commands necessary for linking local JS packages vary by package manager + js_package_manager = self.cli_config.javascript_package_manager + link_steps = [ + FunctionStep( + func=step.function, args={"assets_pkg": assets_pkg, "module_pkg": module_pkg}, - message="Linking module to assets...", - ), + message=step.message, + ) + for step in js_package_manager.package_linking_steps() ] + steps.extend(link_steps) return steps diff --git a/invenio_cli/helpers/package_managers.py b/invenio_cli/helpers/package_managers.py index f5cf151e..5bc98fea 100644 --- a/invenio_cli/helpers/package_managers.py +++ b/invenio_cli/helpers/package_managers.py @@ -9,11 +9,15 @@ import os from abc import ABC +from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Union from pynpm import NPMPackage, PNPMPackage +from ..helpers.process import ProcessResponse + class PythonPackageManager(ABC): """Interface for creating tool-specific Python package management commands.""" @@ -176,6 +180,14 @@ def start_activated_subshell(self) -> List[str]: return [shell, "-c", f"source .venv/bin/activate; exec {shell} -i"] +@dataclass +class AssetsFunction: + """Function to run an assets command with `assets_pkg` and `module_pkg`.""" + + function: Callable[[NPMPackage, NPMPackage], ProcessResponse] + message: str + + class JavascriptPackageManager(ABC): """Interface for creating tool-specific JS package management commands.""" @@ -193,6 +205,10 @@ def env_overrides(self) -> Dict[str, str]: """Provide environment overrides for building Invenio assets.""" return {} + def package_linking_steps(self) -> List[AssetsFunction]: + """Generate steps to link the target package to the project.""" + raise NotImplementedError() + class NPM(JavascriptPackageManager): """Generate ``npm`` commands for managing JS packages.""" @@ -211,6 +227,52 @@ def env_overrides(self): """Provide environment overrides for building Invenio assets.""" return {"INVENIO_WEBPACKEXT_NPM_PKG_CLS": "pynpm:NPMPackage"} + def package_linking_steps(self): + """Generate steps to link the target package to the project.""" + + def _link_package_to_global( + assets_pkg: NPMPackage, module_pkg: NPMPackage + ) -> ProcessResponse: + status_code = module_pkg.run_script("link-dist") + if status_code == 0: + return ProcessResponse( + output="Module linked correctly to global", status_code=0 + ) + else: + return ProcessResponse( + error=f"Unable to link-dist. Got error code {status_code}", + status_code=status_code, + ) + + def _link_global_package( + assets_pkg: NPMPackage, module_pkg: NPMPackage + ) -> ProcessResponse: + try: + module_name = module_pkg.package_json["name"] + except FileNotFoundError as e: + return ProcessResponse( + error="No module found on the specified path. " + f"File not found {e.filename}", + status_code=1, + ) + + status_code = assets_pkg.link(module_name) + if status_code == 0: + return ProcessResponse( + output="Global module linked correctly to local folder", + status_code=0, + ) + else: + return ProcessResponse( + error=f"Unable to link module. Got error code {status_code}", + status_code=status_code, + ) + + return [ + AssetsFunction(_link_package_to_global, "Linking module to global dist..."), + AssetsFunction(_link_global_package, "Linking module to assets..."), + ] + class PNPM(JavascriptPackageManager): """Generate ``pnpm`` commands for managing JS packages.""" @@ -228,3 +290,74 @@ def install_local_package(self, path): def env_overrides(self): """Provide environment overrides for building Invenio assets.""" return {"INVENIO_WEBPACKEXT_NPM_PKG_CLS": "pynpm:PNPMPackage"} + + def package_linking_steps(self): + """Generate steps to link the target package to the project.""" + + def _prelink_dist( + assets_pkg: NPMPackage, module_pkg: NPMPackage + ) -> ProcessResponse: + """Execute the "prelink-dist" script.""" + status_code = module_pkg.run_script("prelink-dist") + if status_code == 0: + return ProcessResponse( + output="Successfully ran prelink-dist script.", + status_code=0, + ) + else: + return ProcessResponse( + error=f"Unable to prelink-dist. Got error code {status_code}", + status_code=status_code, + ) + + def _link_package_single_step( + assets_pkg: NPMPackage, module_pkg: NPMPackage + ) -> ProcessResponse: + """Execute the PNPM single-step package linking.""" + try: + # Accessing `package_json` fails if the file can't be found + module_pkg.package_json["name"] + + # This is geared towards Invenio JS packages... + # But so are all the other steps + status_code = assets_pkg.link( + str(module_pkg.package_json_path.parent / "dist") + ) + except FileNotFoundError as e: + return ProcessResponse( + error="No module found on the specified path. " + f"File not found {e.filename}", + status_code=1, + ) + if status_code == 0: + return ProcessResponse( + output="Module linked successfully to assets", + status_code=0, + ) + else: + return ProcessResponse( + error=f"Unable to link module. Got error code {status_code}", + status_code=status_code, + ) + + def _postlink_dist( + assets_pkg: NPMPackage, module_pkg: NPMPackage + ) -> ProcessResponse: + """Execute the "postlink-dist" script.""" + status_code = module_pkg.run_script("postlink-dist") + if status_code == 0: + return ProcessResponse( + output="Successfully ran postlink-dist script.", + status_code=0, + ) + else: + return ProcessResponse( + error=f"Unable to run postlink-dist. Got error code {status_code}", + status_code=status_code, + ) + + return [ + AssetsFunction(_prelink_dist, "Executing prelink-dist script..."), + AssetsFunction(_link_package_single_step, "Linking module to assets..."), + AssetsFunction(_postlink_dist, "Executing postlink-dist script..."), + ] From 0a695af5e713777470ee85aac736c26e05255f1c Mon Sep 17 00:00:00 2001 From: Maximilian Moser Date: Fri, 28 Mar 2025 22:29:26 +0100 Subject: [PATCH 3/3] release: v1.7.0 --- CHANGES.rst | 4 ++++ invenio_cli/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index eefdb43f..34a07d61 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,10 @@ Changes ======= +Version 1.7.0 (released 2025-03-28) + +- build: allow use of either `npm` or `pnpm` as JS package manager (via `.invenio`) + Version 1.6.1 (released 2025-03-27) - versions: consider `pyproject.toml` when checking `App-{RDM,ILS}` dependency versions diff --git a/invenio_cli/__init__.py b/invenio_cli/__init__.py index 688a18df..def50d95 100644 --- a/invenio_cli/__init__.py +++ b/invenio_cli/__init__.py @@ -9,6 +9,6 @@ """Invenio module to ease the creation and management of applications.""" -__version__ = "1.6.1" +__version__ = "1.7.0" __all__ = ("__version__",)