Flask-Node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flask_node/__init__.py +15 -0
- flask_node/cli.py +78 -0
- flask_node/exceptions.py +41 -0
- flask_node/extension.py +76 -0
- flask_node/manager.py +162 -0
- flask_node/package.py +36 -0
- flask_node/py.typed +0 -0
- flask_node/runner.py +44 -0
- flask_node-0.1.0.dist-info/METADATA +185 -0
- flask_node-0.1.0.dist-info/RECORD +13 -0
- flask_node-0.1.0.dist-info/WHEEL +5 -0
- flask_node-0.1.0.dist-info/licenses/LICENSE +21 -0
- flask_node-0.1.0.dist-info/top_level.txt +1 -0
flask_node/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Generic Node/npm infrastructure for Flask applications."""
|
|
2
|
+
from .extension import Node
|
|
3
|
+
from .manager import NodeManager
|
|
4
|
+
from .package import Package
|
|
5
|
+
from .runner import CommandResult, CommandRunner
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
NodeError, ConfigurationError, EnvironmentError, ExecutableNotFoundError,
|
|
8
|
+
CommandExecutionError, DependencyConflictError, PackageNotFoundError,
|
|
9
|
+
AssetResolutionError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["Node", "NodeManager", "Package", "CommandResult", "CommandRunner", "NodeError",
|
|
13
|
+
"ConfigurationError", "EnvironmentError", "ExecutableNotFoundError",
|
|
14
|
+
"CommandExecutionError", "DependencyConflictError", "PackageNotFoundError",
|
|
15
|
+
"AssetResolutionError"]
|
flask_node/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Thin Flask CLI adapters."""
|
|
2
|
+
from functools import wraps
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
from flask import current_app
|
|
6
|
+
from flask.cli import with_appcontext
|
|
7
|
+
from .exceptions import NodeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def operation(function):
|
|
11
|
+
@wraps(function)
|
|
12
|
+
@with_appcontext
|
|
13
|
+
def wrapped(*args, **kwargs):
|
|
14
|
+
try:
|
|
15
|
+
return function(current_app.extensions["node"], *args, **kwargs)
|
|
16
|
+
except NodeError as exc:
|
|
17
|
+
raise click.ClickException(str(exc)) from exc
|
|
18
|
+
return wrapped
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group("node")
|
|
22
|
+
def node_cli():
|
|
23
|
+
"""Manage the application's isolated Node environment."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@node_cli.command("init")
|
|
27
|
+
@operation
|
|
28
|
+
def initialize(manager):
|
|
29
|
+
"""Create or validate the managed package manifest."""
|
|
30
|
+
click.echo(f"Initialized {manager.initialize()}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@node_cli.command()
|
|
34
|
+
@click.argument("package", required=False)
|
|
35
|
+
@click.option("--version")
|
|
36
|
+
@click.option("--dev", is_flag=True)
|
|
37
|
+
@operation
|
|
38
|
+
def install(manager, package, version, dev):
|
|
39
|
+
"""Install declarations or a registry package."""
|
|
40
|
+
manager.install(package, version, dev=dev, capture_output=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@node_cli.command()
|
|
44
|
+
@click.argument("package")
|
|
45
|
+
@operation
|
|
46
|
+
def uninstall(manager, package):
|
|
47
|
+
"""Remove an npm dependency."""
|
|
48
|
+
manager.uninstall(package, capture_output=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@node_cli.command()
|
|
52
|
+
@operation
|
|
53
|
+
def ci(manager):
|
|
54
|
+
"""Install reproducibly from the existing lockfile."""
|
|
55
|
+
manager.ci(capture_output=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@node_cli.command()
|
|
59
|
+
@operation
|
|
60
|
+
def status(manager):
|
|
61
|
+
"""Show environment and executable locations without launching commands."""
|
|
62
|
+
click.echo(json.dumps(manager.status(), indent=2))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@node_cli.command(context_settings={"ignore_unknown_options": True})
|
|
66
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
67
|
+
@operation
|
|
68
|
+
def npm(manager, args):
|
|
69
|
+
"""Forward arguments to npm in the managed directory."""
|
|
70
|
+
manager.npm(*args, capture_output=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@node_cli.command(context_settings={"ignore_unknown_options": True})
|
|
74
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
75
|
+
@operation
|
|
76
|
+
def npx(manager, args):
|
|
77
|
+
"""Forward arguments to npx in the managed directory."""
|
|
78
|
+
manager.npx(*args, capture_output=False)
|
flask_node/exceptions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Public exceptions raised by Flask-Node."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NodeError(Exception):
|
|
5
|
+
"""Base error for managed Node operations."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(NodeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnvironmentError(NodeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExecutableNotFoundError(NodeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandExecutionError(NodeError):
|
|
21
|
+
def __init__(self, message: str, *, args: tuple[str, ...], cwd: str,
|
|
22
|
+
returncode: int | None = None, stdout: str | None = None,
|
|
23
|
+
stderr: str | None = None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.command = args
|
|
26
|
+
self.cwd = cwd
|
|
27
|
+
self.returncode = returncode
|
|
28
|
+
self.stdout = stdout
|
|
29
|
+
self.stderr = stderr
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DependencyConflictError(NodeError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PackageNotFoundError(NodeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AssetResolutionError(NodeError):
|
|
41
|
+
pass
|
flask_node/extension.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Flask integration with no initialization side effects."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
from flask import Flask, current_app
|
|
5
|
+
from .exceptions import ConfigurationError
|
|
6
|
+
from .manager import NodeManager
|
|
7
|
+
from .runner import CommandRunner, CommandResult
|
|
8
|
+
from .package import Package
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Node:
|
|
12
|
+
def __init__(self, app: Flask | None = None, *, runner: CommandRunner | None = None):
|
|
13
|
+
self._runner = runner
|
|
14
|
+
if app is not None:
|
|
15
|
+
self.init_app(app)
|
|
16
|
+
|
|
17
|
+
def init_app(self, app: Flask) -> None:
|
|
18
|
+
if "node" in app.extensions:
|
|
19
|
+
raise ConfigurationError("Flask-Node is already initialized on this application.")
|
|
20
|
+
defaults = {"NODE_DIR": ".node", "NODE_BIN": "node", "NODE_NPM_BIN": "npm", "NODE_NPX_BIN": "npx"}
|
|
21
|
+
for key, default in defaults.items():
|
|
22
|
+
app.config.setdefault(key, default)
|
|
23
|
+
value = app.config["NODE_DIR"]
|
|
24
|
+
if not isinstance(value, (str, Path)) or not str(value).strip():
|
|
25
|
+
raise ConfigurationError("NODE_DIR must be a nonempty path.")
|
|
26
|
+
directory = Path(value)
|
|
27
|
+
if not directory.is_absolute():
|
|
28
|
+
directory = Path(app.root_path) / directory
|
|
29
|
+
for key in ("NODE_BIN", "NODE_NPM_BIN", "NODE_NPX_BIN"):
|
|
30
|
+
if not isinstance(app.config[key], str) or not app.config[key].strip():
|
|
31
|
+
raise ConfigurationError(f"{key} must be a nonempty executable path.")
|
|
32
|
+
app.extensions["node"] = NodeManager(
|
|
33
|
+
directory, node_bin=app.config["NODE_BIN"], npm_bin=app.config["NODE_NPM_BIN"],
|
|
34
|
+
npx_bin=app.config["NODE_NPX_BIN"], runner=self._runner,
|
|
35
|
+
)
|
|
36
|
+
from .cli import node_cli
|
|
37
|
+
app.cli.add_command(node_cli)
|
|
38
|
+
|
|
39
|
+
def get_manager(self, app: Flask | None = None) -> NodeManager:
|
|
40
|
+
application = app if app is not None else current_app
|
|
41
|
+
manager = application.extensions.get("node")
|
|
42
|
+
if not isinstance(manager, NodeManager):
|
|
43
|
+
raise ConfigurationError("Initialize Flask-Node before using its API.")
|
|
44
|
+
return manager
|
|
45
|
+
|
|
46
|
+
# Delegate the documented manager API while keeping application state local.
|
|
47
|
+
def initialize(self) -> Path:
|
|
48
|
+
return self.get_manager().initialize()
|
|
49
|
+
|
|
50
|
+
def require(self, name: str, version: str = "*", *, dev: bool = False) -> None:
|
|
51
|
+
return self.get_manager().require(name, version, dev=dev)
|
|
52
|
+
|
|
53
|
+
def install(self, name: str | None = None, version: str | None = None, *, dev: bool = False,
|
|
54
|
+
capture_output: bool = True) -> CommandResult:
|
|
55
|
+
return self.get_manager().install(name, version, dev=dev, capture_output=capture_output)
|
|
56
|
+
|
|
57
|
+
def uninstall(self, name: str, *, capture_output: bool = True) -> CommandResult:
|
|
58
|
+
return self.get_manager().uninstall(name, capture_output=capture_output)
|
|
59
|
+
|
|
60
|
+
def npm(self, *args: str, capture_output: bool = True) -> CommandResult:
|
|
61
|
+
return self.get_manager().npm(*args, capture_output=capture_output)
|
|
62
|
+
|
|
63
|
+
def npx(self, *args: str, capture_output: bool = True) -> CommandResult:
|
|
64
|
+
return self.get_manager().npx(*args, capture_output=capture_output)
|
|
65
|
+
|
|
66
|
+
def ci(self, *, capture_output: bool = True) -> CommandResult:
|
|
67
|
+
return self.get_manager().ci(capture_output=capture_output)
|
|
68
|
+
|
|
69
|
+
def package(self, name: str) -> Package:
|
|
70
|
+
return self.get_manager().package(name)
|
|
71
|
+
|
|
72
|
+
def resolve(self, name: str, asset: str | Path) -> Path:
|
|
73
|
+
return self.get_manager().resolve(name, asset)
|
|
74
|
+
|
|
75
|
+
def status(self) -> dict[str, Any]:
|
|
76
|
+
return self.get_manager().status()
|
flask_node/manager.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Application-local Node project lifecycle and dependency operations."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import (ConfigurationError, DependencyConflictError, EnvironmentError,
|
|
8
|
+
PackageNotFoundError, AssetResolutionError)
|
|
9
|
+
from .package import Package, validate_name
|
|
10
|
+
from .runner import CommandResult, CommandRunner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Dependency:
|
|
15
|
+
version: str
|
|
16
|
+
dev: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NodeManager:
|
|
20
|
+
def __init__(self, directory: Path, *, node_bin: str = "node", npm_bin: str = "npm",
|
|
21
|
+
npx_bin: str = "npx", runner: CommandRunner | None = None):
|
|
22
|
+
self.directory = directory.resolve()
|
|
23
|
+
self.node_bin, self.npm_bin, self.npx_bin = node_bin, npm_bin, npx_bin
|
|
24
|
+
self.runner = runner if runner is not None else CommandRunner()
|
|
25
|
+
self._requirements: dict[str, Dependency] = {}
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def manifest_path(self) -> Path:
|
|
29
|
+
return self.directory / "package.json"
|
|
30
|
+
|
|
31
|
+
def _read_manifest(self) -> dict[str, Any]:
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
raise ValueError("manifest must be an object")
|
|
36
|
+
for section in ("dependencies", "devDependencies"):
|
|
37
|
+
dependencies = data.get(section, {})
|
|
38
|
+
if not isinstance(dependencies, dict) or not all(
|
|
39
|
+
isinstance(k, str) and isinstance(v, str) for k, v in dependencies.items()
|
|
40
|
+
):
|
|
41
|
+
raise ValueError(f"{section} must map package names to strings")
|
|
42
|
+
return data
|
|
43
|
+
except (OSError, ValueError) as exc:
|
|
44
|
+
raise EnvironmentError(f"Cannot read {self.manifest_path}: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
def _write_manifest(self, data: dict[str, Any]) -> None:
|
|
47
|
+
try:
|
|
48
|
+
self.manifest_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
raise EnvironmentError(f"Cannot write {self.manifest_path}: {exc}") from exc
|
|
51
|
+
|
|
52
|
+
def initialize(self) -> Path:
|
|
53
|
+
try:
|
|
54
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
# Exclusive creation preserves existing manifests, including concurrent init.
|
|
56
|
+
with self.manifest_path.open("x", encoding="utf-8") as stream:
|
|
57
|
+
json.dump({"private": True, "dependencies": {}, "devDependencies": {}}, stream, indent=2)
|
|
58
|
+
stream.write("\n")
|
|
59
|
+
except FileExistsError:
|
|
60
|
+
self._read_manifest()
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise EnvironmentError(f"Cannot initialize {self.directory}: {exc}") from exc
|
|
63
|
+
return self.directory
|
|
64
|
+
|
|
65
|
+
def require(self, name: str, version: str = "*", *, dev: bool = False) -> None:
|
|
66
|
+
validate_name(name)
|
|
67
|
+
self._validate_version(version)
|
|
68
|
+
dependency = Dependency(version, dev)
|
|
69
|
+
previous = self._requirements.get(name)
|
|
70
|
+
if previous is not None and previous != dependency:
|
|
71
|
+
raise DependencyConflictError(f"Conflicting requirements for {name}: {previous} and {dependency}")
|
|
72
|
+
self._requirements[name] = dependency
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _validate_version(version: str) -> None:
|
|
76
|
+
if not isinstance(version, str) or not version.strip() or "\x00" in version:
|
|
77
|
+
raise ConfigurationError("A dependency version must be a nonempty string.")
|
|
78
|
+
|
|
79
|
+
def _merge_requirements(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
for name, dependency in self._requirements.items():
|
|
81
|
+
section = "devDependencies" if dependency.dev else "dependencies"
|
|
82
|
+
other = "dependencies" if dependency.dev else "devDependencies"
|
|
83
|
+
data.setdefault(other, {}).pop(name, None)
|
|
84
|
+
data.setdefault(section, {})[name] = dependency.version
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
def install(self, name: str | None = None, version: str | None = None, *,
|
|
88
|
+
dev: bool = False, capture_output: bool = True) -> CommandResult:
|
|
89
|
+
if name is None and (version is not None or dev):
|
|
90
|
+
raise ConfigurationError("version/dev require a package name.")
|
|
91
|
+
if name is not None:
|
|
92
|
+
validate_name(name)
|
|
93
|
+
if version is not None:
|
|
94
|
+
self._validate_version(version)
|
|
95
|
+
required = self._requirements.get(name)
|
|
96
|
+
if required and (required.dev != dev or (version is not None and version != required.version)):
|
|
97
|
+
raise DependencyConflictError(f"Installation contradicts the declared requirement for {name}.")
|
|
98
|
+
if required:
|
|
99
|
+
version = required.version
|
|
100
|
+
self.initialize()
|
|
101
|
+
self._write_manifest(self._merge_requirements(self._read_manifest()))
|
|
102
|
+
args = ["install"]
|
|
103
|
+
if name is not None:
|
|
104
|
+
args.extend(["--save-dev" if dev else "--save-prod", "--",
|
|
105
|
+
name + (f"@{version}" if version is not None else "")])
|
|
106
|
+
return self.npm(*args, capture_output=capture_output)
|
|
107
|
+
|
|
108
|
+
def uninstall(self, name: str, *, capture_output: bool = True) -> CommandResult:
|
|
109
|
+
validate_name(name)
|
|
110
|
+
if name in self._requirements:
|
|
111
|
+
raise DependencyConflictError(f"Cannot uninstall actively required package {name}.")
|
|
112
|
+
return self.npm("uninstall", "--", name, capture_output=capture_output)
|
|
113
|
+
|
|
114
|
+
def _check_environment(self) -> None:
|
|
115
|
+
if not self.manifest_path.is_file():
|
|
116
|
+
raise EnvironmentError("Node environment is not initialized. Run 'flask node init'.")
|
|
117
|
+
self._read_manifest()
|
|
118
|
+
|
|
119
|
+
def npm(self, *args: str, capture_output: bool = True) -> CommandResult:
|
|
120
|
+
self._check_environment()
|
|
121
|
+
return self.runner.run(self.npm_bin, args, cwd=self.directory, capture_output=capture_output)
|
|
122
|
+
|
|
123
|
+
def npx(self, *args: str, capture_output: bool = True) -> CommandResult:
|
|
124
|
+
self._check_environment()
|
|
125
|
+
return self.runner.run(self.npx_bin, args, cwd=self.directory, capture_output=capture_output)
|
|
126
|
+
|
|
127
|
+
def ci(self, *, capture_output: bool = True) -> CommandResult:
|
|
128
|
+
self._check_environment()
|
|
129
|
+
if not (self.directory / "package-lock.json").is_file():
|
|
130
|
+
raise EnvironmentError("npm ci requires package-lock.json. Run 'flask node install' first.")
|
|
131
|
+
data = self._read_manifest()
|
|
132
|
+
for name, dependency in self._requirements.items():
|
|
133
|
+
section = "devDependencies" if dependency.dev else "dependencies"
|
|
134
|
+
other = "dependencies" if dependency.dev else "devDependencies"
|
|
135
|
+
if data.get(section, {}).get(name) != dependency.version or name in data.get(other, {}):
|
|
136
|
+
raise DependencyConflictError(f"Manifest disagrees with the declared requirement for {name}.")
|
|
137
|
+
return self.npm("ci", capture_output=capture_output)
|
|
138
|
+
|
|
139
|
+
def package(self, name: str) -> Package:
|
|
140
|
+
validate_name(name)
|
|
141
|
+
modules = (self.directory / "node_modules").resolve()
|
|
142
|
+
root = (modules / name).resolve()
|
|
143
|
+
if not modules.is_relative_to(self.directory) or not root.is_relative_to(modules):
|
|
144
|
+
raise AssetResolutionError(f"Package {name} escapes the managed environment.")
|
|
145
|
+
try:
|
|
146
|
+
manifest = (root / "package.json").resolve()
|
|
147
|
+
if not manifest.is_relative_to(root):
|
|
148
|
+
raise AssetResolutionError(f"Package manifest escapes {name}.")
|
|
149
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
150
|
+
if not isinstance(data, dict) or not isinstance(data.get("version"), str):
|
|
151
|
+
raise ValueError("missing package version")
|
|
152
|
+
except (OSError, ValueError) as exc:
|
|
153
|
+
raise PackageNotFoundError(f"Package {name!r} is not installed or has an invalid manifest.") from exc
|
|
154
|
+
return Package(name, data["version"], root)
|
|
155
|
+
|
|
156
|
+
def resolve(self, name: str, asset: str | Path) -> Path:
|
|
157
|
+
return self.package(name).resolve(asset)
|
|
158
|
+
|
|
159
|
+
def status(self) -> dict[str, Any]:
|
|
160
|
+
return {"directory": str(self.directory), "initialized": self.manifest_path.is_file(),
|
|
161
|
+
"node": self.runner.locate(self.node_bin), "npm": self.runner.locate(self.npm_bin),
|
|
162
|
+
"npx": self.runner.locate(self.npx_bin)}
|
flask_node/package.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Safe access to installed package files, without asset delivery."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path, PureWindowsPath
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .exceptions import AssetResolutionError, ConfigurationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_NAME = re.compile(r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*\Z")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_name(name: str) -> str:
|
|
13
|
+
if not isinstance(name, str) or not _NAME.fullmatch(name):
|
|
14
|
+
raise ConfigurationError(f"Invalid registry package name: {name!r}")
|
|
15
|
+
return name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Package:
|
|
20
|
+
name: str
|
|
21
|
+
version: str
|
|
22
|
+
root: Path
|
|
23
|
+
|
|
24
|
+
def resolve(self, asset: str | Path) -> Path:
|
|
25
|
+
value = str(asset)
|
|
26
|
+
relative = Path(value)
|
|
27
|
+
if (relative.is_absolute() or PureWindowsPath(value).drive or
|
|
28
|
+
"\\" in value or ".." in relative.parts):
|
|
29
|
+
raise AssetResolutionError(f"Unsafe asset path: {value!r}")
|
|
30
|
+
try:
|
|
31
|
+
target = (self.root / relative).resolve(strict=True)
|
|
32
|
+
except (OSError, RuntimeError) as exc:
|
|
33
|
+
raise AssetResolutionError(f"Asset {value!r} is unavailable in {self.name}") from exc
|
|
34
|
+
if not target.is_relative_to(self.root):
|
|
35
|
+
raise AssetResolutionError(f"Asset escapes package {self.name}: {value!r}")
|
|
36
|
+
return target
|
flask_node/py.typed
ADDED
|
File without changes
|
flask_node/runner.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The single subprocess boundary for managed commands."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from .exceptions import CommandExecutionError, ExecutableNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class CommandResult:
|
|
12
|
+
args: tuple[str, ...]
|
|
13
|
+
returncode: int
|
|
14
|
+
stdout: str | None = None
|
|
15
|
+
stderr: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandRunner:
|
|
19
|
+
def locate(self, executable: str) -> str | None:
|
|
20
|
+
return shutil.which(executable)
|
|
21
|
+
|
|
22
|
+
def run(self, executable: str, args: tuple[str, ...], *, cwd: Path,
|
|
23
|
+
capture_output: bool = True) -> CommandResult:
|
|
24
|
+
located = self.locate(executable)
|
|
25
|
+
if located is None:
|
|
26
|
+
raise ExecutableNotFoundError(
|
|
27
|
+
f"Executable {executable!r} was not found. Install Node.js/npm or configure its path."
|
|
28
|
+
)
|
|
29
|
+
command = (located, *args)
|
|
30
|
+
try:
|
|
31
|
+
result = subprocess.run(command, cwd=cwd, shell=False, check=False,
|
|
32
|
+
text=True, capture_output=capture_output)
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
raise ExecutableNotFoundError(f"Cannot launch {executable!r}: {exc}") from exc
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
raise CommandExecutionError(str(exc), args=command, cwd=str(cwd)) from exc
|
|
37
|
+
if result.returncode:
|
|
38
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
39
|
+
raise CommandExecutionError(
|
|
40
|
+
f"{executable} exited with status {result.returncode}" +
|
|
41
|
+
(f": {detail}" if detail else "."), args=command, cwd=str(cwd),
|
|
42
|
+
returncode=result.returncode, stdout=result.stdout, stderr=result.stderr,
|
|
43
|
+
)
|
|
44
|
+
return CommandResult(command, result.returncode, result.stdout, result.stderr)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: Flask-Node
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Isolated Node/npm infrastructure for Flask extensions
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: Flask<4,>=2.2
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# Flask-Node
|
|
15
|
+
|
|
16
|
+
Flask-Node manages an isolated Node/npm project for Flask applications and
|
|
17
|
+
extensions. It contains no JavaScript library integrations, bundler, asset
|
|
18
|
+
server, or frontend framework.
|
|
19
|
+
|
|
20
|
+
Requires Python 3.10+ and Flask 2.2–3.x. Install Node.js (including npm/npx)
|
|
21
|
+
separately when executing commands; importing and initializing the Flask
|
|
22
|
+
extension does not require it.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install Flask-Node
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Application setup
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from flask import Flask
|
|
32
|
+
from flask_node import Node
|
|
33
|
+
|
|
34
|
+
node = Node()
|
|
35
|
+
|
|
36
|
+
def create_app():
|
|
37
|
+
app = Flask(__name__)
|
|
38
|
+
node.init_app(app)
|
|
39
|
+
return app
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`Node(app)` is also supported. `init_app()` registers defaults and the CLI,
|
|
43
|
+
without creating directories or launching commands. Each application owns a
|
|
44
|
+
separate `NodeManager` in `app.extensions["node"]`; a shared `Node` facade uses
|
|
45
|
+
the current application context. `node.get_manager(app)` gives explicit access
|
|
46
|
+
outside a context. Duplicate registration raises `ConfigurationError`.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
| Setting | Default | Meaning |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `NODE_DIR` | `.node` | Relative to `app.root_path`, or an absolute path |
|
|
53
|
+
| `NODE_BIN` | `node` | Node executable name or path |
|
|
54
|
+
| `NODE_NPM_BIN` | `npm` | npm executable name or path |
|
|
55
|
+
| `NODE_NPX_BIN` | `npx` | npx executable name or path |
|
|
56
|
+
|
|
57
|
+
Configure before calling `init_app()`. Configuration is captured per app.
|
|
58
|
+
For a directory beside an application package, configure an absolute project
|
|
59
|
+
path. No behavior depends on the shell's current directory.
|
|
60
|
+
|
|
61
|
+
## Lifecycle and dependencies
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
with app.app_context():
|
|
65
|
+
node.require("example", version="^1")
|
|
66
|
+
node.require("@scope/build-tool", version="^2", dev=True)
|
|
67
|
+
node.initialize()
|
|
68
|
+
node.install()
|
|
69
|
+
node.install("another-package", version="^3")
|
|
70
|
+
node.uninstall("another-package")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`require()` only records an in-memory declaration. Consumer extensions may call
|
|
74
|
+
it during application setup. It creates no files and does not check or install
|
|
75
|
+
packages. Identical declarations are idempotent; differing versions or dependency
|
|
76
|
+
sections raise `DependencyConflictError`. Version declarations are compared as
|
|
77
|
+
strings; Flask-Node does not solve semver ranges.
|
|
78
|
+
|
|
79
|
+
`initialize()` creates `.node/package.json` with `private: true` and empty
|
|
80
|
+
`dependencies` / `devDependencies`. It preserves an existing valid manifest
|
|
81
|
+
without rewriting it. Invalid manifests fail clearly. npm creates the lockfile
|
|
82
|
+
and installed modules later:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
.node/
|
|
86
|
+
package.json
|
|
87
|
+
package-lock.json
|
|
88
|
+
node_modules/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`install()` initializes if necessary, merges active declarations into the
|
|
92
|
+
manifest, preserves unrelated fields and dependencies, and runs `npm install`.
|
|
93
|
+
Declarations take precedence over persisted entries for the same package.
|
|
94
|
+
`install(name, version=None, dev=False)` additionally installs a registry package;
|
|
95
|
+
without a version, npm chooses it unless an active declaration supplies one.
|
|
96
|
+
Conflicting explicit options fail before installation. Uninstalling an actively
|
|
97
|
+
required package is rejected. npm failures may leave manifest changes or partial
|
|
98
|
+
installation artifacts; operations are not transactional.
|
|
99
|
+
|
|
100
|
+
`ci()` requires `package-lock.json` and declarations matching the manifest, then
|
|
101
|
+
runs `npm ci` without rewriting the manifest. npm validates lockfile consistency.
|
|
102
|
+
Commit `.node/package.json` and `.node/package-lock.json` for reproducible builds;
|
|
103
|
+
ignore `.node/node_modules/`.
|
|
104
|
+
|
|
105
|
+
## Commands and diagnostics
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
with app.app_context():
|
|
109
|
+
result = node.npm("run", "custom-script")
|
|
110
|
+
result = node.npx("some-tool", "--help", capture_output=False)
|
|
111
|
+
print(node.status())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Raw commands require an initialized environment and run with its directory as
|
|
115
|
+
`cwd`. Arguments are passed individually with `shell=False`. Python calls capture
|
|
116
|
+
output by default; use `capture_output=False` to inherit terminal streams.
|
|
117
|
+
`CommandResult` exposes `args`, `returncode`, `stdout`, and `stderr`.
|
|
118
|
+
|
|
119
|
+
`status()` reports the directory, manifest presence, and executable locations
|
|
120
|
+
(or `None`). It launches no processes and does not validate executable versions.
|
|
121
|
+
Missing executables raise `ExecutableNotFoundError`; nonzero exits and other
|
|
122
|
+
launch failures raise `CommandExecutionError`, which retains `command`, `cwd`,
|
|
123
|
+
`returncode`, `stdout`, and `stderr`. All public errors derive from `NodeError`.
|
|
124
|
+
|
|
125
|
+
npm/npx may access the network and run package scripts. The managed working
|
|
126
|
+
directory is not a process sandbox; invoked tools can write elsewhere.
|
|
127
|
+
|
|
128
|
+
## Flask CLI
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
flask --app your_app node init
|
|
132
|
+
flask --app your_app node install
|
|
133
|
+
flask --app your_app node install example --version '^1' --dev
|
|
134
|
+
flask --app your_app node uninstall example
|
|
135
|
+
flask --app your_app node npm -- run custom-script --flag
|
|
136
|
+
flask --app your_app node npx -- some-tool --help
|
|
137
|
+
flask --app your_app node ci
|
|
138
|
+
flask --app your_app node status
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
CLI commands use the same Python operations, stream subprocess output, and
|
|
142
|
+
report extension errors with a nonzero exit status. `--` separates forwarding
|
|
143
|
+
arguments from Click's own options.
|
|
144
|
+
|
|
145
|
+
## Installed assets and extension consumers
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
manager = app.extensions["node"]
|
|
149
|
+
manager.require("example", "^1") # Safe during consumer init_app().
|
|
150
|
+
|
|
151
|
+
# After an explicit installation step:
|
|
152
|
+
asset = manager.resolve("example", "dist/example.js")
|
|
153
|
+
package = manager.package("example")
|
|
154
|
+
assert package.resolve("dist/example.js") == asset
|
|
155
|
+
print(package.name, package.version, package.root)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The consumer must initialize Flask-Node first. It owns its library-specific
|
|
159
|
+
configuration, rendering, asset copying/serving, and build/watch commands.
|
|
160
|
+
Flask-Node only declares/installs dependencies, executes commands, and locates
|
|
161
|
+
files. No registration protocol is needed.
|
|
162
|
+
|
|
163
|
+
Package lookup supports ordinary and scoped registry names. Asset lookup returns
|
|
164
|
+
an existing `Path`; absolute paths, parent traversal, Windows-style paths, and
|
|
165
|
+
symlinks escaping the package or managed environment are rejected. Externally
|
|
166
|
+
linked packages are intentionally unsupported. Missing or malformed installed
|
|
167
|
+
packages raise `PackageNotFoundError`; unsafe or missing assets raise
|
|
168
|
+
`AssetResolutionError`. This is filesystem path validation, not protection
|
|
169
|
+
against concurrent malicious filesystem changes.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
python -m venv .venv
|
|
175
|
+
.venv/bin/python -m pip install -e '.[test]' build
|
|
176
|
+
.venv/bin/python -m pytest
|
|
177
|
+
.venv/bin/python -m build
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Tests inject/mock the runner and subprocess boundary. They never download npm
|
|
181
|
+
packages or require Node. A custom runner can be supplied to `Node(runner=...)`
|
|
182
|
+
or `NodeManager(..., runner=...)` for testing. This dependency injection is not a
|
|
183
|
+
consumer plugin protocol.
|
|
184
|
+
|
|
185
|
+
Inspired by [Flask-Tailwind-Manager](https://github.com/SebaSalinass/flask-tailwind-manager).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
flask_node/__init__.py,sha256=gRPSFdjat-Lc5qWFfuTENhxKYRRSwjvZ9unjWTlVBzc,702
|
|
2
|
+
flask_node/cli.py,sha256=3v8v443_E1Ibp5COLtL22QSFPanlxG7_5XQkmgPv4Dw,2132
|
|
3
|
+
flask_node/exceptions.py,sha256=QtPIEL0YphtdgPxVf6U8OFy379skumWS3ProIYEHsfw,841
|
|
4
|
+
flask_node/extension.py,sha256=hmGAZoNK3YCEUSArzz2Yp31F-0vBwx6AIP8pVs3QTGQ,3528
|
|
5
|
+
flask_node/manager.py,sha256=RbnAv8rGkF8GRUmrS8S_FfHAk200PF5UDpiuus9W37o,8199
|
|
6
|
+
flask_node/package.py,sha256=D0eo7WiQ8aCWr889t_bOzzwIBjTRL-KFMyeibYbTPvs,1270
|
|
7
|
+
flask_node/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
flask_node/runner.py,sha256=3bIhrgf0_S288QhdQ_x2OaC4Z77BZAvWjjtI4j1BN68,1792
|
|
9
|
+
flask_node-0.1.0.dist-info/licenses/LICENSE,sha256=eb9NV436hjrM4Ra_SC_Vl1hpk5tYtXojbOi4tmgJ3CI,1074
|
|
10
|
+
flask_node-0.1.0.dist-info/METADATA,sha256=g2mdmeqlnFRKh1AyweHuZQCtANmiPHp4KuuacFpaYbM,7062
|
|
11
|
+
flask_node-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
flask_node-0.1.0.dist-info/top_level.txt,sha256=hI1qQ_O5mRkLX0gHmrmf7dQQqWH5ngGhDZG4RlW1he4,11
|
|
13
|
+
flask_node-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Salinas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flask_node
|