From 76f5bde56ebee887cffb752acc21d00d4f1c5770 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 12:59:32 +0530 Subject: [PATCH] pack: add --include-env Align Python pack with the TypeScript CLI: optional inclusion of .env files in the wheel, with a warning that lists paths without printing values. --- README.md | 3 ++- nitrostack/cli/main.py | 9 +++++-- nitrostack/cli/pack.py | 60 +++++++++++++++++++++++++++++++++--------- tests/test_cli.py | 32 ++++++++++++++++++++++ 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ff05e8d..0f55682 100644 --- a/README.md +++ b/README.md @@ -491,9 +491,10 @@ Attach generated pipeline classes with `@use_guards`, `@use_pipes`, `@use_interc ```bash nitrostack-py pack --dry-run # list files; does not write an artifact nitrostack-py pack # write dist/*.whl +nitrostack-py pack --include-env # also pack .env files (use with caution!) ``` -`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. The real `.env` file and other secrets are never packed. Temporary build directories are deleted afterwards. +`pack` builds a wheel with setuptools (the same backend as this SDK), refreshes `requirements.txt` from `pyproject.toml` when possible, and always includes `.env.example`. `.env`, `.env.local`, and `.env.production` are excluded unless you pass `--include-env`. Temporary build directories are deleted afterwards. ### Upgrade, install, validate diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 6e5553c..e570c03 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1795,13 +1795,18 @@ def main(): # pack command pack_parser = subparsers.add_parser( "pack", - help="Build a deployable wheel of the current project (never includes .env/secrets)", + help="Build a deployable wheel of the current project (excludes .env by default)", ) pack_parser.add_argument( "--dry-run", action="store_true", help="Show files that would be packed without creating the artifact", ) + pack_parser.add_argument( + "--include-env", + action="store_true", + help="Include .env files in the wheel (use with caution!)", + ) # upgrade command upgrade_parser = subparsers.add_parser( @@ -1881,7 +1886,7 @@ def _run_command(fn, *fn_args, **fn_kwargs): else: generate_component(args.generator, args.name) elif args.command == "pack": - _run_command(pack_project, dry_run=args.dry_run) + _run_command(pack_project, dry_run=args.dry_run, include_env=args.include_env) elif args.command == "upgrade": _run_command( upgrade_project, diff --git a/nitrostack/cli/pack.py b/nitrostack/cli/pack.py index b2a805c..221a6bc 100644 --- a/nitrostack/cli/pack.py +++ b/nitrostack/cli/pack.py @@ -1,8 +1,9 @@ """Project packing for `nitrostack-py pack`. Builds a deployable wheel of the current project using the setuptools backend -already declared in NitroStack's packaging config. Secrets (``.env``) are never -included. Temporary build artifacts are cleaned up after packing. +already declared in NitroStack's packaging config. Secrets (``.env``) are excluded +by default; pass ``include_env`` to pack them. Temporary build artifacts are +cleaned up after packing. """ from __future__ import annotations @@ -110,10 +111,17 @@ def _matches_gitignore(rel_posix: str, patterns: Sequence[Tuple[str, bool]]) -> return ignored -def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence[Tuple[str, bool]]) -> bool: +def _should_exclude( + rel_posix: str, + name: str, + is_dir: bool, + gitignore: Sequence[Tuple[str, bool]], + *, + include_env: bool = False, +) -> bool: if name == ".env.example": return False - if _is_secret_env(name): + if _is_secret_env(name) and not include_env: return True if name in _EXCLUDE_DIR_NAMES and is_dir: return True @@ -128,7 +136,7 @@ def _should_exclude(rel_posix: str, name: str, is_dir: bool, gitignore: Sequence return False -def collect_pack_files(root: str) -> List[str]: +def collect_pack_files(root: str, *, include_env: bool = False) -> List[str]: """Return posix-relative paths that would be included in a pack.""" gitignore = _load_gitignore_patterns(root) included: List[str] = [] @@ -141,14 +149,14 @@ def collect_pack_files(root: str) -> List[str]: kept_dirs = [] for dirname in dirnames: child_rel = f"{rel_dir_posix}/{dirname}" if rel_dir_posix else dirname - if _should_exclude(child_rel, dirname, True, gitignore): + if _should_exclude(child_rel, dirname, True, gitignore, include_env=include_env): continue kept_dirs.append(dirname) dirnames[:] = kept_dirs for filename in filenames: child_rel = f"{rel_dir_posix}/{filename}" if rel_dir_posix else filename - if _should_exclude(child_rel, filename, False, gitignore): + if _should_exclude(child_rel, filename, False, gitignore, include_env=include_env): continue included.append(child_rel) @@ -348,14 +356,14 @@ def _is_valid_wheel(path: str) -> bool: return "Wheel-Version:" in body -def _collect_staging_bytes(src_root: str) -> dict: +def _collect_staging_bytes(src_root: str, *, include_env: bool = False) -> dict: contents = {} for dirpath, dirnames, filenames in os.walk(src_root): dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIR_NAMES and not d.endswith(".egg-info")] for filename in filenames: if any(filename.endswith(suffix) for suffix in _EXCLUDE_SUFFIXES): continue - if _is_secret_env(filename): + if _is_secret_env(filename) and not include_env: continue full = os.path.join(dirpath, filename) rel = os.path.relpath(full, src_root).replace("\\", "/") @@ -370,6 +378,8 @@ def _write_purelib_wheel( name: str, version: str, deps: Sequence[str], + *, + include_env: bool = False, ) -> str: """Write a PEP 427 py3-none-any wheel from the staged project files.""" os.makedirs(wheel_dir, exist_ok=True) @@ -379,7 +389,7 @@ def _write_purelib_wheel( wheel_path = os.path.join(wheel_dir, wheel_filename) dist_info = f"{dist_name}-{version}.dist-info" - contents = _collect_staging_bytes(src_root) + contents = _collect_staging_bytes(src_root, include_env=include_env) metadata_lines = [ "Metadata-Version: 2.1", f"Name: {display_name}", @@ -441,18 +451,34 @@ def _build_wheel( name: str, version: str, deps: Sequence[str], + *, + include_env: bool = False, ) -> str: os.makedirs(wheel_dir, exist_ok=True) built = _build_wheel_with_setuptools(src_root, wheel_dir) if built: return built - return _write_purelib_wheel(src_root, wheel_dir, name, version, deps) + return _write_purelib_wheel( + src_root, wheel_dir, name, version, deps, include_env=include_env + ) + + +def _secret_env_paths_in_pack(files: Sequence[str]) -> List[str]: + return sorted(rel for rel in files if _is_secret_env(rel.rsplit("/", 1)[-1])) + + +def _warn_including_env_files(env_files: Sequence[str]) -> None: + if not env_files: + return + listed = ", ".join(env_files) + print(f"Warning: Including .env files in the wheel (use with caution!): {listed}") def pack_project( root: Optional[str] = None, *, dry_run: bool = False, + include_env: bool = False, output_dir: Optional[str] = None, ) -> dict: """Pack the project at ``root``. @@ -462,12 +488,15 @@ def pack_project( root = os.path.abspath(root or os.getcwd()) from nitrostack.cli.vendor import ensure_vendored_nitrostack ensure_vendored_nitrostack(root) - files = collect_pack_files(root) + files = collect_pack_files(root, include_env=include_env) name, version = _project_name_and_version(root) dist_name = _pep503_wheel_name(_normalize_dist_name(name)) planned_wheel = f"{dist_name}-{version}-py3-none-any.whl" dest_dir = os.path.abspath(output_dir or os.path.join(root, "dist")) + if include_env: + _warn_including_env_files(_secret_env_paths_in_pack(files)) + print("Files that would be packed:" if dry_run else "Packing files:") for rel in files: print(f" {rel}") @@ -486,12 +515,17 @@ def pack_project( write_requirements_txt(staging, deps) _ensure_pyproject(staging, name, version, deps) - wheel_path = _build_wheel(staging, wheel_tmp, name, version, deps) + wheel_path = _build_wheel( + staging, wheel_tmp, name, version, deps, include_env=include_env + ) extra = [] env_example = os.path.join(staging, ".env.example") reqs = os.path.join(staging, "requirements.txt") extra.append((".env.example", env_example)) extra.append(("requirements.txt", reqs)) + if include_env: + for rel in _secret_env_paths_in_pack(files): + extra.append((rel, os.path.join(staging, rel.replace("/", os.sep)))) _inject_into_wheel(wheel_path, extra) if not _is_valid_wheel(wheel_path): diff --git a/tests/test_cli.py b/tests/test_cli.py index 95046d9..40b218e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1072,6 +1072,38 @@ def test_pack_dry_run_lists_files_without_writing(tmp_path: Path): assert not (project / "dist").exists() +def test_pack_include_env_dry_run_lists_env_without_printing_contents(tmp_path: Path, capsys): + project = _mini_project(tmp_path) + (project / ".env.local").write_text("TOKEN=local-secret\n", encoding="utf-8") + result = pack_project(str(project), dry_run=True, include_env=True) + assert result["dry_run"] is True + assert ".env" in result["files"] + assert ".env.local" in result["files"] + captured = capsys.readouterr().out + assert "Warning: Including .env files in the wheel (use with caution!)" in captured + assert ".env" in captured + assert ".env.local" in captured + assert "SECRET=should-never-pack" not in captured + assert "TOKEN=local-secret" not in captured + + +def test_pack_include_env_puts_env_in_wheel_without_printing_contents(tmp_path: Path, capsys): + project = _mini_project(tmp_path) + result = pack_project(str(project), dry_run=False, include_env=True) + captured = capsys.readouterr().out + assert "Warning: Including .env files in the wheel (use with caution!)" in captured + assert "SECRET=should-never-pack" not in captured + + wheel = Path(result["wheel"]) + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + assert any(n == ".env" or n.endswith("/.env") for n in names) + env_data = next( + zf.read(n).decode("utf-8") for n in names if n == ".env" or n.endswith("/.env") + ) + assert "SECRET=should-never-pack" in env_data + + def test_pack_creates_valid_wheel(tmp_path: Path): project = _mini_project(tmp_path) result = pack_project(str(project), dry_run=False)