Source code for action0.openapi.names

"""
Turning OpenAPI spellings into the Python names of the generated code.

OpenAPI documents name things in whatever style the API grew up with —
``camelCase`` properties, ``kebab-case`` headers, ``PascalCase`` or
dotted component names. Generated code follows PEP 8: classes are
``PascalCase``, fields ``snake_case``, enum members ``UPPER_SNAKE``.
The functions here perform that conversion and keep the results *valid*:
Python keywords and context-reserved names get a trailing underscore
(the original spelling survives as the field's wire name), identifiers
that would start with a digit get a ``V``/``v_`` prefix, and
:py:class:`NameRegistry` de-duplicates within one scope.
"""

import keyword
import re
import string
import unicodedata
from collections.abc import Collection
from collections.abc import Mapping

#: splits a raw name into words: pluralized acronyms ("APIs" -> APIs,
#: two capitals at least so "Rs232" stays one word), acronym runs with
#: their trailing digits ("HTTPServer" -> HTTP, Server; "V1Forecast" ->
#: V1, Forecast; "SHA256Sum" -> SHA256, Sum), capitalized words
#: ("petId" -> pet, Id), and lowercase/digit runs; everything
#: non-alphanumeric separates
_WORDS = re.compile(r"[A-Z]{2,}s(?![a-z])|[A-Z]+[0-9]*(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+")

#: names an operation dataclass field must not use: the Operation
#: ClassVars (reserved by action0-client) and the field specifiers the
#: generated operations module imports at module level (a field binding
#: one of these in the class body would shadow the specifier for every
#: later field of the same class)
RESERVED_OPERATION_FIELDS = frozenset(
    {
        "method",
        "path",
        "accept",
        "default_location",
        "query",
        "header",
        "path_param",
        "json_field",
        "json_body",
        "form_field",
        "body",
    }
)


def _words(raw: str) -> list[str]:
    """
    Split a raw name into its words.

    Accented letters lose their accents first ("Poké" becomes "Poke")
    instead of splitting the word; anything still non-ASCII after that
    separates words, as all other punctuation does.

    :param raw: the name as spelled in the schema
    :return: the words, ``["x"]`` if nothing alphanumeric remains
    """
    decomposed = unicodedata.normalize("NFKD", raw)
    plain = "".join(char for char in decomposed if not unicodedata.combining(char))
    return _WORDS.findall(plain) or ["x"]


