wexample-wex-addon-dev-python 12.2.0__py3-none-any.whl → 13.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. wexample_wex_addon_dev_python/commands/code/rename.py +41 -19
  2. wexample_wex_addon_dev_python/file/python_pyproject_toml_file.py +11 -11
  3. wexample_wex_addon_dev_python/python_addon_manager.py +1 -1
  4. wexample_wex_addon_dev_python/refactor/abstract_python_path_rename_handler.py +104 -0
  5. wexample_wex_addon_dev_python/refactor/abstract_python_rename_handler.py +89 -0
  6. wexample_wex_addon_dev_python/refactor/abstract_python_symbol_rename_handler.py +183 -0
  7. wexample_wex_addon_dev_python/refactor/applier.py +49 -8
  8. wexample_wex_addon_dev_python/refactor/python_class_rename_handler.py +19 -0
  9. wexample_wex_addon_dev_python/refactor/python_common.py +426 -0
  10. wexample_wex_addon_dev_python/refactor/python_constant_rename_handler.py +37 -0
  11. wexample_wex_addon_dev_python/refactor/python_function_rename_handler.py +21 -0
  12. wexample_wex_addon_dev_python/refactor/python_module_rename_handler.py +74 -0
  13. wexample_wex_addon_dev_python/refactor/python_package_rename_handler.py +83 -219
  14. wexample_wex_addon_dev_python/refactor/rename_plan.py +58 -17
  15. wexample_wex_addon_dev_python/refactor/symbol_kind.py +21 -6
  16. wexample_wex_addon_dev_python/services/python/commands/service/setup.py +2 -2
  17. wexample_wex_addon_dev_python/workdir/python_package_workdir.py +8 -8
  18. wexample_wex_addon_dev_python/workdir/python_workdir.py +6 -6
  19. {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/METADATA +10 -10
  20. {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/RECORD +24 -16
  21. /wexample_wex_addon_dev_python/{helpers → helper}/__init__.py +0 -0
  22. /wexample_wex_addon_dev_python/{helpers → helper}/pdm.py +0 -0
  23. {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/WHEEL +0 -0
  24. {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,426 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import libcst as cst
8
+
9
+ # Directories never scanned for source files (build/venv artefacts, VCS).
10
+ SKIP_DIR_NAMES: frozenset[str] = frozenset({
11
+ "__pycache__",
12
+ ".venv",
13
+ "venv",
14
+ "env",
15
+ ".git",
16
+ "node_modules",
17
+ ".pytest_cache",
18
+ ".mypy_cache",
19
+ ".ruff_cache",
20
+ ".pdm-build",
21
+ "dist",
22
+ "build",
23
+ })
24
+
25
+
26
+ def dotted_to_parts(node) -> tuple[str, ...] | None:
27
+ """Flatten a chained `cst.Attribute` / `cst.Name` into ('a', 'b', 'c')."""
28
+ parts: list[str] = []
29
+ cur = node
30
+ while isinstance(cur, cst.Attribute):
31
+ if not isinstance(cur.attr, cst.Name):
32
+ return None
33
+ parts.append(cur.attr.value)
34
+ cur = cur.value
35
+ if not isinstance(cur, cst.Name):
36
+ return None
37
+ parts.append(cur.value)
38
+ parts.reverse()
39
+ return tuple(parts)
40
+
41
+
42
+ def file_to_package_parts(py_file: Path) -> tuple[str, ...] | None:
43
+ """Return the dotted parts of the Python package containing `py_file`.
44
+
45
+ Walks up from the file's directory through every parent that has an
46
+ `__init__.py`; the walk stops at the first ancestor without one, which
47
+ marks the package root. Returns None when the file isn't inside any
48
+ Python package (no `__init__.py` chain). Used to resolve relative
49
+ imports to their absolute equivalent.
50
+ """
51
+ parts: list[str] = []
52
+ current = py_file.parent
53
+ while (current / "__init__.py").is_file():
54
+ parts.append(current.name)
55
+ up = current.parent
56
+ if up == current:
57
+ break
58
+ current = up
59
+ if not parts:
60
+ return None
61
+ parts.reverse()
62
+ return tuple(parts)
63
+
64
+
65
+ def iter_python_files(scope_roots: list[Path]) -> Iterator[Path]:
66
+ """Iterate every .py file under any scope root, skipping build/VCS dirs."""
67
+ seen: set[str] = set()
68
+ for root in scope_roots:
69
+ stack: list[Path] = [root]
70
+ while stack:
71
+ current = stack.pop()
72
+ try:
73
+ for entry in current.iterdir():
74
+ if entry.is_dir():
75
+ if entry.name not in SKIP_DIR_NAMES:
76
+ stack.append(entry)
77
+ elif entry.suffix == ".py":
78
+ key = str(entry.resolve())
79
+ if key in seen:
80
+ continue
81
+ seen.add(key)
82
+ yield entry
83
+ except (PermissionError, FileNotFoundError):
84
+ continue
85
+
86
+
87
+ def parts_to_dotted(parts: tuple[str, ...]):
88
+ node: cst.BaseExpression = cst.Name(parts[0])
89
+ for part in parts[1:]:
90
+ node = cst.Attribute(value=node, attr=cst.Name(part))
91
+ return node
92
+
93
+
94
+ def rewrite_imports(
95
+ py_file: Path,
96
+ old_parts: tuple[str, ...],
97
+ new_parts: tuple[str, ...],
98
+ ) -> str | None:
99
+ """Return rewritten source, or None when no import touched this file."""
100
+ source = py_file.read_text(encoding="utf-8")
101
+ try:
102
+ module = cst.parse_module(source)
103
+ except cst.ParserSyntaxError:
104
+ return None
105
+
106
+ transformer = _ImportRenameTransformer(
107
+ old_parts=old_parts,
108
+ new_parts=new_parts,
109
+ file_pkg=file_to_package_parts(py_file),
110
+ )
111
+ new_module = module.visit(transformer)
112
+ if not transformer.changed:
113
+ return None
114
+ return new_module.code
115
+
116
+
117
+ def rewrite_symbol_references(
118
+ py_file: Path,
119
+ parent_parts: tuple[str, ...],
120
+ old_name: str,
121
+ new_name: str,
122
+ *,
123
+ is_definition_file: bool = False,
124
+ ) -> str | None:
125
+ """Return rewritten source, or None when no occurrence touched this file.
126
+
127
+ Handles imports (`from <parent> import <old_name>`, with or without `as`),
128
+ dotted imports (`import <parent>.<old_name>`), attribute access
129
+ (`<parent>.<old_name>`), and bare references whose qualified name resolves
130
+ to the external dotted name. In the definition file, locally-bound
131
+ `<old_name>` references are renamed too.
132
+ """
133
+ source = py_file.read_text(encoding="utf-8")
134
+ # Cheap pre-filter: QualifiedNameProvider scope analysis is expensive,
135
+ # most files don't even mention the symbol. The substring check matches
136
+ # def statements, imports, attribute access and bare references in one go.
137
+ if old_name not in source:
138
+ return None
139
+
140
+ try:
141
+ module = cst.parse_module(source)
142
+ except cst.ParserSyntaxError:
143
+ return None
144
+
145
+ wrapper = cst.metadata.MetadataWrapper(module)
146
+ transformer = _SymbolRenameTransformer(
147
+ parent_parts=parent_parts,
148
+ old_name=old_name,
149
+ new_name=new_name,
150
+ is_definition_file=is_definition_file,
151
+ )
152
+ new_module = wrapper.visit(transformer)
153
+ if not transformer.changed:
154
+ return None
155
+ return new_module.code
156
+
157
+
158
+ @dataclass
159
+ class RenameReport:
160
+ """Outcome of a rename run.
161
+
162
+ `source_path`/`target_path` describe the move (or proposed move in dry-run);
163
+ they may point to a directory (PACKAGE) or to a .py file (MODULE).
164
+ `files_changed` lists every .py whose imports were rewritten.
165
+ """
166
+
167
+ files_changed: list[Path] = field(default_factory=list)
168
+ files_scanned: int = 0
169
+ source_path: Path | None = None
170
+ target_path: Path | None = None
171
+
172
+
173
+ class _ImportRenameTransformer(cst.CSTTransformer):
174
+ """Rewrites import statements that target the renamed package/module.
175
+
176
+ Covers four shapes:
177
+ A. `from <old>.<...> import X` / `import <old>.<...>` — absolute path
178
+ starts with the renamed entity.
179
+ B. `from <parent-of-old> import <old.last_part>` — the entity is
180
+ imported by name (alias position) under its direct parent.
181
+ C. Relative variants of A: `from .<x>.<...> import Y` whose resolved
182
+ absolute path starts with the renamed entity. Needs the current
183
+ file's dotted package (`file_pkg`) to resolve the dots.
184
+ D. Relative variants of B: `from .<...> import <old.last_part>` whose
185
+ resolved module path equals the old entity's direct parent.
186
+ """
187
+
188
+ def __init__(
189
+ self,
190
+ old_parts: tuple[str, ...],
191
+ new_parts: tuple[str, ...],
192
+ file_pkg: tuple[str, ...] | None = None,
193
+ ) -> None:
194
+ super().__init__()
195
+ self.old_parts = old_parts
196
+ self.new_parts = new_parts
197
+ self.file_pkg = file_pkg
198
+ self._n = len(old_parts)
199
+ self.changed = False
200
+
201
+ def leave_Import(
202
+ self,
203
+ original_node: cst.Import,
204
+ updated_node: cst.Import,
205
+ ) -> cst.Import:
206
+ new_names = []
207
+ touched = False
208
+ for alias in updated_node.names:
209
+ parts = dotted_to_parts(alias.name)
210
+ if parts is None or not self._starts_with(parts):
211
+ new_names.append(alias)
212
+ continue
213
+ new_parts = self.new_parts + parts[self._n:]
214
+ new_names.append(alias.with_changes(name=parts_to_dotted(new_parts)))
215
+ touched = True
216
+ if not touched:
217
+ return updated_node
218
+ self.changed = True
219
+ return updated_node.with_changes(names=new_names)
220
+
221
+ def leave_ImportFrom(
222
+ self,
223
+ original_node: cst.ImportFrom,
224
+ updated_node: cst.ImportFrom,
225
+ ) -> cst.ImportFrom:
226
+ rel_count = len(updated_node.relative)
227
+ resolved_pkg = self._resolve_relative_prefix(rel_count)
228
+ if rel_count > 0 and resolved_pkg is None:
229
+ # Relative import we can't resolve (no file_pkg or depth too deep) →
230
+ # leave it alone instead of guessing.
231
+ return updated_node
232
+
233
+ module_parts = (
234
+ dotted_to_parts(updated_node.module)
235
+ if updated_node.module is not None
236
+ else ()
237
+ )
238
+ if updated_node.module is not None and module_parts is None:
239
+ return updated_node
240
+
241
+ absolute_module = (resolved_pkg or ()) + (module_parts or ())
242
+
243
+ # Case A/C — the module side IS the renamed entity (or below it).
244
+ if self._starts_with(absolute_module):
245
+ new_absolute = self.new_parts + absolute_module[self._n:]
246
+ new_module_parts = new_absolute[len(resolved_pkg or ()):]
247
+ self.changed = True
248
+ new_module_node = (
249
+ parts_to_dotted(new_module_parts) if new_module_parts else None
250
+ )
251
+ return updated_node.with_changes(module=new_module_node)
252
+
253
+ # Case B/D — the module is exactly the direct parent of the renamed
254
+ # entity, and the entity appears as an imported name.
255
+ if (
256
+ self._n >= 1
257
+ and len(absolute_module) == self._n - 1
258
+ and absolute_module == self.old_parts[:-1]
259
+ ):
260
+ old_last = self.old_parts[-1]
261
+ new_last = self.new_parts[-1]
262
+ new_names = []
263
+ touched = False
264
+ for alias in updated_node.names:
265
+ if (
266
+ isinstance(alias.name, cst.Name)
267
+ and alias.name.value == old_last
268
+ ):
269
+ new_names.append(alias.with_changes(name=cst.Name(new_last)))
270
+ touched = True
271
+ else:
272
+ new_names.append(alias)
273
+ if touched:
274
+ self.changed = True
275
+ return updated_node.with_changes(names=new_names)
276
+
277
+ return updated_node
278
+
279
+ def _resolve_relative_prefix(
280
+ self, rel_count: int
281
+ ) -> tuple[str, ...] | None:
282
+ """Convert a relative-import dot count into the absolute package
283
+ prefix it resolves to. Returns `()` for an absolute (non-relative)
284
+ import, the resolved prefix for a relative one, or None when
285
+ resolution is impossible (no file_pkg or depth overflow)."""
286
+ if rel_count == 0:
287
+ return ()
288
+ if self.file_pkg is None:
289
+ return None
290
+ # `from . import X` ← parent_count 0 (current package)
291
+ # `from .. import X` ← parent_count 1 (one level up)
292
+ parent_count = rel_count - 1
293
+ if parent_count > len(self.file_pkg):
294
+ return None
295
+ if parent_count == 0:
296
+ return self.file_pkg
297
+ return self.file_pkg[:-parent_count]
298
+
299
+ def _starts_with(self, parts: tuple[str, ...]) -> bool:
300
+ return len(parts) >= self._n and parts[: self._n] == self.old_parts
301
+
302
+
303
+ class _SymbolRenameTransformer(cst.CSTTransformer):
304
+ """Rewrites references to one specific symbol (parent.old_name → parent.new_name).
305
+
306
+ Two cases handled uniformly via libcst's QualifiedNameProvider:
307
+ - Consumer file: matches the external IMPORT-sourced qualified name
308
+ `<parent>.<old_name>` on Name/Attribute usages, and rewrites
309
+ `from <parent> import <old_name>` / `import <parent>.<old_name>` lines.
310
+ - Definition file: when `is_definition_file=True`, additionally rewrites
311
+ Name nodes whose qualified name is the bare `<old_name>` with LOCAL
312
+ source — that covers the def statement itself plus same-module references.
313
+
314
+ Alias preservation: `from M import Old as X` keeps `X` untouched because
315
+ `original.value == 'X' != 'Old'`; only the import's `name` part is
316
+ rewritten via `leave_ImportFrom`.
317
+ """
318
+
319
+ METADATA_DEPENDENCIES = (cst.metadata.QualifiedNameProvider,)
320
+
321
+ def __init__(
322
+ self,
323
+ parent_parts: tuple[str, ...],
324
+ old_name: str,
325
+ new_name: str,
326
+ *,
327
+ is_definition_file: bool = False,
328
+ ) -> None:
329
+ super().__init__()
330
+ self.parent_parts = parent_parts
331
+ self.old_name = old_name
332
+ self.new_name = new_name
333
+ self.external_qname = ".".join(parent_parts) + "." + old_name
334
+ self.is_definition_file = is_definition_file
335
+ self.changed = False
336
+
337
+ def leave_Attribute(
338
+ self,
339
+ original_node: cst.Attribute,
340
+ updated_node: cst.Attribute,
341
+ ) -> cst.Attribute:
342
+ if (
343
+ not isinstance(original_node.attr, cst.Name)
344
+ or original_node.attr.value != self.old_name
345
+ ):
346
+ return updated_node
347
+ if not self._qname_matches_external(original_node):
348
+ return updated_node
349
+ self.changed = True
350
+ return updated_node.with_changes(attr=cst.Name(self.new_name))
351
+
352
+ def leave_Import(
353
+ self,
354
+ original_node: cst.Import,
355
+ updated_node: cst.Import,
356
+ ) -> cst.Import:
357
+ new_names: list[cst.ImportAlias] = []
358
+ touched = False
359
+ full_parts = self.parent_parts + (self.old_name,)
360
+ for alias in updated_node.names:
361
+ parts = dotted_to_parts(alias.name)
362
+ if parts == full_parts:
363
+ new_parts = self.parent_parts + (self.new_name,)
364
+ new_names.append(alias.with_changes(name=parts_to_dotted(new_parts)))
365
+ touched = True
366
+ else:
367
+ new_names.append(alias)
368
+ if not touched:
369
+ return updated_node
370
+ self.changed = True
371
+ return updated_node.with_changes(names=new_names)
372
+
373
+ def leave_ImportFrom(
374
+ self,
375
+ original_node: cst.ImportFrom,
376
+ updated_node: cst.ImportFrom,
377
+ ) -> cst.ImportFrom:
378
+ if updated_node.module is None:
379
+ return updated_node
380
+ module_parts = dotted_to_parts(updated_node.module)
381
+ if module_parts != self.parent_parts:
382
+ return updated_node
383
+ if not isinstance(updated_node.names, (list, tuple)):
384
+ return updated_node
385
+ new_names: list[cst.ImportAlias] = []
386
+ touched = False
387
+ for alias in updated_node.names:
388
+ if (
389
+ isinstance(alias.name, cst.Name)
390
+ and alias.name.value == self.old_name
391
+ ):
392
+ new_names.append(alias.with_changes(name=cst.Name(self.new_name)))
393
+ touched = True
394
+ else:
395
+ new_names.append(alias)
396
+ if not touched:
397
+ return updated_node
398
+ self.changed = True
399
+ return updated_node.with_changes(names=new_names)
400
+
401
+ def leave_Name(
402
+ self,
403
+ original_node: cst.Name,
404
+ updated_node: cst.Name,
405
+ ) -> cst.Name:
406
+ if original_node.value != self.old_name:
407
+ return updated_node
408
+ quals = self.get_metadata(
409
+ cst.metadata.QualifiedNameProvider, original_node, set()
410
+ )
411
+ for q in quals:
412
+ if q.name == self.external_qname:
413
+ self.changed = True
414
+ return updated_node.with_changes(value=self.new_name)
415
+ if (
416
+ self.is_definition_file
417
+ and q.name == self.old_name
418
+ and q.source == cst.metadata.QualifiedNameSource.LOCAL
419
+ ):
420
+ self.changed = True
421
+ return updated_node.with_changes(value=self.new_name)
422
+ return updated_node
423
+
424
+ def _qname_matches_external(self, node: cst.CSTNode) -> bool:
425
+ quals = self.get_metadata(cst.metadata.QualifiedNameProvider, node, set())
426
+ return any(q.name == self.external_qname for q in quals)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import libcst as cst
4
+ from wexample_helpers.decorator.base_class import base_class
5
+
6
+ from wexample_wex_addon_dev_python.refactor.abstract_python_symbol_rename_handler import (
7
+ AbstractPythonSymbolRenameHandler,
8
+ )
9
+
10
+
11
+ @base_class
12
+ class PythonConstantRenameHandler(AbstractPythonSymbolRenameHandler):
13
+ """Rename a top-level constant — a module-level assignment whose target is
14
+ a single bare name. Catches both annotated (`FOO: int = 1`) and plain
15
+ (`FOO = 1`) forms. Tuple/multi-target assignments are intentionally
16
+ skipped to keep the predicate unambiguous."""
17
+
18
+ def _is_definition_node(self, node: cst.CSTNode, name: str) -> bool:
19
+ if isinstance(node, cst.SimpleStatementLine):
20
+ for small in node.body:
21
+ if self._statement_assigns(small, name):
22
+ return True
23
+ return False
24
+
25
+ def _kind_label(self) -> str:
26
+ return "constant"
27
+
28
+ def _statement_assigns(self, small: cst.BaseSmallStatement, name: str) -> bool:
29
+ if isinstance(small, cst.Assign):
30
+ if len(small.targets) != 1:
31
+ return False
32
+ target = small.targets[0].target
33
+ return isinstance(target, cst.Name) and target.value == name
34
+ if isinstance(small, cst.AnnAssign):
35
+ target = small.target
36
+ return isinstance(target, cst.Name) and target.value == name
37
+ return False
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import libcst as cst
4
+ from wexample_helpers.decorator.base_class import base_class
5
+
6
+ from wexample_wex_addon_dev_python.refactor.abstract_python_symbol_rename_handler import (
7
+ AbstractPythonSymbolRenameHandler,
8
+ )
9
+
10
+
11
+ @base_class
12
+ class PythonFunctionRenameHandler(AbstractPythonSymbolRenameHandler):
13
+ """Rename a top-level function declared with `def foo(...)` at module level.
14
+ Async-def is covered too. Methods on classes are out of scope here — they
15
+ need type-aware dispatch resolution."""
16
+
17
+ def _is_definition_node(self, node: cst.CSTNode, name: str) -> bool:
18
+ return isinstance(node, cst.FunctionDef) and node.name.value == name
19
+
20
+ def _kind_label(self) -> str:
21
+ return "function"
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from wexample_helpers.decorator.base_class import base_class
6
+
7
+ from wexample_wex_addon_dev_python.refactor.abstract_python_path_rename_handler import (
8
+ AbstractPythonPathRenameHandler,
9
+ )
10
+ from wexample_wex_addon_dev_python.refactor.python_common import SKIP_DIR_NAMES
11
+
12
+
13
+ @base_class
14
+ class PythonModuleRenameHandler(AbstractPythonPathRenameHandler):
15
+ """Rename a single Python module (a `.py` file) and propagate the
16
+ import-rewrite. Unlike a package, the move can cross parent directories
17
+ — the target's parent must already exist (we don't create new packages
18
+ implicitly; the preflight surfaces a missing parent as a clean error)."""
19
+
20
+ def __attrs_post_init__(self) -> None:
21
+ super().__attrs_post_init__()
22
+ # A module dotted name needs at least one parent package — a bare
23
+ # top-level name has nowhere to live and `_derive_target_path`
24
+ # couldn't compute a target for it.
25
+ if "." not in self.source or "." not in self.target:
26
+ raise ValueError(
27
+ "module source/target must be fully dotted (e.g. 'pkg.sub.mod')"
28
+ )
29
+
30
+ def _derive_target_path(self, source_path: Path) -> Path:
31
+ n = len(self._source_parts)
32
+ # Strip the .py extension to align with `_source_parts`, swap the
33
+ # trailing N parts for `_target_parts`, then re-attach the extension.
34
+ file_parts_stripped = source_path.parts[:-1] + (source_path.stem,)
35
+ if file_parts_stripped[-n:] != self._source_parts:
36
+ raise RuntimeError(
37
+ f"Source file {source_path} doesn't end with parts {self._source_parts}"
38
+ )
39
+ prefix = file_parts_stripped[:-n]
40
+ target_with_ext = self._target_parts[:-1] + (self._target_parts[-1] + ".py",)
41
+ return Path(*prefix, *target_with_ext)
42
+
43
+ def _find_source_path(self) -> Path:
44
+ parent_parts = self._source_parts[:-1]
45
+ file_name = self._source_parts[-1] + ".py"
46
+ n_parent = len(parent_parts)
47
+
48
+ matches: list[Path] = []
49
+ for root in self.scope_roots:
50
+ for init_file in root.rglob("__init__.py"):
51
+ if any(part in SKIP_DIR_NAMES for part in init_file.parent.parts):
52
+ continue
53
+ pkg_dir = init_file.parent
54
+ if len(pkg_dir.parts) < n_parent:
55
+ continue
56
+ if pkg_dir.parts[-n_parent:] != parent_parts:
57
+ continue
58
+ candidate = pkg_dir / file_name
59
+ if candidate.is_file():
60
+ matches.append(candidate)
61
+
62
+ unique = list({str(m): m for m in matches}.values())
63
+
64
+ if not unique:
65
+ roots_str = ", ".join(str(r) for r in self.scope_roots)
66
+ raise FileNotFoundError(
67
+ f"No module file matching '{self.source}' under: {roots_str}"
68
+ )
69
+ if len(unique) > 1:
70
+ joined = "\n ".join(str(m) for m in unique)
71
+ raise ValueError(
72
+ f"Multiple modules match '{self.source}', refine the dotted name:\n {joined}"
73
+ )
74
+ return unique[0]