Skip to content

Commit

Permalink
feat: allow run command to accept dist directory
Browse files Browse the repository at this point in the history
The `run` command now accepts paths to both source and dist
directories. Source directories will be built on-the-fly, while dist
directories can be executed directly.
  • Loading branch information
tumidi committed Jul 2, 2024
1 parent a099e08 commit c9c0946
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 60 deletions.
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description = "Library and toolset for the development of QuestionPy packages"
authors = ["innoCampus <[email protected]>"]
license = "MIT"
homepage = "https://questionpy.org"
version = "0.2.5"
version = "0.2.6"
packages = [
{ include = "questionpy" },
{ include = "questionpy_sdk" }
Expand All @@ -28,7 +28,7 @@ python = "^3.11"
aiohttp = "^3.9.3"
pydantic = "^2.6.4"
PyYAML = "^6.0.1"
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "763c15bcf3906ebb80d8d0bd9c15e842de4f8b0c" }
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "263750b29fb5ac3b883422141bed13cdb3bd0706" }
jinja2 = "^3.1.3"
aiohttp-jinja2 = "^1.6"
lxml = "~5.1.0"
Expand Down
37 changes: 20 additions & 17 deletions questionpy_sdk/commands/_helper.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# 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]>

import zipfile
from pathlib import Path

import click
from pydantic import ValidationError

from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME
from questionpy_common.manifest import Manifest
from questionpy_sdk.package.builder import DirPackageBuilder
from questionpy_sdk.package.errors import PackageBuildError, PackageSourceValidationError
from questionpy_sdk.package.source import PackageSource
Expand All @@ -19,40 +19,43 @@
)


def build_dir_package(source_path: Path) -> None:
def _get_dir_package_location(source_path: Path) -> DirPackageLocation:
try:
return DirPackageLocation(source_path)
except (OSError, ValidationError, ValueError) as exc:
msg = f"Failed to read package manifest:\n{exc}"
raise click.ClickException(msg) from exc


def _get_dir_package_location_from_source(pkg_string: str, source_path: Path) -> DirPackageLocation:
# Always rebuild package.
try:
package_source = PackageSource(source_path)
except PackageSourceValidationError as exc:
raise click.ClickException(str(exc)) from exc

try:
with DirPackageBuilder(package_source) as builder:
builder.write_package()
click.echo(f"Successfully built package '{pkg_string}'.")
except PackageBuildError as exc:
msg = f"Failed to build package: {exc}"
raise click.ClickException(msg) from exc

return _get_dir_package_location(source_path / DIST_DIR)

def get_package_location(pkg_string: str) -> PackageLocation:
pkg_path = Path(pkg_string)

def get_package_location(pkg_string: str, pkg_path: Path) -> PackageLocation:
if pkg_path.is_dir():
# Always rebuild package.
build_dir_package(pkg_path)
click.echo(f"Successfully built package '{pkg_string}'.")
try:
manifest_path = pkg_path / DIST_DIR / MANIFEST_FILENAME
with manifest_path.open() as manifest_fp:
manifest = Manifest.model_validate_json(manifest_fp.read())
return DirPackageLocation(pkg_path, manifest)
except (OSError, ValidationError, ValueError) as exc:
msg = f"Failed to read package manifest:\n{exc}"
raise click.ClickException(msg) from exc
# dist dir
if (pkg_path / MANIFEST_FILENAME).is_file():
return _get_dir_package_location(pkg_path)
# source dir
return _get_dir_package_location_from_source(pkg_string, pkg_path)

if zipfile.is_zipfile(pkg_path):
return ZipPackageLocation(pkg_path)

msg = f"'{pkg_string}' doesn't look like a QPy package zip file or directory."
msg = f"'{pkg_string}' doesn't look like a QPy package file, source directory, or dist directory."
raise click.ClickException(msg)


Expand Down
5 changes: 4 additions & 1 deletion questionpy_sdk/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# 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 pathlib import Path

import click

from questionpy_sdk.commands._helper import get_package_location
Expand All @@ -19,5 +21,6 @@ def run(package: str) -> None:
- a dist directory, or
- a source directory (built on-the-fly).
""" # noqa: D301
web_server = WebServer(get_package_location(package))
pkg_path = Path(package).resolve()
web_server = WebServer(get_package_location(package, pkg_path))
web_server.start_server()
40 changes: 16 additions & 24 deletions questionpy_sdk/package/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# 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 functools import cached_property
from pathlib import Path

import yaml
Expand All @@ -28,41 +29,20 @@ def __init__(self, path: Path):
PackageSourceValidationError: If the package source could not be validated.
"""
self._path = path
self._config = self._read_yaml_config()
self._validate()

def _validate(self) -> None:
self._check_required_paths()

def _check_required_paths(self) -> None:
# check for `python/NAMESPACE/SHORTNAME/__init__.py`
package_init_path = self._path / "python" / self._config.namespace / self._config.short_name / "__init__.py"
try:
package_init_path.stat()
except FileNotFoundError as exc:
msg = f"Expected '{package_init_path}' to exist"
raise PackageSourceValidationError(msg) from exc
package_init_path = self._path / "python" / self.config.namespace / self.config.short_name / "__init__.py"
if not package_init_path.is_file():
msg = f"Expected '{package_init_path}' to be a file"
msg = f"Expected '{package_init_path}' to exist"
raise PackageSourceValidationError(msg)

