Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3e897bb
feat: add DistDynamicQPyDependency to manifest
MHajoha Nov 18, 2025
0865195
refactor: Indexer and PackageCollection don't use worker pool anymore
MHajoha Dec 1, 2025
20d2583
chore: ignore false-positive PLW0108
MHajoha Dec 15, 2025
a9406d9
refactor: use worker_context in serve_static_file
MHajoha Dec 15, 2025
d53f2b5
refactor: move PackageNamespaceAndShortName to questionpy_common and …
MHajoha Dec 15, 2025
627f4ab
refactor: rename read_manifest to read_manifest_from_zip and add read…
MHajoha Dec 15, 2025
590b522
refactor!: change DistStaticQPyDependency
MHajoha Dec 15, 2025
60b41bb
refactor!: remove ComparableManifest
MHajoha Jan 8, 2026
dd6c2d0
feat: add a dependency tree solver based on resolvelib
MHajoha Dec 15, 2025
303d0d9
feat: load all dependencies in worker
MHajoha Dec 15, 2025
cb5a911
refactor!: remove WorkerDependencyManager to improve testability
MHajoha Jan 12, 2026
8db38a3
fix: DirPackageLocation already points to dist
MHajoha Jan 12, 2026
c419a6f
chore: bump version to 0.9.0
MHajoha Jan 12, 2026
8158932
refactor: support existing Version instance in ParsableSemverVersion
MHajoha Jan 12, 2026
5039823
fix: static dep on root package
MHajoha Jan 12, 2026
19af277
refactor: reduce on_msg_load_qpy_package cognitive complexity
MHajoha Jan 13, 2026
02f46b2
refactor: support ZipFile in read_manifest_from_zip
MHajoha Jan 13, 2026
3d91274
refactor: simplify get_preference
MHajoha Jan 13, 2026
1ed173d
docs: add docstring to _find_cycle_and_longest_path
MHajoha Jan 13, 2026
c23338d
fix: exclude incompatible candidates from find_matches result
MHajoha Jan 13, 2026
c6f64c9
refactor: simplify is_satisfied_by
MHajoha Jan 13, 2026
8507508
refactor: don't call resolve_dependency_tree if no deps
MHajoha Jan 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions poetry.lock

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

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "questionpy-server"
description = "QuestionPy application server"
license = { file = "LICENSE.md" }
urls = { homepage = "https://questionpy.org" }
version = "0.8.0"
version = "0.9.0"
authors = [
{ name = "TU Berlin innoCampus" },
{ email = "info@isis.tu-berlin.de" }
Expand All @@ -21,7 +21,8 @@ dependencies = [
"semver >=3.0.4, <4.0.0",
"psutil >=7.0.0, <8.0.0",
"jinja2 >=3.1.6, <4.0.0",
"pyyaml >=6.0.2, <7.0.0"
"pyyaml >=6.0.2, <7.0.0",
"resolvelib >=1.2.1, <2.0.0"
]

[tool.poetry.group.dev.dependencies]
Expand Down
31 changes: 31 additions & 0 deletions questionpy_common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
from abc import ABC, abstractmethod
from typing import Any, NamedTuple, Self

from pydantic import GetCoreSchemaHandler
from pydantic_core import CoreSchema, core_schema


Expand All @@ -20,3 +22,32 @@ def __get_pydantic_core_schema__(cls, *_: object) -> CoreSchema:


TranslatableString.register(str)


class PackageNamespaceAndShortName(NamedTuple):
"""Tuple of namespace and short name, identifying any version of a specific package."""

namespace: str
short_name: str

def __str__(self) -> str:
return f"@{self.namespace}/{self.short_name}"

@classmethod
def from_string(cls, value: str) -> Self:
"""Parse an NSSN in the same format as produced by `__str__`."""
value = value.strip()
if not value.startswith("@") or value.count("/") != 1:
msg = f"Invalid package identifier (NSSN): '{value}'"
raise ValueError(msg)

ns, sn = value.removeprefix("@").split("/", maxsplit=1)
return cls(ns, sn)

@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handle: GetCoreSchemaHandler) -> CoreSchema:
return core_schema.no_info_before_validator_function(
lambda obj: cls.from_string(obj) if isinstance(obj, str) else obj,
handle(source_type),
serialization=core_schema.to_string_ser_schema(),
)
15 changes: 15 additions & 0 deletions questionpy_common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,19 @@
r"^([a-zA-Z_][a-zA-Z0-9_]*|\.\.)(\[([a-zA-Z_][a-zA-Z0-9_]*|\.\.)?])*$"
)

