Source code for codegen.naming

"""Naming conventions and import-path helpers for code generation.

Provides :class:`Name` for deriving conventional identifiers
(PascalCase, snake_case, slugs) from a base string, plus helpers
for splitting and constructing Python dotted import paths.
"""

import re

_PASCAL_BOUNDARY = re.compile(r"([A-Z]+)([A-Z][a-z])")
_CAMEL_BOUNDARY = re.compile(r"([a-z\d])([A-Z])")


[docs] class Name: """Derives conventional identifiers from a base string. Accepts either a ``PascalCase`` class name (e.g. ``"Article"``) or a ``snake_case`` identifier (e.g. ``"publish_article"``) and exposes the common derived forms used by code generators. Examples:: model = Name("Article") model.pascal # "Article" model.lower # "article" model.suffixed("Resource") # "ArticleResource" action = Name("publish_article") action.pascal # "PublishArticle" action.slug # "publish-article" action.suffixed("Request") # "PublishArticleRequest" """ def __init__(self, raw: str) -> None: # noqa: D107 self.raw = raw @property def pascal(self) -> str: """PascalCase form of the name. If the raw string contains no underscores and already starts with an uppercase letter it is returned as-is (assumed to already be PascalCase, e.g. ``"StockMovement"`` from a dotted import path). """ if "_" not in self.raw and self.raw[:1].isupper(): return self.raw return "".join(part.capitalize() for part in self.raw.split("_")) @property def lower(self) -> str: """Fully lowercased form, no separator inserted. ``"Article"`` → ``"article"``, ``"publish_article"`` → ``"publish_article"``. Use :attr:`snake` instead when the result will become a file/module/function name and the input is PascalCase -- ``Name("NotificationPreference").lower`` collapses to ``"notificationpreference"`` whereas :attr:`snake` returns ``"notification_preference"``. """ return self.raw.lower() @property def snake(self) -> str: """snake_case form (for file/module/function names). ``"Article"`` → ``"article"``, ``"NotificationPreference"`` → ``"notification_preference"``, ``"XMLParser"`` → ``"xml_parser"``, ``"publish_article"`` → ``"publish_article"``. PascalCase / camelCase boundaries become underscores so multi-word model classes produce readable identifiers. Strings already containing underscores pass through unchanged (only lowercased) on the assumption that the caller chose their own boundaries. """ if "_" in self.raw: return self.raw.lower() head = _PASCAL_BOUNDARY.sub(r"\1_\2", self.raw) return _CAMEL_BOUNDARY.sub(r"\1_\2", head).lower() @property def slug(self) -> str: """Hyphenated slug form (for URL segments). ``"publish_article"`` → ``"publish-article"``, ``"NotificationPreference"`` → ``"notification-preference"`` (PascalCase boundaries are split first via :attr:`snake`). """ return self.snake.replace("_", "-")
[docs] def suffixed(self, suffix: str) -> str: """PascalCase name with *suffix* appended. Args: suffix: Class-name suffix, e.g. ``"CreateRequest"``. Returns: Combined string, e.g. ``"ArticleCreateRequest"``. """ return f"{self.pascal}{suffix}"
[docs] @classmethod def from_dotted(cls, dotted_path: str) -> tuple[str, Name]: """Create a :class:`Name` from a dotted import path. Args: dotted_path: A fully-qualified class path such as ``"myapp.models.Article"``. Returns: A ``(module, Name)`` tuple, e.g. ``("myapp.models", Name("Article"))``. Callers that need the raw class-name string can read it from ``Name.raw``. Raises: ValueError: If *dotted_path* contains fewer than two parts. """ if "." not in dotted_path: msg = ( f"'{dotted_path}' is not a valid dotted import path. " f"Expected 'module.ClassName', " f"e.g. 'myapp.models.Article'." ) raise ValueError(msg) module, _, class_name = dotted_path.rpartition(".") return module, cls(class_name)
[docs] @staticmethod def parent_path(dotted: str, *, levels: int = 1) -> str: """Return *dotted*'s ancestor module path. Drops the last *levels* dot-separated segments. When the path runs out of segments before *levels* are stripped, it stops and returns whatever remains rather than producing an empty string — so callers can chain the op without guarding for short paths:: >>> Name.parent_path("blog.models.Article") 'blog.models' >>> Name.parent_path("blog.models.Article", levels=2) 'blog' >>> Name.parent_path("single", levels=2) 'single' ``levels=2`` is the common idiom for "app module from model dotted path" -- ``"blog.models.Article" -> "blog"``. """ for _ in range(levels): head, sep, _ = dotted.rpartition(".") if not sep: return dotted dotted = head return dotted
def split_dotted_class(dotted_path: str) -> tuple[str, str]: """Split a fully-qualified class path into ``(module, class_name)``. Convenience for callers that just want both pieces as strings — skip the :class:`Name` wrapper that :meth:`Name.from_dotted` returns when the only thing they need is the bare class-name string for an import line. Args: dotted_path: Fully-qualified class path, e.g. ``"myapp.models.Article"``. Returns: ``(module, class_name)`` tuple of plain strings. Raises: ValueError: If *dotted_path* contains fewer than two parts. """ module, name = Name.from_dotted(dotted_path) return module, name.raw
[docs] def prefix_import(prefix: str, *parts: str) -> str: """Build a Python import path under *prefix* (which may be empty). Args: prefix: Optional package prefix, e.g. ``"_generated"``. *parts: Module name segments to join with ``.``. Returns: A ``.``-joined import path, with *prefix* prepended when non-empty. """ if prefix: return ".".join([prefix, *parts]) return ".".join(parts)