@property
@cached_property
def config(self) -> PackageConfig:
return self._config

@property
def config_path(self) -> Path:
return self._path / PACKAGE_CONFIG_FILENAME

@property
def normalized_filename(self) -> str:
return create_normalized_filename(self._config)

@property
def path(self) -> Path:
return self._path

def _read_yaml_config(self) -> PackageConfig:
try:
with self.config_path.open() as config_file:
return PackageConfig.model_validate(yaml.safe_load(config_file))
Expand All @@ -76,3 +56,15 @@ def _read_yaml_config(self) -> PackageConfig:
# TODO: pretty error feedback (https://docs.pydantic.dev/latest/errors/errors/#customize-error-messages)
msg = f"Failed to validate package config '{self.config_path}': {exc}"
raise PackageSourceValidationError(msg) from exc

@property
def config_path(self) -> Path:
return self._path / PACKAGE_CONFIG_FILENAME

@property
def normalized_filename(self) -> str:
return create_normalized_filename(self.config)

@property
def path(self) -> Path:
return self._path
42 changes: 36 additions & 6 deletions tests/questionpy_sdk/commands/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +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 asyncio
import contextlib
import os
import signal
import subprocess
import sys
from collections.abc import Iterator
from asyncio.subprocess import PIPE, Process
from collections.abc import AsyncIterator, Iterable, Iterator
from pathlib import Path
from typing import Any

import aiohttp
import pytest
from click.testing import CliRunner, Result

Expand Down Expand Up @@ -60,13 +62,41 @@ def cwd(isolated_runner: tuple[CliRunner, Path]) -> Path:
return isolated_runner[1]


@pytest.fixture
async def client_session() -> AsyncIterator[aiohttp.ClientSession]:
async with aiohttp.ClientSession() as session:
yield session


# can't test long-running processes with `CliRunner` (https://github.com/pallets/click/issues/2171)
@contextlib.contextmanager
def long_running_cmd(args: list[str]) -> Iterator[subprocess.Popen]:
@contextlib.asynccontextmanager
async def long_running_cmd(args: Iterable[str], timeout: float = 5) -> AsyncIterator[Process]:
try:
popen_args = [sys.executable, "-m", "questionpy_sdk", "--", *args]
proc = subprocess.Popen(popen_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
proc = await asyncio.create_subprocess_exec(*popen_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)

# ensure tests don't hang indefinitely
async def kill_after_timeout() -> None:
await asyncio.sleep(timeout)
proc.send_signal(signal.SIGINT)

kill_task = asyncio.create_task(kill_after_timeout())
yield proc

finally:
if kill_task:
kill_task.cancel()
proc.send_signal(signal.SIGINT)
proc.wait()
await proc.wait()


async def assert_webserver_is_up(session: aiohttp.ClientSession, url: str = "http://localhost:8080/") -> None:
for _ in range(50): # allow 5 sec to come up
try:
async with session.get(url) as response:
assert response.status == 200
return
except aiohttp.ClientConnectionError:
await asyncio.sleep(0.1)

pytest.fail("Webserver didn't come up")
24 changes: 18 additions & 6 deletions tests/questionpy_sdk/commands/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

from pathlib import Path

from aiohttp import ClientSession
from click.testing import CliRunner

from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME
from questionpy_sdk.commands.run import run
from tests.questionpy_sdk.commands.conftest import long_running_cmd
from questionpy_sdk.package.builder import DirPackageBuilder
from questionpy_sdk.package.source import PackageSource
from tests.questionpy_sdk.commands.conftest import assert_webserver_is_up, long_running_cmd


def test_run_no_arguments(runner: CliRunner) -> None:
Expand All @@ -20,19 +23,28 @@ def test_run_no_arguments(runner: CliRunner) -> None:
def test_run_with_not_existing_package(runner: CliRunner) -> None:
result = runner.invoke(run, ["package.qpy"])
assert result.exit_code != 0
assert "'package.qpy' doesn't look like a QPy package zip file, directory or module" in result.stdout
assert "'package.qpy' doesn't look like a QPy package file, source directory, or dist directory." in result.stdout


def test_run_non_zip_file(runner: CliRunner, cwd: Path) -> None:
(cwd / "README.md").write_text("Foo bar")
result = runner.invoke(run, ["README.md"])
assert result.exit_code != 0
assert "'README.md' doesn't look like a QPy package zip file, directory or module" in result.stdout
assert "'README.md' doesn't look like a QPy package file, source directory, or dist directory." in result.stdout


def test_run_dir_builds_package(source_path: Path) -> None:
with long_running_cmd(["run", str(source_path)]) as proc:
async def test_run_source_dir_builds_package(source_path: Path, client_session: ClientSession) -> None:
async with long_running_cmd(("run", str(source_path))) as proc:
assert proc.stdout
first_line = proc.stdout.readline().decode("utf-8")
first_line = (await proc.stdout.readline()).decode("utf-8")
assert f"Successfully built package '{source_path}'" in first_line
assert (source_path / DIST_DIR / MANIFEST_FILENAME).exists()
await assert_webserver_is_up(client_session)


async def test_run_dist_dir(source_path: Path, client_session: ClientSession) -> None:
with DirPackageBuilder(PackageSource(source_path)) as builder:
builder.write_package()

async with long_running_cmd(("run", str(source_path / DIST_DIR))):
await assert_webserver_is_up(client_session)

0 comments on commit c9c0946

Please sign in to comment.