Skip to content

Commit e200ace

Browse files
committed
feat: source resolver configuration
Introduce new configuration for source resolver and downloads. The new system uses profiles for common tasks: - `pypi-sdist`: resolve versions of sdists from PyPI, download sdist - `pypi-prebuilt`: resolve versions of platform wheels from PyPI, download pre-built wheel from PyPI. - `pypi-download`: resolve versions of any package from PyPI, download from external URL (with `{version}` variable) - `pypi-git`: resolve versions for any package from PyPI, git clone (with `{version}` variable) - `gitlab`: resolve and download from Gitlab (tarball or git clone) - `github`: resolve and download from Github (tarball or git clone) The new settings will eventually replace `download_source`, `resolver_dist`, and `git_options` top-level options as well as `wheel_server_url` and `pre_built` flags for variants. Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent dc48c3a commit e200ace

8 files changed

Lines changed: 427 additions & 4 deletions

File tree

src/fromager/packagesettings.py

Lines changed: 286 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import enum
34
import logging
45
import os
56
import pathlib
@@ -18,10 +19,10 @@
1819
from pydantic import Field
1920
from pydantic_core import CoreSchema, core_schema
2021

21-
from . import overrides
22+
from . import overrides, resolver
2223

2324
if typing.TYPE_CHECKING:
24-
from . import build_environment, context
25+
from . import build_environment, context, requirements_file
2526

2627
logger = logging.getLogger(__name__)
2728

@@ -354,6 +355,12 @@ class VariantInfo(pydantic.BaseModel):
354355
pre_built: bool = False
355356
"""Use pre-built wheel from index server?"""
356357

358+
source: typing.Annotated[
359+
SourceResolver | None,
360+
pydantic.Field(default=None, discriminator="provider"),
361+
]
362+
"""Source resolver and downloader"""
363+
357364

358365
class GitOptions(pydantic.BaseModel):
359366
"""Git repository cloning options
@@ -385,6 +392,269 @@ class GitOptions(pydantic.BaseModel):
385392
"""
386393

387394

