import re
from projspec.proj.base import ProjectSpec, ParseFailed
from projspec.content.package import NodePackage
from projspec.artifact.process import Process
from projspec.utils import run_subprocess
from projspec.content.executable import Command
from projspec.utils import AttrDict
[docs]
class Node(ProjectSpec):
"""Node.js project, managed by NPM
This is a project that contains a package.json file.
"""
icon = "🟩"
spec_doc = "https://docs.npmjs.com/cli/v11/configuring-npm/package-json"
def match(self):
return "package.json" in self.proj.basenames
def parse0(self):
from projspec.content.environment import Environment, Stack, Precision
from projspec.content.metadata import DescriptiveMetadata
from projspec.artifact.python_env import LockFile
import json
with self.proj.fs.open(f"{self.proj.url}/package.json", "rt") as f:
pkg_json = json.load(f)
self.meta = pkg_json
# Metadata
name = pkg_json.get("name")
version = pkg_json.get("version")
description = pkg_json.get("description")
# Dependencies
dependencies = pkg_json.get("dependencies")
dev_dependencies = pkg_json.get("devDependencies")
# Entry points for runtime execution: CLI
scripts = pkg_json.get("scripts", {})
bin = pkg_json.get("bin")
# Entry points for importable code: library
main = pkg_json.get("main")
module = pkg_json.get("module")
# TBD: exports?
# Package manager
package_manager = pkg_json.get("packageManager", "npm@latest")
if isinstance(package_manager, str):
package_manager_name = package_manager.split("@")[0]
else:
package_manager_name = package_manager.get("name", "npm")
# Commands
bin_entry = {}
if bin and isinstance(bin, str):
bin_entry = {name: bin}
elif bin and isinstance(bin, dict):
bin_entry = bin
# Contents
conts = AttrDict(
{
"descriptive_metadata": DescriptiveMetadata(
proj=self.proj,
meta={
"name": name,
"version": version,
"description": description,
"main": main,
"module": module,
},
),
},
)
cmd = AttrDict()
for name, path in bin_entry.items():
cmd[name] = Command(proj=self.proj, cmd=["node", f"{self.proj.url}/{path}"])
# Artifacts
arts = AttrDict()
for script_name, script_cmd in scripts.items():
if script_name == "build":
arts["build"] = Process(
proj=self.proj,
cmd=[package_manager_name, "run", script_name],
)
else:
cmd[script_name] = Command(
proj=self.proj,
cmd=[package_manager_name, "run", script_name],
)
if "package-lock.json" in self.proj.basenames:
arts["lock_file"] = LockFile(
proj=self.proj,
cmd=["npm", "install"],
fn=self.proj.basenames["package-lock.json"],
)
# TODO: load lockfile and make environment
conts.setdefault("environment", {})["node"] = Environment(
proj=self.proj,
stack=Stack.NPM,
packages=dependencies,
precision=Precision.SPEC,
)
conts.setdefault("environment", {})["node_dev"] = Environment(
proj=self.proj,
stack=Stack.NPM,
packages=dev_dependencies, # + dependencies?
precision=Precision.SPEC,
)
conts["node_package"] = (NodePackage(name=name, proj=self.proj),)
conts["command"] = cmd
self._artifacts = arts
self._contents = conts
def parse(self):
self.parse0()
# TODO: a vscode extension has key "contributes" in package.json and engine: vscode: {},
# and then you can build a .vsix with `vsce pack`.
[docs]
class Yarn(Node):
"""A node project that uses `yarn` for building"""
icon = "🧶"
spec_doc = "https://yarnpkg.com/configuration/yarnrc"
def match(self):
return ".yarnrc.yml" in self.proj.basenames
def parse(self, ignore=False):
from projspec.content.environment import Environment, Stack, Precision
from projspec.artifact.python_env import LockFile
super().parse0()
try:
with self.proj.fs.open(f"{self.proj.url}/yarn.lock", "rt") as f:
txt = f.read()
except FileNotFoundError:
if ignore:
# only used by JLab - we know it complies with yarn even without lock-file.
return
raise ParseFailed
hits = re.findall(r'resolution: "(.*?)"', txt, flags=re.MULTILINE)
self.artifacts["lock_file"] = LockFile(
proj=self.proj,
cmd=["yarn", "install"],
fn=self.proj.basenames["yarn.lock"],
)
self.contents.setdefault("environment", {})["yarn_lock"] = Environment(
proj=self.proj,
stack=Stack.NPM,
packages=hits,
precision=Precision.LOCK,
)
[docs]
class JLabExtension(Yarn):
"""A node variant specific to Jupyter-Lab
https://jupyterlab.readthedocs.io/en/latest/developer/contributing.html
#installing-node-js-and-jlpm
These projects have at least a front-end component, but build to python
wheels for distribution. A running jupyter-lab process might be considered
an output.
Some jlab projects will also have python components (i.e., server extensions
https://jupyter-server.readthedocs.io/en/latest/developers/extensions.html).
"""
icon = "🧩"
# TODO: we may add a jupyter server extension python project type in the
# future, defined by a JSON server config file.
def match(self):
return "package.json" in self.proj.basenames and bool(self.proj.pyproject)
def parse(self):
from projspec.artifact.python_env import LockFile
super().parse(ignore=True)
if not self.meta["scripts"].get("build", "").startswith("jlpm"):
raise ParseFailed("JLab extensions build with jlpm")
self.artifacts["lock_file"] = LockFile(
proj=self.proj,
cmd=["jlpm", "install"],
fn=f"{self.proj.url}/yarn.lock",
)
# create() with https://github.com/jupyterlab/extension-template
@staticmethod
def _create(path: str, name: str | None = None) -> None:
# this is a highly opinionated template
cmd = [
"copier",
"copy",
"--trust",
"--defaults",
"--data",
"author_name=you",
"--data",
f"kind=frontend-and-server",
"--data",
"repository=https://github.com/github_username/my-extension",
"https://github.com/jupyterlab/extension-template",
path,
]
run_subprocess(cmd, cwd=path, output=False)