-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: update
PackageBuilder
and add PackageSource
classes
Extract the packager builder logic into separate class `PackageBuilder`. The class `PackageSource` represents a source package folder and handles validation. Build packages to temporary package file, so no output files are overwritten when build fails. Introduce a `--no-interaction` CLI flag, similar to how it's implemented in Poetry and other tools. Also add a `--force` flag for the `package` task. Ref: #69
- Loading branch information
1 parent
1a8e32f
commit 9d1cd45
Showing
11 changed files
with
328 additions
and
117 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ | |
# (c) Technische Universität Berlin, innoCampus <[email protected]> | ||
|
||
import logging | ||
import shutil | ||
import sys | ||
|
||
import click | ||
|
@@ -12,11 +13,25 @@ | |
from questionpy_sdk.commands.repo import repo | ||
from questionpy_sdk.commands.run import run | ||
|
||
# Contrary to click's docs, there's no autodetection of the terminal width (pallets/click#2253) | ||
terminal_width = shutil.get_terminal_size()[0] | ||
|
||
@click.group(context_settings={"help_option_names": ["-h", "--help"]}) | ||
|
||
@click.group(context_settings={"help_option_names": ["-h", "--help"], "terminal_width": terminal_width}) | ||
@click.option( | ||
"-n", | ||
"--no-interaction", | ||
is_flag=True, | ||
show_default=True, | ||
default=False, | ||
help="Do not ask any interactive question.", | ||
) | ||
@click.option("-v", "--verbose", is_flag=True, show_default=True, default=False, help="Use log level DEBUG.") | ||
def cli(*, verbose: bool) -> None: | ||
@click.pass_context | ||
def cli(ctx: click.Context, *, verbose: bool, no_interaction: bool) -> None: | ||
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, stream=sys.stderr) | ||
ctx.ensure_object(dict) | ||
ctx.obj["no_interaction"] = no_interaction | ||
|
||
|
||
cli.add_command(create) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,19 +2,17 @@ | |
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. | ||
# (c) Technische Universität Berlin, innoCampus <[email protected]> | ||
|
||
import inspect | ||
import subprocess | ||
import shutil | ||
import tempfile | ||
from contextlib import suppress | ||
from pathlib import Path | ||
from tempfile import TemporaryDirectory | ||
from types import ModuleType | ||
|
||
import click | ||
|
||
import questionpy | ||
from questionpy_sdk.commands._helper import create_normalized_filename, read_yaml_config | ||
from questionpy_sdk.constants import PACKAGE_CONFIG_FILENAME | ||
from questionpy_sdk.models import PackageConfig | ||
from questionpy_sdk.package import PackageBuilder | ||
from questionpy_sdk.package.builder import PackageBuilder | ||
from questionpy_sdk.package.errors import PackageBuildError | ||
|
||
from ._helper import confirm_overwrite, read_package_source | ||
|
||
|
||
def validate_out_path(context: click.Context, _parameter: click.Parameter, value: Path | None) -> Path | None: | ||
|
@@ -26,54 +24,51 @@ def validate_out_path(context: click.Context, _parameter: click.Parameter, value | |
|
||
@click.command() | ||
@click.argument("source", type=click.Path(exists=True, file_okay=False, path_type=Path)) | ||
@click.option("--config", "-c", "config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) | ||
@click.option("--out", "-o", "out_path", callback=validate_out_path, type=click.Path(path_type=Path)) | ||
def package(source: Path, config_path: Path | None, out_path: Path | None) -> None: | ||
if not config_path: | ||
config_path = source / PACKAGE_CONFIG_FILENAME | ||
|
||
config = read_yaml_config(config_path) | ||
@click.option( | ||
"--out", | ||
"-o", | ||
"out_path", | ||
callback=validate_out_path, | ||
type=click.Path(path_type=Path), | ||
help="Output file path of QuestionPy package. [default: 'NAMESPACE-SHORT_NAME-VERSION.qpy']", | ||
) | ||
@click.option("--force", "-f", "force_overwrite", is_flag=True, help="Force overwriting of output file.") | ||
@click.pass_context | ||
def package(ctx: click.Context, source: Path, out_path: Path | None, *, force_overwrite: bool) -> None: | ||
"""Build package from directory SOURCE.""" | ||
package_source = read_package_source(source) | ||
overwriting = False | ||
|
||
if not out_path: | ||
out_path = Path(create_normalized_filename(config)) | ||
if out_path.exists() and click.confirm( | ||
f"The path '{out_path}' already exists. Do you want to overwrite it?", abort=True | ||
): | ||
out_path.unlink() | ||
out_path = Path(package_source.normalized_filename) | ||
|
||
if out_path.exists(): | ||
if force_overwrite or (not ctx.obj["no_interaction"] and confirm_overwrite(out_path)): | ||
overwriting = True | ||
else: | ||
msg = f"Output file '{out_path}' exists" | ||
raise click.ClickException(msg) | ||
|
||
try: | ||
with PackageBuilder(out_path) as out_file: | ||
_copy_package(out_file, questionpy) | ||
_install_dependencies(out_file, config_path, config) | ||
out_file.write_glob(source, "python/**/*") | ||
out_file.write_glob(source, "static/**/*") | ||
out_file.write_manifest(config) | ||
except subprocess.CalledProcessError as e: | ||
out_path.unlink(missing_ok=True) | ||
msg = f"Failed to install requirements: {e.stderr.decode()}" | ||
raise click.ClickException(msg) from e | ||
# Use temp file, otherwise we risk overwriting `out_path` in case of a build error. | ||
temp_file = tempfile.NamedTemporaryFile(delete=False) | ||
temp_file_path = Path(temp_file.name) | ||
|
||
try: | ||
with PackageBuilder(temp_file, package_source) as builder: | ||
builder.write_package() | ||
except PackageBuildError as exc: | ||
msg = f"Failed to build package: {exc}" | ||
raise click.ClickException(msg) from exc | ||
finally: | ||
temp_file.close() | ||
|
||
if overwriting: | ||
Path(out_path).unlink() | ||
|
||
shutil.move(temp_file_path, out_path) | ||
finally: | ||
with suppress(FileNotFoundError): | ||
temp_file_path.unlink() | ||
|
||
click.echo(f"Successfully created '{out_path}'.") | ||
|
||
|
||
def _install_dependencies(target: PackageBuilder, config_path: Path, config: PackageConfig) -> None: | ||
if isinstance(config.requirements, str): | ||
# treat as a relative reference to a requirements.txt and read those | ||
pip_args = ["-r", str(config_path.parent / config.requirements)] | ||
elif isinstance(config.requirements, list): | ||
# treat as individual dependency specifiers | ||
pip_args = config.requirements | ||
else: | ||
# no dependencies specified | ||
return | ||
|
||
with TemporaryDirectory(prefix=f"qpy_{config.short_name}") as tempdir: | ||
subprocess.run(["pip", "install", "--target", tempdir, *pip_args], check=True, capture_output=True) # noqa: S603, S607 | ||
target.write_glob(Path(tempdir), "**/*", prefix="dependencies/site-packages") | ||
|
||
|
||
def _copy_package(target: PackageBuilder, pkg: ModuleType) -> None: | ||
# inspect.getfile returns the path to the package's __init__.py | ||
package_dir = Path(inspect.getfile(pkg)).parent | ||
# TODO: Exclude __pycache__, pyc files and the like. | ||
target.write_glob(package_dir, "**/*", prefix=f"dependencies/site-packages/{pkg.__name__}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# This file is part of the QuestionPy SDK. (https://questionpy.org) | ||
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. | ||
# (c) Technische Universität Berlin, innoCampus <[email protected]> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
# This file is part of the QuestionPy SDK. (https://questionpy.org) | ||
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. | ||
# (c) Technische Universität Berlin, innoCampus <[email protected]> | ||
|
||
from questionpy_common.manifest import Manifest | ||
|
||
|
||
def create_normalized_filename(manifest: Manifest) -> str: | ||
"""Creates a normalized file name for the given manifest. | ||
Args: | ||
manifest: manifest of the package | ||
Returns: | ||
normalized file name | ||
""" | ||
return f"{manifest.namespace}-{manifest.short_name}-{manifest.version}.qpy" |
Oops, something went wrong.