Source code for mafw.scaffold.names

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Distribution-to-import name derivation and validation.

A MAFw project is identified by two related names:

* the **distribution name** written in ``pyproject.toml`` (for example
  ``my-analysis``), which may contain hyphens;
* the **import name**, the importable Python package (for example
  ``my_analysis``), obtained by replacing every hyphen with an underscore.

This module provides the single, total derivation function :func:`to_import_name`
and the validation routine :func:`validate`, which together guarantee that a
generated project always has a safe, importable package name and that a malicious
or malformed name can never reach the filesystem.

.. versionadded:: 2.4
"""

import keyword
import re
import sys

from mafw.scaffold.errors import InvalidEmailError, InvalidNameError, NameCollisionError

MAX_NAME_LENGTH = 214
"""Maximum accepted length of a project name.

This mirrors the de-facto package-name length limit used by the Python packaging
ecosystem, keeping generated distribution names portable.
"""

# A valid Python package identifier: one or more underscore-separated segments,
# each starting with a letter or underscore and continuing with letters, digits
# or underscores. This is deliberately stricter than ``str.isidentifier`` because
# it also rejects names that would not round-trip through the hyphen mapping.
_PACKAGE_IDENTIFIER = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')

# Characters and components that indicate a path-traversal attempt. These are
# checked first so that a dangerous name is rejected before any other rule and,
# crucially, before any filesystem access.
_PATH_SEPARATORS = ('/', '\\')
_TRAVERSAL_COMPONENTS = ('.', '..')

# A pragmatic e-mail pattern: a local part, an ``@``, a dotted domain with a
# two-or-more-letter top-level label. It deliberately does not implement the full
# RFC 5322 grammar; it catches the malformed addresses (missing ``@``, missing
# domain or TLD, embedded whitespace) that would otherwise break the build backend
# when written into ``pyproject.toml``.
_EMAIL_PATTERN = re.compile(
    r"[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
    r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}'
)


[docs] def to_import_name(distribution_name: str) -> str: """Derive the import name from a distribution name. The mapping replaces every hyphen with a single underscore and leaves every other character unchanged. It is total (it never raises) and idempotent (applying it twice gives the same result as applying it once), because the output contains no hyphens for a second pass to rewrite. :param distribution_name: The distribution name, possibly containing hyphens. :type distribution_name: str :return: The derived import name. :rtype: str """ return distribution_name.replace('-', '_')
[docs] def validate(distribution_name: str, import_name: str) -> None: """Validate a distribution name and its derived import name. The checks are applied in a fixed order and each failure raises a specific error with an actionable message: #. **Path safety** — reject any name containing a path separator or a ``.`` / ``..`` component. This is checked first so a dangerous name never reaches the filesystem. #. **Non-empty and length** — reject an empty name or one longer than :data:`MAX_NAME_LENGTH` characters. #. **Identifier grammar** — reject a derived import name that is not a valid Python package identifier. #. **Keyword collision** — reject an import name that is a hard or soft Python keyword. #. **Standard-library collision** — reject an import name that shadows a top-level standard-library module. :param distribution_name: The distribution name supplied by the user. :type distribution_name: str :param import_name: The import name derived by :func:`to_import_name`. :type import_name: str :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. """ # 1. Path safety first: a separator or traversal component must never be # allowed to influence a filesystem path. for separator in _PATH_SEPARATORS: if separator in distribution_name: raise InvalidNameError( f'The project name {distribution_name!r} is invalid: it must not contain a path separator.' ) # Split on both separators defensively; on this path neither is present, but # the component check also guards the bare "." and ".." names. components = re.split(r'[\\/]', distribution_name) if any(component in _TRAVERSAL_COMPONENTS for component in components): raise InvalidNameError( f'The project name {distribution_name!r} is invalid: it must not contain a path-traversal component.' ) # 2. Non-empty and length limit (checked on the normalized import name). if not import_name: raise InvalidNameError('The project name is invalid: it must not be empty.') if len(import_name) > MAX_NAME_LENGTH: raise InvalidNameError(f'The project name is invalid: it must be at most {MAX_NAME_LENGTH} characters long.') # 3. Identifier grammar. if _PACKAGE_IDENTIFIER.fullmatch(import_name) is None: raise InvalidNameError( f'The project name {distribution_name!r} is invalid: it does not form a valid Python package ' f'identifier ({import_name!r}).' ) # 4. Keyword collision (hard and soft keywords). if keyword.iskeyword(import_name) or keyword.issoftkeyword(import_name): raise NameCollisionError( f'The import name {import_name!r} is a reserved Python keyword; choose a different project name.' ) # 5. Standard-library top-level module collision. if import_name in sys.stdlib_module_names: raise NameCollisionError( f'The import name {import_name!r} collides with the standard-library module of the same name; ' f'choose a different project name.' )
[docs] def validate_email(email: str) -> None: """Validate that ``email`` is a well-formed author e-mail address. The check is intentionally pragmatic rather than a full RFC 5322 parser: it rejects the common malformed inputs (no ``@``, no domain, no top-level domain, whitespace) that produce invalid package metadata and make the build backend fail, while accepting ordinary addresses. :param email: The e-mail address to validate. :type email: str :raises mafw.scaffold.errors.InvalidEmailError: If the address is not well formed. """ if _EMAIL_PATTERN.fullmatch(email) is None: raise InvalidEmailError( f'The author e-mail {email!r} is not a valid address; expected something like "name@example.org".' )