Skip to content

Commit 724e72f

Browse files
committed
feat: add configurable output directory support
- Add output.directory field to configuration - Create resolve_output_path_from_config() helper function - Update api.py and build.py to use new helper - Add validation for output.directory field - Add tests for directory + name and directory-only configurations - Update documentation with output.directory examples - Mark feature as completed in ROADMAP.md
1 parent 59e3d99 commit 724e72f

File tree

6 files changed

+165
-52
lines changed

6 files changed

+165
-52
lines changed

ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Some of these we just want to consider, and may not want to implement.
2222

2323
### Phase 2: Configuration System
2424
- **Config File Support**: `.zipbundler.py`, `.zipbundler.jsonc`, or `pyproject.toml` integration (searches current directory and parent directories) ✅
25-
- **Output Path**: Configurable output directory and filename
25+
- **Output Path**: Configurable output directory and filename
2626
- **Entry Point Configuration**: Define entry points in config file
2727
- **Metadata Auto-Detection**: `init` command auto-detects metadata from `pyproject.toml` when creating config files ✅
2828

@@ -59,7 +59,8 @@ Some of these we just want to consider, and may not want to implement.
5959
// Output configuration
6060
"output": {
6161
"path": "dist/my_package.zip",
62-
"name": "my_package" // Optional: used to generate default path (dist/{name}.pyz) when path is not specified
62+
"directory": "build", // Optional: output directory (default: "dist")
63+
"name": "my_package" // Optional: used to generate default path ({directory}/{name}.pyz) when path is not specified
6364
},
6465

6566
// Entry point for executable zip

