Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 15 additions & 1 deletion src/protostar/cli/tui/recipe/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from textual import work
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.message import Message
from textual.widgets import Static

from protostar.cli.ui import plan_tree, planned_paths
Expand All @@ -32,6 +33,13 @@ def _count(number: int, noun: str) -> str:
class PlanPreview(VerticalScroll):
"""The tree ``--dry-run`` prints, plus any collisions, for the current draft."""

class PlanUpdated(Message):
"""Posted when planning finishes or fails."""

def __init__(self, *, error: ProtostarError | None = None) -> None:
super().__init__()
self.error = error

def __init__(self, config: UserConfig) -> None:
super().__init__()
self.config = config
Expand Down Expand Up @@ -59,9 +67,14 @@ async def update_plan(self, draft: InitDraft) -> None:
manifest = await asyncio.to_thread(_plan, draft, self.config)
except MissingTemplateVariablesError as exc:
self._show(Text(f"Waiting for values: {', '.join(exc.variables)}."))
self.post_message(self.PlanUpdated(error=None))
return
except ProtostarError as exc:
self._show(Text(str(exc)), error=True)
message = Text(str(exc))
if exc.hint:
message.append(f" {exc.hint}", style="dim")
self._show(message, error=True)
self.post_message(self.PlanUpdated(error=exc))
return
paths, _ = planned_paths(manifest)
dependencies = manifest.dependencies
Expand Down Expand Up @@ -90,6 +103,7 @@ async def update_plan(self, draft: InitDraft) -> None:
else Text(""),
tree=plan_tree(manifest) if paths else Text(""),
)
self.post_message(self.PlanUpdated(error=None))

def _show(
self,
Expand Down
42 changes: 33 additions & 9 deletions src/protostar/cli/tui/recipe/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from protostar.analysis import NoteKind, ProjectAnalysis
from protostar.config import TemplateSource, UserConfig
from protostar.errors import ConfigurationError, ProtostarError
from protostar.init_draft import DraftTemplate, InitDecision, InitDraft
from protostar.init_draft import DraftTemplate, InitDecision, InitDraft, check_draft
from protostar.metadata import MetadataKey
from protostar.modules import TOOLING_MODULES
from protostar.recipe import (
Expand Down Expand Up @@ -110,6 +110,8 @@ def __init__(
self._template_error = False
self._loading = False
self._tools_invalid = False
self._draft_error = False
self._plan_error = False
self.catalog = catalog
self.base_recipe = draft.existing_recipe or establish_recipe(config)
self.overrides = dict(draft.tool_overrides)
Expand Down Expand Up @@ -408,9 +410,24 @@ def _refresh_tools(self) -> None:

def _refresh_continue(self) -> None:
self.query_one("#continue", Button).disabled = (
self._tools_invalid or self._template_error or self._loading
self._tools_invalid
or self._template_error
or self._loading
or self._draft_error
or self._plan_error
)

def _check_draft(self, draft: InitDraft) -> bool:
"""Disable Continue at once for an invalid field; the preview says why."""
try:
check_draft(draft)
except ConfigurationError:
self._draft_error = True
else:
self._draft_error = False
self._refresh_continue()
return not self._draft_error

def _current_draft(self, variables: Mapping[str, str] | None = None) -> InitDraft:
fields = self.query_one(VariableFields)
if variables is None:
Expand All @@ -429,6 +446,7 @@ def _current_draft(self, variables: Mapping[str, str] | None = None) -> InitDraf
variables=tuple(sorted(variables.items())),
allowed_secrets=fields.allowed_secrets,
metadata=tuple(sorted(metadata.items())),
# An empty minimum leaves the configured or detected default.
python_version=str(minimum) if minimum else None,
)

Expand All @@ -447,7 +465,14 @@ def _changed(self) -> None:
docker=self._docker(),
)
)
self.query_one(PlanPreview).update_plan(self._current_draft())
draft = self._current_draft()
self._check_draft(draft)
self.query_one(PlanPreview).update_plan(draft)

