Skip to content

Commit a3a0b7f

Browse files
committed
wip
1 parent a9f052d commit a3a0b7f

File tree

5 files changed

+155
-18
lines changed

5 files changed

+155
-18
lines changed

questionpy_common/constants.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,17 @@
2626
FORM_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(
2727
r"^([a-zA-Z_][a-zA-Z0-9_]*|\.\.)(\[([a-zA-Z_][a-zA-Z0-9_]*|\.\.)?])*$"
2828
)
29+
30+
# Regular expressions.
31+
RE_SEMVER = (
32+
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-]*)"
33+
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
34+
)
35+
36+
RE_API = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$"
37+
38+
# The SemVer and Api version patterns are used on pydantic fields, which uses Rust regexes, so re.compiling them makes
39+
# no sense. We match RE_VALID_CHARS_NAME in Python though, so here it does.
40+
RE_VALID_CHARS_NAME = re.compile(r"^[a-z\d_]+$")
41+
42+
NAME_MAX_LENGTH = 127

questionpy_common/manifest.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,26 @@
22
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
33
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
44

5-
import re
65
from enum import StrEnum
76
from keyword import iskeyword, issoftkeyword
8-
from typing import Annotated, NewType
7+
from typing import Annotated, Literal, NewType
98

10-
from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator
9+
from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator, AfterValidator
1110
from pydantic.fields import Field
1211

12+
from questionpy_common.constants import NAME_MAX_LENGTH, RE_API, RE_SEMVER, RE_VALID_CHARS_NAME
13+
from questionpy_common.dependencies import QPyDependencyVersionSpecifier
14+
1315

1416
class PackageType(StrEnum):
1517
LIBRARY = "LIBRARY"
1618
QUESTIONTYPE = "QUESTIONTYPE"
1719
QUESTION = "QUESTION"
1820

1921

20-
# Defaults.
2122
DEFAULT_NAMESPACE = "local"
2223
DEFAULT_PACKAGETYPE = PackageType.QUESTIONTYPE
2324

24-
# Regular expressions.
25-
RE_SEMVER = (
26-
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-]*)"
27-
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
28-
)
29-
RE_API = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$"
30-
# The SemVer and Api version patterns are used on pydantic fields, which uses Rust regexes, so re.compiling them makes
31-
# no sense. We match RE_VALID_CHARS_NAME in Python though, so here it does.
32-
RE_VALID_CHARS_NAME = re.compile(r"^[a-z\d_]+$")
33-
34-
NAME_MAX_LENGTH = 127
35-
3625

3726
# Validators.
3827
def ensure_is_valid_name(name: str) -> str:
@@ -148,7 +137,27 @@ class DistStaticQPyDependency(BaseModel):
148137
"""Hash of the ZIP package whose contents lie in `dir_name`."""
149138

150139

151-
type DistQPyDependency = DistStaticQPyDependency
140+
type DependencyLockStrategy = Literal["required", "preferred-no-downgrade", "preferred-allow-downgrade"]
141+
142+
143+
class LockedDependencyInfo(BaseModel):
144+
strategy: DependencyLockStrategy
145+
locked_version: Annotated[str, Field(pattern=RE_SEMVER)]
146+
locked_hash: str
147+
148+
149+
class AbstractDynamicQPyDependency(BaseModel, ABC):
150+
namespace: Annotated[str, AfterValidator(ensure_is_valid_name)]
151+
short_name: Annotated[str, AfterValidator(ensure_is_valid_name)]
152+
version: QPyDependencyVersionSpecifier
153+
include_prereleases: bool
154+
155+
156+
class DistDynamicQPyDependency(AbstractDynamicQPyDependency):
157+
locked: LockedDependencyInfo | None = None
158+
159+
160+
type DistQPyDependency = DistStaticQPyDependency | DistDynamicQPyDependency
152161

153162