[docs] def class_name(raw: str) -> str: """ Turn a schema name into a ``PascalCase`` class name. >>> class_name("pet-store") 'PetStore' >>> class_name("petStatus") 'PetStatus' >>> class_name("HTTPValidationError") 'HttpValidationError' >>> class_name("APIs.guru") # pluralized acronyms stay one word 'ApisGuru' >>> class_name("get /v1/forecast") # digits stay with their word 'GetV1Forecast' >>> class_name("PokéAPI") # accents are dropped, not word breaks 'PokeApi' >>> class_name("1password") # leading digit: prefixed 'V1password' :param raw: the name as spelled in the schema :return: a valid Python class name """ name = "".join(word.capitalize() for word in _words(raw)) if name[0].isdigit(): name = "V" + name return name
[docs] def field_name(raw: str, *, reserved: Collection[str] = ()) -> str: """ Turn a schema name into a ``snake_case`` field name. >>> field_name("petId") 'pet_id' >>> field_name("X-Request-Id") 'x_request_id' >>> field_name("numAPIs") # pluralized acronyms stay one word 'num_apis' >>> field_name("GetV1ForecastResponse") # digits stay with their word 'get_v1_forecast_response' >>> field_name("SHA256Sum") 'sha256_sum' >>> field_name("class") # Python keyword 'class_' >>> field_name("path", reserved=RESERVED_OPERATION_FIELDS) 'path_' >>> field_name("1st") # leading digit: prefixed 'v_1st' :param raw: the name as spelled in the schema :param reserved: additional names to avoid (e.g. :py:data:`RESERVED_OPERATION_FIELDS` for operation fields) :return: a valid, non-reserved Python field name """ name = "_".join(word.lower() for word in _words(raw)) if name[0].isdigit(): name = "v_" + name while keyword.iskeyword(name) or name in reserved: name += "_" return name
[docs] def constant_name(raw: str) -> str: """ Turn an enum value into an ``UPPER_SNAKE`` member name. >>> constant_name("on-sale") 'ON_SALE' >>> constant_name("notAvailable") 'NOT_AVAILABLE' >>> constant_name("1st") # digit-led: enum members must not start with "_" 'V_1ST' :param raw: the enum value as spelled in the schema :return: a valid Python enum member name """ name = "_".join(word.upper() for word in _words(raw)) if name[0].isdigit(): name = "V_" + name return name
[docs] def operation_class_name(operation_id: "str | None", method: str, path: str) -> str: """ Name the operation class after the ``operationId``, if there is one, and after method and path otherwise. >>> operation_class_name("listPets", "get", "/pets") 'ListPets' >>> operation_class_name(None, "get", "/pets/{petId}") 'GetPetsPetId' :param operation_id: the schema's ``operationId``, if any :param method: the HTTP method :param path: the path as spelled in the schema :return: a valid Python class name """ return class_name(operation_id if operation_id else f"{method} {path}")
[docs] def converter_name(model_class: str) -> str: """ Name the JSON-to-model converter function for a model class. >>> converter_name("Pet") 'pet_from_json' >>> converter_name("HttpError") 'http_error_from_json' :param model_class: the model's Python class name :return: the converter function's name """ return f"{field_name(model_class)}_from_json"
[docs] def properties_constant_name(model_class: str) -> str: """ Name the declared-properties set constant of a model with a catch-all ``additionalProperties`` field. The constant holds the wire names of the declared properties; the model's converter fills the catch-all field with every payload key outside the set. >>> properties_constant_name("Pet") '_PET_PROPERTIES' >>> properties_constant_name("HttpError") '_HTTP_ERROR_PROPERTIES' :param model_class: the model's Python class name :return: the constant's name """ return f"_{field_name(model_class).upper()}_PROPERTIES"
[docs] def path_placeholders(path: str) -> tuple[str, ...]: """ Return the ``{placeholder}`` names of a path template, in order. >>> path_placeholders("/stores/{storeId}/pets/{petId}") ('storeId', 'petId') :param path: the path template :return: the placeholder names :raises ValueError: if the template's braces are malformed """ return tuple(name for _, name, _, _ in string.Formatter().parse(path) if name is not None)
[docs] def rewrite_path(path: str, renames: Mapping[str, str]) -> str: """ Rename the ``{placeholder}``\\ s of a path template. Placeholder names must equal the Python names of the operation's ``path_param()`` fields (action0-client validates that, and the specifier deliberately has no wire-name parameter), so the template is rewritten to the renamed fields. >>> rewrite_path("/pets/{petId}", {"petId": "pet_id"}) '/pets/{pet_id}' :param path: the path template as spelled in the schema :param renames: schema spelling to Python name, per placeholder :return: the rewritten template """ return re.sub( r"\{([^{}]*)\}", lambda match: "{" + renames.get(match.group(1), match.group(1)) + "}", path, )
[docs] class NameRegistry: """ De-duplicates names within one scope (module, enum, class). The first claim of a name gets it as-is, later claims of the same name get a numeric suffix: >>> registry = NameRegistry() >>> registry.claim("Pet") 'Pet' >>> registry.claim("Pet") 'Pet2' >>> registry.claim("Pet") 'Pet3' """ def __init__(self) -> None: self._taken: set[str] = set()
[docs] def claim(self, preferred: str) -> str: """ Return the preferred name, made unique within this registry. :param preferred: the name to claim :return: the name, or the first free numbered variant of it """ name = preferred count = 1 while name in self._taken: count += 1 name = f"{preferred}{count}" self._taken.add(name) return name