# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Validated description of a project to generate.
:class:`mafw.scaffold.spec.ProjectSpec` is the single immutable value object that
carries every input needed to render a MAFw analysis project. Building it through
:meth:`ProjectSpec.create` is the one place where raw, user-supplied values are
normalized, validated and defaulted, so that the rest of the generation service
only ever sees known-good data and never has to prompt or re-validate.
.. versionadded:: 2.4
"""
import functools
import re
from dataclasses import dataclass
from importlib import metadata
from pathlib import Path
from mafw.__about__ import __version__ as _mafw_version
from mafw.scaffold.errors import MissingValueError
from mafw.scaffold.names import to_import_name, validate, validate_email
DEFAULT_DESCRIPTION = 'Scientific analysis with MAFw'
"""Default one-line description used when the user does not supply one."""
_FALLBACK_PYTHON_VERSION = '3.11'
"""Fallback minimum Python version used only if MAFw metadata cannot be read.
The authoritative value is derived at runtime from the installed MAFw
distribution's own ``Requires-Python`` metadata (see
:func:`default_python_version`), which the maintainers keep in sync with
``tool.mafw.supported-python``. This constant is a last resort for the unlikely
case where that metadata is unavailable.
"""
DEFAULT_AUTHOR_NAME = 'Anonymous Scientist'
"""Default author name used when the user does not supply one."""
DEFAULT_AUTHOR_EMAIL = 'anonymous@example.org'
"""Default author e-mail used when the user does not supply one."""
DEFAULT_LICENSE = 'MIT'
"""Default SPDX license identifier for a generated project.
MIT is a permissive, widely understood license in the scientific community; the
generated README explains how the author can switch to a different one.
"""
[docs]
@functools.cache
def default_python_version() -> str:
"""Return the default minimum Python version for a generated project.
The value is taken from the installed MAFw distribution's ``Requires-Python``
metadata (for example ``>=3.11`` yields ``3.11``). That metadata is generated
from MAFw's own ``pyproject.toml``, which the maintainers keep aligned with the
single source of truth ``tool.mafw.supported-python``. Reading it here means the
scaffold never hard-codes a Python floor and needs no copy of MAFw's source
``pyproject.toml`` at runtime (it also works from a ``uvx`` install).
If the metadata cannot be read or parsed, :data:`_FALLBACK_PYTHON_VERSION` is
returned.
:return: The default minimum Python version as a ``major.minor`` string.
:rtype: str
"""
try:
requires_python = metadata.metadata('mafw').get('Requires-Python')
except metadata.PackageNotFoundError: # pragma: no cover - MAFw is always installed at runtime
return _FALLBACK_PYTHON_VERSION
if not requires_python:
return _FALLBACK_PYTHON_VERSION
# Extract the lowest major.minor appearing in the specifier (e.g. ">=3.11").
match = re.search(r'(\d+)\.(\d+)', requires_python)
if match is None:
return _FALLBACK_PYTHON_VERSION
return f'{match.group(1)}.{match.group(2)}'
[docs]
@dataclass(frozen=True)
class ProjectSpec:
"""An immutable, fully validated description of a project to generate.
:ivar distribution_name: The installable distribution name (may contain hyphens).
:ivar import_name: The importable package name derived from the distribution name.
:ivar description: A short, human-readable project description.
:ivar python_version: The minimum supported Python version (for example ``3.11``).
:ivar author_name: The project author's name, written into the package metadata.
:ivar author_email: The project author's e-mail, written into the package metadata.
:ivar license_id: The SPDX license identifier for the generated project.
:ivar destination: The resolved, absolute destination directory for the project.
:ivar mafw_version: The MAFw version the generated project depends on.
"""
distribution_name: str
import_name: str
description: str
python_version: str
author_name: str
author_email: str
license_id: str
destination: Path
mafw_version: str
[docs]
@classmethod
def create(
cls,
*,
raw_name: str | None,
description: str | None = None,
python_version: str | None = None,
author_name: str | None = None,
author_email: str | None = None,
path: Path | None = None,
license_id: str | None = None,
) -> 'ProjectSpec':
"""Build a validated :class:`mafw.scaffold.spec.ProjectSpec` from raw inputs.
The project name is the only required value. The description, Python
version, author name/e-mail and license fall back to documented defaults
when omitted, and the destination defaults to ``<path>/<distribution_name>``
(or ``<cwd>/<distribution_name>`` when no path is given). The default Python
version is derived from the installed MAFw metadata, not hard-coded.
:param raw_name: The project name as typed by the user, or ``None``.
:type raw_name: str | None
:param description: An optional project description.
:type description: str | None
:param python_version: An optional minimum Python version.
:type python_version: str | None
:param author_name: An optional author name.
:type author_name: str | None
:param author_email: An optional author e-mail address.
:type author_email: str | None
:param path: An optional destination parent directory.
:type path: Path | None
:param license_id: An optional SPDX license identifier; defaults to MIT.
:type license_id: str | None
:return: A validated project specification.
:rtype: mafw.scaffold.spec.ProjectSpec
:raises mafw.scaffold.errors.MissingValueError: If no project name is supplied.
:raises mafw.scaffold.errors.InvalidNameError: If the name is unsafe, empty,
too long or not a valid identifier.
:raises mafw.scaffold.errors.NameCollisionError: If the import name collides
with a keyword or a standard-library module.
:raises mafw.scaffold.errors.InvalidEmailError: If the author e-mail is
malformed.
"""
if raw_name is None or not raw_name.strip():
raise MissingValueError('A project name is required (none was supplied).')
distribution_name = raw_name.strip()
import_name = to_import_name(distribution_name)
# Validation raises on any unsafe or invalid name before a path is built.
validate(distribution_name, import_name)
resolved_description = description if description is not None and description.strip() else DEFAULT_DESCRIPTION
resolved_python = (
python_version if python_version is not None and python_version.strip() else default_python_version()
)
resolved_author = author_name if author_name is not None and author_name.strip() else DEFAULT_AUTHOR_NAME
resolved_email = author_email if author_email is not None and author_email.strip() else DEFAULT_AUTHOR_EMAIL
resolved_license = license_id if license_id is not None and license_id.strip() else DEFAULT_LICENSE
# Reject a malformed author e-mail: it would otherwise produce invalid
# pyproject.toml metadata and make the build backend fail.
resolved_email = resolved_email.strip()
validate_email(resolved_email)
# The destination is the project directory itself: <parent>/<distribution_name>.
parent = path if path is not None else Path.cwd()
destination = (parent / distribution_name).resolve()
return cls(
distribution_name=distribution_name,
import_name=import_name,
description=resolved_description.strip(),
python_version=resolved_python.strip(),
author_name=resolved_author.strip(),
author_email=resolved_email,
license_id=resolved_license.strip(),
destination=destination,
mafw_version=_mafw_version,
)