154163
class DistDependencies(BaseModel):
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import re
2+
from dataclasses import dataclass
3+
from typing import Any, Literal, Protocol, Self
4+
5+
from pydantic import GetCoreSchemaHandler
6+
from pydantic_core import CoreSchema, core_schema
7+
8+
from questionpy_common.constants import RE_SEMVER
9+
10+
type _Operator = Literal["==", "!=", ">=", "<=", ">", "<"]
11+
_OPERATORS: set[_Operator] = {"==", "!=", ">=", "<=", ">", "<"}
12+
13+
_SEMVER_PATTERN = re.compile(RE_SEMVER)
14+
15+
16+
class VersionProtocol(Protocol):
17+
"""Partial protocol for SemVer version objects.
18+
19+
We don't want `questionpy_common` to depend on the `semver` package, so we define this protocol instead of using
20+
`semver.Version` directly.
21+
"""
22+
23+
def __gt__(self, other: str) -> bool: ...
24+
25+
def __ge__(self, other: str) -> bool: ...
26+
27+
def __lt__(self, other: str) -> bool: ...
28+
29+
def __le__(self, other: str) -> bool: ...
30+
31+
32+
@dataclass(frozen=True)
33+
class QPyDependencyVersionSpecifier:
34+
"""One or more clauses restricting allowed versions for a QPy package dependency."""
35+
36+
@dataclass(frozen=True)
37+
class Clause:
38+
"""A single comparison clause such as `>= 1.2.2`."""
39+
40+
operator: _Operator
41+
operand: str
42+
43+
def allows(self, version: VersionProtocol) -> bool:
44+
"""Check if this clause is fulfilled by the given version."""
45+
# Note: The semver package we use does already implement a `match` method, but we would like to validate
46+
# each clause early, before the matching needs to be done.
47+
match self.operator:
48+
case "<":
49+
return version < self.operand
50+
case "<=":
51+
return version <= self.operand
52+
case "==":
53+
return version == self.operand
54+
case ">=":
55+
return version >= self.operand
56+
case ">":
57+
return version > self.operand
58+
case _:
59+
# Shouldn't be reachable.
60+
msg = f"Invalid operator: {self.operator}"
61+
raise ValueError(msg)
62+
63+
@classmethod
64+
def from_string(cls, string: str) -> Self:
65+
string = string.strip()
66+
67+
operator = next(filter(string.startswith, _OPERATORS), None)
68+
if operator:
69+
version_string = string.removeprefix(operator).lstrip()
70+
if not _SEMVER_PATTERN.match(string):
71+
msg = f"Comparison version '{version_string}' of clause '{string}' does not conform to SemVer."
72+
raise ValueError(msg)
73+
74+
operand = version_string
75+
else:
76+
# No operator. Check if string is a version, since we allow "==" to be omitted.
77+
if not _SEMVER_PATTERN.match(string):
78+
msg = (
79+
f"Version specifier clause '{string}' does not start with a valid operator and isn't a "
80+
f"version itself. Valid operators are {', '.join(_OPERATORS)}."
81+
)
82+
raise ValueError(msg)
83+
84+
operator = "=="
85+
operand = string
86+
87+
return cls(operator, operand)
88+
89+
def __str__(self) -> str:
90+
return f"{self.operator} {self.operand}"
91+
92+
clauses: tuple[Clause, ...]
93+
94+
def __str__(self) -> str:
95+
return ", ".join(map(str, self.clauses))
96+
97+
@classmethod
98+
def from_string(cls, string: str) -> Self:
99+
return cls(tuple(map(cls.Clause.from_string, string.split(","))))
100+
101+
def allows(self, version: str) -> bool:
102+
"""Checks if _all_ clauses allow the given version."""
103+
return all(clause.allows(version) for clause in self.clauses)
104+
105+
@classmethod
106+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
107+
return core_schema.json_or_python_schema(
108+
core_schema.no_info_after_validator_function(cls.from_string, handler(str)),
109+
core_schema.is_instance_schema(cls),
110+
serialization=core_schema.to_string_ser_schema()
111+
)

questionpy_server/utils/versioning.py

Whitespace-only changes.

tests/test_data/factories.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from polyfactory.factories.pydantic_factory import ModelFactory
1010
from semver import Version
1111

12-
from questionpy_common.manifest import Bcp47LanguageTag, PartialPackagePermissions
12+
from questionpy_common.manifest import Bcp47LanguageTag, PartialPackagePermissions, DistDependencies
1313
from questionpy_server.repository.models import RepoMeta, RepoPackageVersions
1414
from questionpy_server.utils.manifest import ComparableManifest
1515

@@ -31,6 +31,8 @@ class RepoMetaFactory(ModelFactory):
3131
class RepoPackageVersionsFactory(CustomFactory):
3232
__model__ = RepoPackageVersions
3333

34+
manifest = Use(lambda: ManifestFactory.build())
35+
3436

3537
class ManifestFactory(CustomFactory):
3638
__model__ = ComparableManifest
@@ -42,3 +44,4 @@ class ManifestFactory(CustomFactory):
4244
url = Use(ModelFactory.__faker__.url)
4345
icon = None
4446
permissions = PartialPackagePermissions()
47+
dependencies = DistDependencies(qpy=[])

0 commit comments

Comments
 (0)