Skip to content

Commit 6eb238d

Browse files
committed
wip
1 parent a9f052d commit 6eb238d

File tree

5 files changed

+147
-17
lines changed

5 files changed

+147
-17
lines changed

questionpy_common/constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,18 @@
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+
SEMVER_PATTERN = re.compile(RE_SEMVER)
36+
37+
RE_API = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$"
38+
39+
# The SemVer and Api version patterns are used on pydantic fields, which uses Rust regexes, so re.compiling them makes
40+
# no sense. We match RE_VALID_CHARS_NAME in Python though, so here it does.
41+
RE_VALID_CHARS_NAME = re.compile(r"^[a-z\d_]+$")
42+
43+
NAME_MAX_LENGTH = 127

questionpy_common/dependencies.py

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

questionpy_common/manifest.py

Lines changed: 23 additions & 16 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 <[email protected]>
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

109
from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator
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,25 @@ 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 DistDynamicQPyDependency(BaseModel):
150+
namespace: str
151+
short_name: str
152+
version: QPyDependencyVersionSpecifier
153+
include_prereleases: bool
154+
155+
locked: LockedDependencyInfo | None = None
156+
157+
158+
type DistQPyDependency = DistStaticQPyDependency | DistDynamicQPyDependency
152159

153160

154161
class DistDependencies(BaseModel):

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)