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 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)
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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