@on(PlanPreview.PlanUpdated)
def _plan_updated(self, event: PlanPreview.PlanUpdated) -> None:
self._plan_error = event.error is not None
self._refresh_continue()

@on(Select.Changed, "#template")
def select_template(self, event: Select.Changed) -> None:
Expand Down Expand Up @@ -584,9 +609,8 @@ def action_continue(self) -> None:
if self.query_one("#continue", Button).disabled:
return
variables = self.query_one(VariableFields).values()
if variables is not None:
self.app.push_screen(
ReviewScreen(
self._current_draft(variables), self.config, can_go_back=True
)
)
if variables is None:
return
draft = self._current_draft(variables)
if self._check_draft(draft):
self.app.push_screen(ReviewScreen(draft, self.config, can_go_back=True))
6 changes: 6 additions & 0 deletions src/protostar/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
validate_target,
)
from .interpolation import BUILT_IN_VARIABLES, extract_variables, render_template
from .metadata import validate_github_username
from .migrations import Migration, parse_migrations
from .network import RemoteTemplate, fetch_remote_template
from .options import Condition, TemplateOption, parse_condition, parse_options
from .workspace import check_python_version

logger = logging.getLogger("protostar")

Expand Down Expand Up @@ -311,6 +313,10 @@ def __post_init__(self) -> None:
"Cannot configure both 'pre_commit = true' and 'prek = true'.",
hint="Choose either pre_commit or prek as your default git hook manager in your configuration.",
)
if self.python_version is not None:
check_python_version(self.python_version)
if self.github_username:
validate_github_username(self.github_username)

normalized: dict[str, TemplateAliasConfig] = {}
for k, v in self.templates.items():
Expand Down
24 changes: 22 additions & 2 deletions src/protostar/init_draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .config import TemplateSource, UserConfig
from .manifest import CollisionStrategy, ProjectMetadata
from .merge import NO_RESOLUTIONS, Resolutions
from .metadata import validate_metadata
from .models import InitRequest
from .modules import BootstrapModule, PythonCore, SystemWorkspaceModule
from .options import OptionValue, resolve_options
Expand All @@ -22,6 +23,7 @@
)
from .registry import ResolvedHookRevision
from .secret_guard import check_variable_values
from .workspace import check_python_version


@dataclass(frozen=True)
Expand Down Expand Up @@ -83,6 +85,22 @@ class InitDecision:
resolutions: Resolutions = NO_RESOLUTIONS


def check_draft(draft: InitDraft) -> None:
"""Validate the draft's own field values without resolving it.

It renders nothing, so an editor can call it on every change. The recipe
``resolve_init`` decodes applies the same checks.

Raises:
ConfigurationError: If the Python version or a metadata value is
invalid.
"""
if draft.python_version is not None:
check_python_version(draft.python_version)
if draft.metadata is not None:
validate_metadata(dict(draft.metadata))


def resolve_init(
draft: InitDraft, user_config: UserConfig
) -> tuple[list[BootstrapModule], InitRequest]:
Expand All @@ -94,8 +112,10 @@ def resolve_init(
if draft.analysis is not None and existing is None
else ProjectFacts()
)
python = draft.python_version or (
facts.python_version.value if facts.python_version else None
python = (
draft.python_version
if draft.python_version is not None
else (facts.python_version.value if facts.python_version else None)
)
config = (
replace(user_config, python_version=existing.python, ide=existing.ide)
Expand Down
89 changes: 88 additions & 1 deletion src/protostar/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from __future__ import annotations

import enum
from collections.abc import Callable
import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from .errors import ConfigurationError
from .system import get_git_config
from .workflows import TargetOS
from .workspace import check_python_version

if TYPE_CHECKING:
from .config import UserConfig
Expand All @@ -20,6 +23,10 @@
"MetadataKey",
"PromptType",
"resolve_auto_metadata",
"validate_docker_port",
"validate_github_username",
"validate_metadata",
"validate_minimum_python",
]


Expand Down Expand Up @@ -94,6 +101,69 @@ class MetadataField:
choices: list[str] | None
auto_resolver: Callable[[UserConfig], Any | None] | None
default: Any | None
validator: Callable[[object], None] | None = None


# Leading character alphanumeric, then alphanumerics and hyphens. Accounts from
# before GitHub's current rules may end in or repeat a hyphen, and Enterprise
# Managed Users carry an ``_shortcode`` suffix, so neither is rejected.
_GITHUB_USERNAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,38}")
_PORT_PATTERN = re.compile(r"[0-9]+")
_MAX_PORT = 65535


