Skip to content

Commit

Permalink
feat: update PackageBuilder and add PackageSource classes
Browse files Browse the repository at this point in the history
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
tumidi authored and MartinGauk committed May 8, 2024
1 parent 1a8e32f commit 9d1cd45
Show file tree
Hide file tree
Showing 11 changed files with 328 additions and 117 deletions.
19 changes: 17 additions & 2 deletions questionpy_sdk/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# (c) Technische Universität Berlin, innoCampus <[email protected]>

import logging
import shutil
import sys

import click
Expand All @@ -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)
Expand Down
42 changes: 11 additions & 31 deletions questionpy_sdk/commands/_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,9 @@
from pathlib import Path

import click
import yaml
from pydantic import ValidationError

from questionpy_common.manifest import Manifest
from questionpy_sdk.constants import PACKAGE_CONFIG_FILENAME
from questionpy_sdk.models import PackageConfig
from questionpy_sdk.package.errors import PackageSourceValidationError
from questionpy_sdk.package.source import PackageSource
from questionpy_server.worker.runtime.package_location import (
DirPackageLocation,
FunctionPackageLocation,
Expand All @@ -20,23 +17,18 @@
)


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"
def read_package_source(source_path: Path) -> PackageSource:
try:
return PackageSource(source_path)
except PackageSourceValidationError as exc:
raise click.ClickException(str(exc)) from exc


def infer_package_kind(string: str) -> PackageLocation:
path = Path(string)
if path.is_dir():
config = read_yaml_config(path / PACKAGE_CONFIG_FILENAME)
return DirPackageLocation(path, config)
package_source = read_package_source(path)
return DirPackageLocation(path, package_source.config)

if zipfile.is_zipfile(path):
return ZipPackageLocation(path)
Expand All @@ -62,17 +54,5 @@ def infer_package_kind(string: str) -> PackageLocation:
raise click.ClickException(msg)


def read_yaml_config(path: Path) -> PackageConfig:
if not path.is_file():
msg = f"The config '{path}' does not exist."
raise click.ClickException(msg)

with path.open() as config_f:
try:
return PackageConfig.model_validate(yaml.safe_load(config_f))
except yaml.YAMLError as e:
msg = f"Failed to parse config '{path}': {e}"
raise click.ClickException(msg) from e
except ValidationError as e:
msg = f"Invalid config '{path}': {e}"
raise click.ClickException(msg) from e
def confirm_overwrite(filepath: Path) -> bool:
return click.confirm(f"The path '{filepath}' already exists. Do you want to overwrite it?", abort=True)
13 changes: 11 additions & 2 deletions questionpy_sdk/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@ def validate_name(context: click.Context, _parameter: click.Parameter, value: st

@click.command(context_settings={"show_default": True})
@click.argument("short_name", callback=validate_name)
@click.option("--namespace", "-n", "namespace", callback=validate_name, default=DEFAULT_NAMESPACE)
@click.option("--out", "-o", "out_path", type=click.Path(path_type=Path))
@click.option(
"--namespace", "-n", "namespace", callback=validate_name, default=DEFAULT_NAMESPACE, help="Package namespace."
)
@click.option(
"--out",
"-o",
"out_path",
type=click.Path(path_type=Path),
help="Newly created package directory. [default: SHORT_NAME]",
)
def create(short_name: str, namespace: str, out_path: Path | None) -> None:
"""Create new package."""
if not out_path:
out_path = Path(short_name)
if out_path.exists():
Expand Down
103 changes: 49 additions & 54 deletions questionpy_sdk/commands/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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__}")
2 changes: 1 addition & 1 deletion questionpy_sdk/commands/repo/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

import click

from questionpy_sdk.commands._helper import create_normalized_filename
from questionpy_sdk.commands.repo._helper import IndexCreator, get_manifest
from questionpy_sdk.package._helper import create_normalized_filename


@click.command()
Expand Down
27 changes: 0 additions & 27 deletions questionpy_sdk/package.py

This file was deleted.

3 changes: 3 additions & 0 deletions questionpy_sdk/package/__init__.py
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]>
17 changes: 17 additions & 0 deletions questionpy_sdk/package/_helper.py
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"
Loading

0 comments on commit 9d1cd45

Please sign in to comment.