# Regular expressions.

ENVIRONMENT_VARIABLE_REGEX: Final[str] = r"[a-zA-Z_][a-zA-Z0-9_]*"

RE_SEMVER = (
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
)

RE_API = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$"

# The SemVer and Api version patterns are used on pydantic fields, which uses Rust regexes, so re.compiling them makes
# no sense. We match RE_VALID_CHARS_NAME in Python though, so here it does.
RE_VALID_CHARS_NAME = re.compile(r"^[a-z\d_]+$")

NAME_MAX_LENGTH = 127
58 changes: 58 additions & 0 deletions questionpy_common/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from dataclasses import dataclass, field
from typing import Annotated

from pydantic import Field

from questionpy_common import PackageNamespaceAndShortName
from questionpy_common.constants import RE_SEMVER
from questionpy_common.manifest import DistDependencies
from questionpy_common.package_location import PackageLocation


@dataclass(frozen=True)
class StaticDependencySolution:
"""Indicates that a package in the tree provides a static dependency that is to be used.

Usually, this is a solution for the static dependency itself, but if there is also a dynamic dependency in the tree
for that NSSN _and_ that dynamic dependency allows the static version, a `StaticDependencySolution` might be used to
also solve a dynamic dependency.

If multiple packages provide the same version of a static dependency, any of them may be used as the solution.
Static dependencies on different versions of the same NSSN will always lead to a `DependencyConflictError`.
"""

nssn: PackageNamespaceAndShortName

owner: PackageNamespaceAndShortName
"""The package that includes this static dependency."""

hash: str
# semver.Version is avoided to allow solutions to be passed to the package.
version: Annotated[str, Field(pattern=RE_SEMVER)]

dependencies: DistDependencies = field(compare=False)
"""Transitive dependencies of this dependency."""

def __str__(self) -> str:
return f"{self.hash} ({self.version}, statically packaged in '{self.owner}')"


@dataclass(frozen=True)
class DynamicDependencySolution:
"""Indicates that the given version should be used to supply all usages of the NSSN."""

nssn: PackageNamespaceAndShortName

hash: str
# semver.Version is avoided to allow solutions to be passed to the package.
version: Annotated[str, Field(pattern=RE_SEMVER)]
dependencies: DistDependencies = field(compare=False)
"""Transitive dependencies of this dependency."""

def __str__(self) -> str:
return f"{self.hash} ({self.version}, dynamic)"


type DependencySolution = StaticDependencySolution | DynamicDependencySolution

type SolutionAndLocation = tuple[DynamicDependencySolution, PackageLocation] | tuple[StaticDependencySolution, None]
14 changes: 2 additions & 12 deletions questionpy_common/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
from enum import Enum
from functools import total_ordering
from importlib.resources.abc import Traversable
from typing import NamedTuple, Protocol
from typing import Protocol

from pydantic import BaseModel, JsonValue

from questionpy_common import PackageNamespaceAndShortName
from questionpy_common.api.package import QPyPackageInterface
from questionpy_common.manifest import Bcp47LanguageTag, Manifest

Expand All @@ -21,7 +22,6 @@
"OnRequestCallback",
"Package",
"PackageInitFunction",
"PackageNamespaceAndShortName",
"PackageNotInitializedError",
"PackageNotLoadedError",
"PackagePermissions",
Expand Down Expand Up @@ -79,16 +79,6 @@ def __lt__(self, other: object) -> bool:
return NotImplemented


class PackageNamespaceAndShortName(NamedTuple):
"""Tuple of namespace and short name, identifying any version of a specific package."""

namespace: str
short_name: str

