Coverage for pytest_jsonschema_snapshot/tools/name_maker.py: 70%
77 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-02 00:37 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-02 00:37 +0000
1from __future__ import annotations
3import inspect
4import re
5import types
6from functools import partial
7from typing import Callable, List, Optional, TypedDict
9# ──────────────────────────── Типы ────────────────────────────
10_Meta = TypedDict(
11 "_Meta",
12 {
13 "package": str,
14 "package_full": str,
15 "path_parts": List[str],
16 "class": Optional[str],
17 "method": str,
18 },
19)
22# ──────────────────────────── Класс ───────────────────────────
23class NameMaker:
24 """
25 Lightweight helper that converts a callable into a string identifier
26 using a tiny placeholder-based template language (keeps backward
27 compatibility with the original test-suite).
29 Supported placeholders
30 ----------------------
32 `{package}` – full module path (``tests.test_mod``)
34 `{package_full=SEP}` – same but with custom separator (default “.”)
36 `{path} / {path=SEP}` – module path *without* the first segment
38 `{class}` – class name or empty string
40 `{method}` – function / method name
42 `{class_method} / {...=SEP}` – ``Class{SEP}method`` or just ``method``
44 Unknown placeholders collapse to an empty string.
46 After substitution:
47 * “//”, “..”, “--” are collapsed to “/”, “.”, “-” respectively;
48 * double underscores **are preserved** so ``__call__`` stays intact.
49 """
51 _RE_PLHDR = re.compile(r"\{([^{}]+)\}")
53 # ───────────────────────────── PUBLIC ──────────────────────────────
54 @staticmethod
55 def format(obj: Callable[..., object], rule: str) -> str:
56 """
57 Render *rule* using metadata extracted from *obj*.
58 """
59 meta: _Meta = NameMaker._meta(obj)
61 def _sub(match: re.Match[str]) -> str: # noqa: N802
62 token: str | None = match.group(1)
63 name: str = token.split("=", 1)[0] if token else ""
64 joiner: str | None = token.split("=", 1)[1] if token and "=" in token else None
65 return NameMaker._expand(name, joiner, meta)
67 out = NameMaker._RE_PLHDR.sub(_sub, rule)
68 return NameMaker._collapse(out)
70 # ──────────────────────────── INTERNAL ────────────────────────────
71 # metadata ----------------------------------------------------------
72 @staticmethod
73 def _unwrap(obj: Callable[..., object]) -> Callable[..., object]:
74 """Strip functools.partial and @functools.wraps wrappers."""
75 while True:
76 if isinstance(obj, partial):
77 obj = obj.func
78 continue
79 if hasattr(obj, "__wrapped__"):
80 obj = obj.__wrapped__
81 continue
82 break
83 return obj
85 @staticmethod
86 def _meta(obj: Callable[..., object]) -> _Meta:
87 """Return mapping used during placeholder substitution."""
88 obj = NameMaker._unwrap(obj)
90 # 1) built-in function (len, sum, …)
91 if inspect.isbuiltin(obj) or isinstance(obj, types.BuiltinFunctionType):
92 qualname = obj.__name__
93 module = obj.__module__ or "builtins"
95 # 2) callable instance (defines __call__)
96 elif not (inspect.isfunction(obj) or inspect.ismethod(obj)):
97 qualname = f"{obj.__class__.__qualname__}.__call__"
98 module = obj.__class__.__module__
100 # 3) regular function / bound or unbound method
101 else:
102 qualname = obj.__qualname__
103 module = obj.__module__
105 parts: List[str] = qualname.split(".")
106 cls: Optional[str] = None
107 if len(parts) > 1 and parts[-2] != "<locals>":
108 cls = parts[-2]
109 method = parts[-1]
111 mod_parts = (module or "").split(".")
112 return {
113 "package": module,
114 "package_full": module,
115 "path_parts": mod_parts[1:] if len(mod_parts) > 1 else [],
116 "class": cls,
117 "method": method,
118 }
120 # placeholders ------------------------------------------------------
121 @staticmethod
122 def _expand(name: str, joiner: Optional[str], m: _Meta) -> str:
123 if name == "package":
124 return m["package"]
125 if name == "package_full":
126 sep = joiner if joiner is not None else "."
127 return sep.join(m["package_full"].split("."))
128 if name == "path":
129 if not m["path_parts"]:
130 return ""
131 sep = joiner if joiner is not None else "/"
132 return sep.join(m["path_parts"])
133 if name == "class":
134 return m["class"] or ""
135 if name == "method":
136 return m["method"]
137 if name == "class_method":
138 sep = joiner if joiner is not None else "."
139 cls_name = m["class"]
140 if cls_name:
141 return sep.join([cls_name, m["method"]])
142 return m["method"]
143 # unknown placeholder → empty
144 return ""
146 # post-processing ---------------------------------------------------
147 @staticmethod
148 def _collapse(s: str) -> str:
149 # collapse critical duplicates but keep double underscores
150 s = re.sub(r"/{2,}", "/", s) # '//' → '/'
151 s = re.sub(r"\.{2,}", ".", s) # '..' → '.'
152 s = re.sub(r"-{2,}", "-", s) # '--' → '-'
153 return s