Source code for mafw.scaffold.spec

#  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, )