def __str__(self) -> str:
return f"@{self.namespace}/{self.short_name}"


class Package(Protocol):
@property
def manifest(self) -> Manifest: ...
Expand Down
84 changes: 62 additions & 22 deletions questionpy_common/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,31 @@
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>

import re
from abc import ABC
from enum import StrEnum
from keyword import iskeyword, issoftkeyword
from typing import Annotated, NewType

from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator
from typing import Annotated, Literal, NewType

from pydantic import (
AfterValidator,
BaseModel,
ByteSize,
PositiveInt,
StringConstraints,
conset,
field_validator,
)
from pydantic.fields import Field

from questionpy_common.constants import ENVIRONMENT_VARIABLE_REGEX
from questionpy_common import PackageNamespaceAndShortName
from questionpy_common.constants import (
ENVIRONMENT_VARIABLE_REGEX,
NAME_MAX_LENGTH,
RE_API,
RE_SEMVER,
RE_VALID_CHARS_NAME,
)
from questionpy_common.version_specifiers import QPyDependencyVersionSpecifier


class PackageType(StrEnum):
Expand All @@ -19,22 +35,9 @@ class PackageType(StrEnum):
QUESTION = "QUESTION"


# Defaults.
DEFAULT_NAMESPACE = "local"
DEFAULT_PACKAGETYPE = PackageType.QUESTIONTYPE

# Regular expressions.
RE_SEMVER = (
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
)
RE_API = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$"
# The SemVer and Api version patterns are used on pydantic fields, which uses Rust regexes, so re.compiling them makes
# no sense. We match RE_VALID_CHARS_NAME in Python though, so here it does.
RE_VALID_CHARS_NAME = re.compile(r"^[a-z\d_]+$")

NAME_MAX_LENGTH = 127


# Validators.
def ensure_is_valid_name(name: str) -> str:
Expand Down Expand Up @@ -139,6 +142,10 @@ def ensure_contains_english_translation(
def identifier(self) -> str:
return f"@{self.namespace}/{self.short_name}"

@property
def nssn(self) -> PackageNamespaceAndShortName:
return PackageNamespaceAndShortName(self.namespace, self.short_name)


class PackageFile(BaseModel):
"""Represents a static file included in a built package."""
Expand All @@ -148,13 +155,46 @@ class PackageFile(BaseModel):


class DistStaticQPyDependency(BaseModel):
dir_name: str
"""Name (without `dist/dependencies/qpy/`) of the directory the dependency package contents reside in."""
namespace: Annotated[str, AfterValidator(ensure_is_valid_name)]
short_name: Annotated[str, AfterValidator(ensure_is_valid_name)]
version: Annotated[str, Field(pattern=RE_SEMVER)]

dependencies: "DistDependencies"
"""Transitive dependencies of this dependency."""

hash: str
"""Hash of the ZIP package whose contents lie in `dir_name`."""
"""Hash of the ZIP package whose contents are included in this package."""

@property
def nssn(self) -> PackageNamespaceAndShortName:
return PackageNamespaceAndShortName(self.namespace, self.short_name)


type DependencyLockStrategy = Literal["required", "preferred-no-downgrade", "preferred-allow-downgrade"]


class LockedDependencyInfo(BaseModel):
strategy: DependencyLockStrategy
locked_version: Annotated[str, Field(pattern=RE_SEMVER)]
locked_hash: str


class AbstractDynamicQPyDependency(BaseModel, ABC):
namespace: Annotated[str, AfterValidator(ensure_is_valid_name)]
short_name: Annotated[str, AfterValidator(ensure_is_valid_name)]
version: QPyDependencyVersionSpecifier | None = None
include_prereleases: bool = False

@property
def nssn(self) -> PackageNamespaceAndShortName:
return PackageNamespaceAndShortName(self.namespace, self.short_name)


class DistDynamicQPyDependency(AbstractDynamicQPyDependency):
locked: LockedDependencyInfo | None = None


type DistQPyDependency = DistStaticQPyDependency
type DistQPyDependency = DistStaticQPyDependency | DistDynamicQPyDependency


class DistDependencies(BaseModel):
Expand Down
Loading