395+
VERSION_QUOTED = "%7Bversion%7D"
396+
397+
398+
class BuildSDist(enum.StrEnum):
399+
pep517 = "pep517"
400+
tarball = "tarball"
401+
402+
403+
class AbstractResolver(pydantic.BaseModel):
404+
model_config = MODEL_CONFIG
405+
406+
provider: str
407+
408+
def resolver_provider(
409+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
410+
) -> resolver.BaseProvider:
411+
raise NotImplementedError
412+
413+
414+
class PyPISDistResolver(AbstractResolver):
415+
"""Resolve version with PyPI, download sdist from PyPI"""
416+
417+
provider: typing.Literal["pypi-sdist"]
418+
419+
index_url: pydantic.HttpUrl = pydantic.Field(
420+
default=pydantic.HttpUrl("https://pypi.org/simple/"),
421+
description="Python Package Index URL",
422+
)
423+
424+
# It is not safe to use PEP 517 to re-generate a source distribution.
425+
# Some PEP 517 backends require VCS to generate correct sdist.
426+
build_sdist: typing.ClassVar[BuildSDist | None] = BuildSDist.tarball
427+
428+
def resolver_provider(
429+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
430+
) -> resolver.PyPIProvider:
431+
return resolver.PyPIProvider(
432+
include_sdists=True,
433+
include_wheels=False,
434+
sdist_server_url=str(self.index_url),
435+
constraints=ctx.constraints,
436+
req_type=req_type,
437+
ignore_platform=False,
438+
)
439+
440+
441+
class PyPIPrebuiltResolver(AbstractResolver):
442+
"""Resolve version with PyPI, download pre-built wheel from PyPI"""
443+
444+
provider: typing.Literal["pypi-prebuilt"]
445+
446+
index_url: pydantic.HttpUrl = pydantic.Field(
447+
default=pydantic.HttpUrl("https://pypi.org/simple/"),
448+
description="Python Package Index URL",
449+
)
450+
451+
build_sdist: typing.ClassVar[BuildSDist | None] = None
452+
453+
def resolver_provider(
454+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
455+
) -> resolver.PyPIProvider:
456+
return resolver.PyPIProvider(
457+
include_sdists=False,
458+
include_wheels=True,
459+
sdist_server_url=str(self.index_url),
460+
constraints=ctx.constraints,
461+
req_type=req_type,
462+
ignore_platform=False,
463+
)
464+
465+
466+
class PyPIDownloadResolver(AbstractResolver):
467+
"""Resolve version with PyPI, download sdist from arbitrary URL"""
468+
469+
provider: typing.Literal["pypi-download"]
470+
471+
index_url: pydantic.HttpUrl = pydantic.Field(
472+
default=pydantic.HttpUrl("https://pypi.org/simple/"),
473+
description="Python Package Index URL",
474+
)
475+
476+
download_url: pydantic.HttpUrl
477+
"""Remote download URL
478+
479+
URL must contain '{version}' template string.
480+
"""
481+
482+
build_sdist: typing.ClassVar[BuildSDist | None] = BuildSDist.tarball
483+
484+
@pydantic.field_validator("download_url", mode="after")
485+
@classmethod
486+
def validate_download_url(cls, value: pydantic.HttpUrl) -> pydantic.HttpUrl:
487+
if not value.path:
488+
raise ValueError(f"url {value} has an empty path")
489+
if VERSION_QUOTED not in value.path:
490+
raise ValueError(f"missing '{{version}}' in url {value}")
491+
return value
492+
493+
def resolver_provider(
494+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
495+
) -> resolver.PyPIProvider:
496+
return resolver.PyPIProvider(
497+
include_sdists=True,
498+
include_wheels=True,
499+
sdist_server_url=str(self.index_url),
500+
constraints=ctx.constraints,
501+
req_type=req_type,
502+
ignore_platform=True,
503+
override_download_url=str(self.download_url).replace(
504+
VERSION_QUOTED, "{version}"
505+
),
506+
)
507+
508+
509+
class PyPIGitResolver(AbstractResolver):
510+
"""Resolve version with PyPI, build sdist from git clone"""
511+
512+
provider: typing.Literal["pypi-git"]
513+
514+
index_url: pydantic.HttpUrl = pydantic.Field(
515+
default=pydantic.HttpUrl("https://pypi.org/simple/"),
516+
description="Python Package Index URL",
517+
)
518+
519+
clone_url: pydantic.AnyUrl
520+
"""git clone URL
521+
522+
https://git.test/repo.git
523+
"""
524+
525+
tag: str
526+
527+
build_sdist: BuildSDist = BuildSDist.pep517
528+
"""Source distribution build method"""
529+
530+
@pydantic.field_validator("clone_url", mode="after")
531+
@classmethod
532+
def validate_clone_url(cls, value: pydantic.AnyUrl) -> pydantic.AnyUrl:
533+
if value.scheme not in {"https", "ssh"}:
534+
raise ValueError(f"invalid scheme in url {value}")
535+
if not value.path:
536+
raise ValueError(f"url {value} has an empty path")
537+
return value
538+
539+
@pydantic.field_validator("tag", mode="after")
540+
@classmethod
541+
def validate_tag(cls, value: str) -> str:
542+
if "{version}" not in value:
543+
raise ValueError(f"missing '{{version}}' in tag {value}")
544+
return value
545+
546+
def resolver_provider(
547+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
548+
) -> resolver.PyPIProvider:
549+
download_url = f"git+{self.clone_url}@{self.tag}"
550+
return resolver.PyPIProvider(
551+
include_sdists=True,
552+
include_wheels=True,
553+
sdist_server_url=str(self.index_url),
554+
constraints=ctx.constraints,
555+
req_type=req_type,
556+
ignore_platform=True,
557+
override_download_url=download_url,
558+
)
559+
560+
561+
class GithubSourceResolver(AbstractResolver):
562+
"""Resolve version from Github tags, build sdist from tarball or git clone"""
563+
564+
provider: typing.Literal["github"]
565+
566+
url: pydantic.HttpUrl
567+
"""Full GitHub project URL"""
568+
569+
tag_pattern: re.Pattern = re.compile(r"v?(\d+\..*)")
570+
"""Regular expression matching the tag"""
571+
572+
retrieve_method: resolver.RetrieveMethod = resolver.RetrieveMethod.git_https
573+
"""Retrieve method (tar bundle, git clone)"""
574+
575+
build_sdist: BuildSDist = BuildSDist.pep517
576+
"""Source distribution build method"""
577+
578+
@pydantic.field_validator("url", mode="after")
579+
@classmethod
580+
def validate_url(cls, value: pydantic.HttpUrl) -> pydantic.HttpUrl:
581+
if value.host != "github.com":
582+
raise ValueError("Expected 'github.com' in {value}")
583+
if not value.path or value.path.count("/") != 2:
584+
raise ValueError("Invalid path in {value}, expected two elements")
585+
return value
586+
587+
def resolver_provider(
588+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
589+
) -> resolver.GitHubTagProvider:
590+
path = self.url.path
591+
assert path
592+
path = path.lstrip("/")
593+
if path.endswith(".git"):
594+
path = path[:-4]
595+
organization, repo = path.split("/")
596+
return resolver.GitHubTagProvider(
597+
organization=organization,
598+
repo=repo,
599+
constraints=ctx.constraints,
600+
matcher=self.tag_pattern,
601+
req_type=req_type,
602+
retrieve_method=self.retrieve_method,
603+
)
604+
605+
606+
class GitlabSourceResolver(AbstractResolver):
607+
"""Resolve version from Gitlab tags, build sdist from download or clone"""
608+
609+
provider: typing.Literal["gitlab"]
610+
611+
url: pydantic.HttpUrl
612+
"""Full GitLab project URL"""
613+
614+
tag_pattern: re.Pattern = re.compile(r"v?(\d+\..*)")
615+
"""Regular expression matching the tag"""
616+
617+
retrieve_method: resolver.RetrieveMethod = resolver.RetrieveMethod.git_https
618+
"""Retrieve method (tar bundle, git clone)"""
619+
620+
build_sdist: BuildSDist = BuildSDist.pep517
621+
"""Source distribution build method"""
622+
623+
@pydantic.field_validator("url", mode="after")
624+
@classmethod
625+
def validate_url(cls, value: pydantic.HttpUrl) -> pydantic.HttpUrl:
626+
if value.scheme != "https" or not value.host or not value.path:
627+
raise ValueError("invalid url {value}")
628+
return value
629+
630+
def resolver_provider(
631+
self, ctx: context.WorkContext, req_type: requirements_file.RequirementType
632+
) -> resolver.GitLabTagProvider:
633+
path = self.url.path
634+
assert path
635+
path = path.lstrip("/")
636+
if path.endswith(".git"):
637+
path = path[:-4]
638+
return resolver.GitLabTagProvider(
639+
project_path=path,
640+
server_url=f"https://{self.url.host}",
641+
constraints=ctx.constraints,
642+
matcher=self.tag_pattern,
643+
req_type=req_type,
644+
retrieve_method=self.retrieve_method,
645+
)
646+
647+
648+
SourceResolver = (
649+
PyPISDistResolver
650+
| PyPIPrebuiltResolver
651+
| PyPIDownloadResolver
652+
| PyPIGitResolver
653+
| GithubSourceResolver
654+
| GitlabSourceResolver
655+
)
656+
657+
388658
_DictStrAny = dict[str, typing.Any]
389659

390660

@@ -453,6 +723,12 @@ class PackageSettings(pydantic.BaseModel):
453723
env: EnvVars = Field(default_factory=dict)
454724
"""Common env var for all variants"""
455725

726+
source: typing.Annotated[
727+
SourceResolver | None,
728+
pydantic.Field(default=None, discriminator="provider"),
729+
]
730+
"""Source resolver and downloader"""
731+
456732
download_source: DownloadSource = Field(default_factory=DownloadSource)
457733
"""Alternative source download settings"""
458734

@@ -986,6 +1262,14 @@ def variants(self) -> Mapping[Variant, VariantInfo]:
9861262
"""Get the variant configuration for the current package"""
9871263
return self._ps.variants
9881264

1265+
@property
1266+
def source_resolver(self) -> SourceResolver | None:
1267+
"""Get source resolver settings (variant or global)"""
1268+
vi = self._ps.variants.get(self.variant)
1269+
if vi is not None and vi.source is not None:
1270+
return vi.source
1271+
return self._ps.source
1272+
9891273
def serialize(self, **kwargs: typing.Any) -> dict[str, typing.Any]:
9901274
return self._ps.serialize(**kwargs)
9911275

0 commit comments

Comments
 (0)