def validate_github_username(value: object) -> None:
"""Validates a GitHub user or organization name.

Raises:
ConfigurationError: If the value could not name a GitHub account.
"""
if not isinstance(value, str):
raise ConfigurationError(
f"Invalid GitHub username: {value!r}.",
hint="GitHub username must be a string.",
)
if value.startswith("@"):
raise ConfigurationError(
f"Invalid GitHub username: {value!r}.",
hint=f"Drop the leading '@': use {value[1:]!r}.",
)
if not _GITHUB_USERNAME_PATTERN.fullmatch(value):
raise ConfigurationError(
f"Invalid GitHub username: {value!r}.",
hint="A GitHub username is at most 39 letters, digits, and hyphens, starting with a letter or digit.",
)


def validate_docker_port(value: object) -> None:
"""Validates a container port: a whole number from 1 to 65535.

Raises:
ConfigurationError: If the value is not a port number.
"""
if isinstance(value, int) and not isinstance(value, bool):
port = value
elif isinstance(value, str) and _PORT_PATTERN.fullmatch(value):
port = int(value)
else:
raise ConfigurationError(
f"Invalid container port: {value!r}.",
hint="Container port must be a whole number, such as '8000'.",
)
if not 1 <= port <= _MAX_PORT:
raise ConfigurationError(
f"Invalid container port: {value!r}.",
hint=f"Container port must be between 1 and {_MAX_PORT}.",
)


def validate_minimum_python(value: object) -> None:
"""Validates a minimum Python version such as ``3.10``.

Raises:
ConfigurationError: If the value is not a Python 3 version.
"""
check_python_version(value, label="minimum Python version")


METADATA_FIELDS: dict[MetadataKey, MetadataField] = {
Expand Down Expand Up @@ -136,6 +206,7 @@ class MetadataField:
choices=None,
auto_resolver=lambda cfg: cfg.github_username,
default="",
validator=validate_github_username,
),
MetadataKey.MINIMUM_PYTHON: MetadataField(
key=MetadataKey.MINIMUM_PYTHON,
Expand All @@ -144,6 +215,7 @@ class MetadataField:
choices=None,
auto_resolver=lambda cfg: cfg.python_version,
default="3.13",
validator=validate_minimum_python,
),
MetadataKey.SUPPORTED_OS: MetadataField(
key=MetadataKey.SUPPORTED_OS,
Expand All @@ -160,10 +232,25 @@ class MetadataField:
choices=None,
auto_resolver=None,
default="8000",
validator=validate_docker_port,
),
}


def validate_metadata(metadata: Mapping[str, object]) -> None:
"""Validates each metadata value that has a validator.

An empty value leaves the field unset, so it is never invalid.

Raises:
ConfigurationError: If a value is invalid for its field.
"""
for key, field in METADATA_FIELDS.items():
value = metadata.get(key)
if value not in (None, "") and field.validator is not None:
field.validator(value)


def resolve_auto_metadata(
keys: set[MetadataKey | str] | None = None,
config: UserConfig | None = None,
Expand Down
Loading
Loading