docs/configuration.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ Output configuration for the zip file.
7070
{
7171
"output": {
7272
"path": "dist/my_package.zip",
73+
"directory": "build", // Optional: output directory (default: "dist")
7374
"name": "my_package" // Optional: override zip name
7475
}
7576
}
7677
```
7778

78-
- `path`: Full path to output zip file (default: `dist/{package_name}.zip`)
79-
- `name`: Optional name override for the zip file
79+
- `path`: Full path to output zip file (takes precedence over `directory` and `name`)
80+
- `directory`: Output directory (default: `"dist"`). Used with `name` to generate path when `path` is not specified
81+
- `name`: Optional name override for the zip file. When used with `directory`, generates `{directory}/{name}.pyz`
8082

8183
#### `entry_point` (optional)
8284
Entry point for executable zip files (equivalent to `-m` / `--main` in zipapp-style CLI).
@@ -175,6 +177,7 @@ version = "1.0.0"
175177
],
176178
"output": {
177179
"path": "dist/my_package.zip",
180+
"directory": "build",
178181
"name": "my_package"
179182
},
180183
"entry_point": "my_package.__main__:main",

src/zipbundler/api.py

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from .actions import watch_for_changes
1515
from .build import build_zipapp, extract_archive_to_tempdir, list_files
1616
from .commands.build import extract_entry_point_code
17-
from .commands.validate import find_config, load_config, validate_config_structure
17+
from .commands.validate import (
18+
find_config,
19+
load_config,
20+
resolve_output_path_from_config,
21+
validate_config_structure,
22+
)
1823
from .commands.zipapp_style import is_archive_file
1924
from .constants import DEFAULT_WATCH_INTERVAL
2025
from .logs import getAppLogger
@@ -289,17 +294,7 @@ def build_zip( # noqa: C901, PLR0912, PLR0913, PLR0915
289294
# Extract output path from config
290295
if output_path is None:
291296
output_config: dict[str, Any] | None = config.get("output")
292-
if output_config:
293-
output_path_str: str | None = output_config.get("path")
294-
output_name: str | None = output_config.get("name")
295-
if output_path_str:
296-
output_path = Path(output_path_str)
297-
elif output_name:
298-
output_path = Path(f"dist/{output_name}.pyz")
299-
else:
300-
output_path = Path("dist/bundle.pyz")
301-
else:
302-
output_path = Path("dist/bundle.pyz")
297+
output_path = resolve_output_path_from_config(output_config)
303298

304299
# Extract options from config
305300
options: dict[str, Any] | None = config.get("options")
@@ -402,7 +397,7 @@ def build_zip( # noqa: C901, PLR0912, PLR0913, PLR0915
402397
)
403398

404399

405-
def watch( # noqa: C901, PLR0912
400+
def watch(
406401
config_path: str | Path | None = None,
407402
*,
408403
packages: list[str] | None = None,
@@ -448,23 +443,13 @@ def watch( # noqa: C901, PLR0912
448443
exclude = config.get("exclude")
449444
if output_path is None:
450445
output_config: dict[str, Any] | None = config.get("output")
451-
if output_config:
452-
output_path_str: str | None = output_config.get("path")
453-
output_name: str | None = output_config.get("name")
454-
if output_path_str:
455-
output_path = Path(output_path_str)
456-
elif output_name:
457-
output_path = Path(f"dist/{output_name}.pyz")
458-
else:
459-
output_path = Path("dist/bundle.pyz")
460-
else:
461-
output_path = Path("dist/bundle.pyz")
446+
output_path = resolve_output_path_from_config(output_config)
462447

463448
if not packages:
464449
msg = "packages must be provided if config_path is not specified"
465450
raise ValueError(msg)
466451
if output_path is None:
467-
output_path = Path("dist/bundle.pyz")
452+
output_path = resolve_output_path_from_config(None)
468453

469454
# Resolve output path relative to cwd
470455
if not isinstance(output_path, Path):

src/zipbundler/commands/build.py

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from zipbundler.commands.validate import (
1515
find_config,
1616
load_config,
17+
resolve_output_path_from_config,
1718
validate_config_structure,
1819
)
1920
from zipbundler.logs import getAppLogger
@@ -323,29 +324,12 @@ def handle_build_command(args: argparse.Namespace) -> int: # noqa: C901, PLR091
323324

324325
# Extract output path
325326
output_config: dict[str, Any] | None = config.get("output")
326-
if output_config: # pyright: ignore[reportUnnecessaryIsInstance]
327-
output_path_str: str | None = output_config.get("path")
328-
output_name: str | None = output_config.get("name")
329-
else:
330-
output_path_str = None
331-
output_name = None
332-
333-
if not output_path_str:
334-
# Default output path
335-
if output_name:
336-
# Use output.name to generate default path
337-
output_path_str = f"dist/{output_name}.pyz"
338-
logger.debug(
339-
"No output path specified, using output.name: %s",
340-
output_path_str,
341-
)
342-
else:
343-
output_path_str = "dist/bundle.pyz"
344-
logger.debug(
345-
"No output path specified, using default: %s", output_path_str
346-
)
347-
348-
output = (cwd / output_path_str).resolve()
327+
output_path = resolve_output_path_from_config(output_config)
328+
logger.debug(
329+
"Resolved output path from config: %s",
330+
output_path,
331+
)
332+
output = (cwd / output_path).resolve()
349333

350334
# Extract entry point
351335
entry_point_str: str | None = config.get("entry_point")

src/zipbundler/commands/validate.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,46 @@ def _validate_output_path(output_path: str, cwd: Path) -> tuple[bool, str]:
250250
return False, msg
251251

252252

253+
def resolve_output_path_from_config(
254+
output_config: dict[str, Any] | None,
255+
default_directory: str = "dist",
256+
default_name: str = "bundle",
257+
) -> Path:
258+
"""Resolve output path from config output section.
259+
260+
Handles:
261+
- output.path: Full path (takes precedence)
262+
- output.directory + output.name: Directory and filename
263+
- output.name: Filename only (uses default_directory)
264+
- None: Uses default_directory and default_name
265+
266+
Args:
267+
output_config: Output configuration dict with optional 'path', 'directory',
268+
'name'
269+
default_directory: Default directory if not specified (default: "dist")
270+
default_name: Default filename (without extension) if not specified
271+
(default: "bundle")
272+
273+
Returns:
274+
Resolved Path object
275+
"""
276+
if not output_config:
277+
return Path(f"{default_directory}/{default_name}.pyz")
278+
279+
output_path_str: str | None = output_config.get("path")
280+
if output_path_str:
281+
return Path(output_path_str)
282+
283+
output_directory: str | None = output_config.get("directory")
284+
output_name: str | None = output_config.get("name")
285+
286+
# Use directory from config or default, name from config or default
287+
directory = output_directory if output_directory is not None else default_directory
288+
name = output_name if output_name is not None else default_name
289+
290+
return Path(f"{directory}/{name}.pyz")
291+
292+
253293
def _validate_packages_field(
254294
config: dict[str, Any],
255295
errors: list[str],
@@ -334,6 +374,13 @@ def _validate_output_field(
334374
msg = "Field 'output.name' must be a string"
335375
warnings.append(msg)
336376

377+
# Validate output.directory field (optional)
378+
if "directory" in output:
379+
output_directory: Any = output["directory"] # pyright: ignore[reportUnknownVariableType]
380+
if not isinstance(output_directory, str):
381+
msg = "Field 'output.directory' must be a string"
382+
warnings.append(msg)
383+
337384

338385
def _validate_shebang_option(
339386
options: dict[str, Any],

tests/50_core/test_build_command.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,99 @@ def test_cli_build_command_output_name_ignored_with_path(
299299
os.chdir(original_cwd)
300300

301301

302+
def test_cli_build_command_output_directory(tmp_path: Path) -> None:
303+
"""Test build command with output.directory configuration."""
304+
original_cwd = Path.cwd()
305+
try:
306+
os.chdir(tmp_path)
307+
308+
# Create a package structure
309+
src_dir = tmp_path / "src" / "mypackage"
310+
src_dir.mkdir(parents=True)
311+
(src_dir / "__init__.py").write_text("")
312+
(src_dir / "module.py").write_text("def func():\n pass\n")
313+
314+
# Create config file with output.directory and output.name
315+
config_file = tmp_path / ".zipbundler.jsonc"
316+
config_file.write_text(
317+
"""{
318+
"packages": ["src/mypackage/**/*.py"],
319+
"output": {
320+
"directory": "build",
321+
"name": "my_package"
322+
}
323+
}
324+
""",
325+
encoding="utf-8",
326+
)
327+
328+
# Handle both module and function cases (runtime mode swap)
329+
main_func = mod_main if callable(mod_main) else mod_main.main
330+
code = main_func(["build"])
331+
332+
# Verify exit code is 0
333+
assert code == 0
334+
335+
# Verify zip file was created in the custom directory
336+
output_file = tmp_path / "build" / "my_package.pyz"
337+
assert output_file.exists()
338+
339+
# Verify zip file is valid and contains expected files
340+
with zipfile.ZipFile(output_file, "r") as zf:
341+
names = zf.namelist()
342+
assert any("mypackage/__init__.py" in name for name in names)
343+
assert any("mypackage/module.py" in name for name in names)
344+
345+
# Verify default dist directory was NOT used
346+
assert not (tmp_path / "dist" / "my_package.pyz").exists()
347+
finally:
348+
os.chdir(original_cwd)
349+
350+
351+
def test_cli_build_command_output_directory_only(tmp_path: Path) -> None:
352+
"""Test build command with only output.directory (no name)."""
353+
original_cwd = Path.cwd()
354+
try:
355+
os.chdir(tmp_path)
356+
357+
# Create a package structure
358+
src_dir = tmp_path / "src" / "mypackage"
359+
src_dir.mkdir(parents=True)
360+
(src_dir / "__init__.py").write_text("")
361+
(src_dir / "module.py").write_text("def func():\n pass\n")
362+
363+
# Create config file with only output.directory
364+
config_file = tmp_path / ".zipbundler.jsonc"
365+
config_file.write_text(
366+
"""{
367+
"packages": ["src/mypackage/**/*.py"],
368+
"output": {
369+
"directory": "output"
370+
}
371+
}
372+
""",
373+
encoding="utf-8",
374+
)
375+
376+
# Handle both module and function cases (runtime mode swap)
377+
main_func = mod_main if callable(mod_main) else mod_main.main
378+
code = main_func(["build"])
379+
380+
# Verify exit code is 0
381+
assert code == 0
382+
383+
# Verify zip file was created in custom directory with default name
384+
output_file = tmp_path / "output" / "bundle.pyz"
385+
assert output_file.exists()
386+
387+
# Verify zip file is valid
388+
with zipfile.ZipFile(output_file, "r") as zf:
389+
names = zf.namelist()
390+
assert any("mypackage/__init__.py" in name for name in names)
391+
finally:
392+
os.chdir(original_cwd)
393+
394+
302395
def test_cli_build_command_invalid_config(tmp_path: Path) -> None:
303396
"""Test build command with invalid config (missing packages)."""
304397
original_cwd = Path.cwd()

0 commit comments

Comments
 (0)