codeshift 0.3.3__py3-none-any.whl → 0.3.5__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 (36) hide show
  1. codeshift/cli/commands/apply.py +24 -2
  2. codeshift/cli/package_manager.py +102 -0
  3. codeshift/knowledge/generator.py +11 -1
  4. codeshift/knowledge_base/libraries/aiohttp.yaml +186 -0
  5. codeshift/knowledge_base/libraries/attrs.yaml +181 -0
  6. codeshift/knowledge_base/libraries/celery.yaml +244 -0
  7. codeshift/knowledge_base/libraries/click.yaml +195 -0
  8. codeshift/knowledge_base/libraries/django.yaml +355 -0
  9. codeshift/knowledge_base/libraries/flask.yaml +270 -0
  10. codeshift/knowledge_base/libraries/httpx.yaml +183 -0
  11. codeshift/knowledge_base/libraries/marshmallow.yaml +238 -0
  12. codeshift/knowledge_base/libraries/numpy.yaml +429 -0
  13. codeshift/knowledge_base/libraries/pytest.yaml +192 -0
  14. codeshift/knowledge_base/libraries/sqlalchemy.yaml +2 -1
  15. codeshift/migrator/engine.py +60 -0
  16. codeshift/migrator/transforms/__init__.py +2 -0
  17. codeshift/migrator/transforms/aiohttp_transformer.py +608 -0
  18. codeshift/migrator/transforms/attrs_transformer.py +570 -0
  19. codeshift/migrator/transforms/celery_transformer.py +546 -0
  20. codeshift/migrator/transforms/click_transformer.py +526 -0
  21. codeshift/migrator/transforms/django_transformer.py +852 -0
  22. codeshift/migrator/transforms/fastapi_transformer.py +12 -7
  23. codeshift/migrator/transforms/flask_transformer.py +505 -0
  24. codeshift/migrator/transforms/httpx_transformer.py +419 -0
  25. codeshift/migrator/transforms/marshmallow_transformer.py +515 -0
  26. codeshift/migrator/transforms/numpy_transformer.py +413 -0
  27. codeshift/migrator/transforms/pydantic_v1_to_v2.py +53 -8
  28. codeshift/migrator/transforms/pytest_transformer.py +351 -0
  29. codeshift/migrator/transforms/requests_transformer.py +74 -1
  30. codeshift/migrator/transforms/sqlalchemy_transformer.py +692 -39
  31. {codeshift-0.3.3.dist-info → codeshift-0.3.5.dist-info}/METADATA +46 -4
  32. {codeshift-0.3.3.dist-info → codeshift-0.3.5.dist-info}/RECORD +36 -15
  33. {codeshift-0.3.3.dist-info → codeshift-0.3.5.dist-info}/WHEEL +0 -0
  34. {codeshift-0.3.3.dist-info → codeshift-0.3.5.dist-info}/entry_points.txt +0 -0
  35. {codeshift-0.3.3.dist-info → codeshift-0.3.5.dist-info}/licenses/LICENSE +0 -0
  36. {codeshift-0.3.3.dist-info → codeshift-0.3.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,570 @@
1
+ """attrs 21.x to 23.x+ transformation using LibCST.
2
+
3
+ Transforms legacy attr namespace to modern attrs namespace:
4
+ - @attr.s -> @attrs.define
5
+ - @attr.attrs -> @attrs.define
6
+ - @attr.s(frozen=True) -> @attrs.frozen
7
+ - attr.ib() -> attrs.field()
8
+ - attr.attrib() -> attrs.field()
9
+ - attr.Factory -> attrs.Factory
10
+ - attr.asdict/astuple/fields/has/evolve -> attrs.*
11
+ - attr.validators.* -> attrs.validators.*
12
+ - attr.converters.* -> attrs.converters.*
13
+ - cmp parameter -> eq and order parameters
14
+ """
15
+
16
+ import libcst as cst
17
+
18
+ from codeshift.migrator.ast_transforms import BaseTransformer
19
+
20
+
21
+ class AttrsTransformer(BaseTransformer):
22
+ """Transform attrs 21.x code to 23.x+."""
23
+
24
+ def __init__(self) -> None:
25
+ super().__init__()
26
+ self._needs_attrs_import = False
27
+ self._needs_define_import = False
28
+ self._needs_frozen_import = False
29
+ self._needs_field_import = False
30
+ self._needs_factory_import = False
31
+ self._has_attr_import = False
32
+ self._has_attrs_import = False
33
+
34
+ def visit_Import(self, node: cst.Import) -> bool:
35
+ """Track import attr statements."""
36
+ if isinstance(node.names, cst.ImportStar):
37
+ return True
38
+ for name in node.names:
39
+ if isinstance(name, cst.ImportAlias):
40
+ if isinstance(name.name, cst.Name):
41
+ if name.name.value == "attr":
42
+ self._has_attr_import = True
43
+ elif name.name.value == "attrs":
44
+ self._has_attrs_import = True
45
+ return True
46
+
47
+ def visit_ImportFrom(self, node: cst.ImportFrom) -> bool:
48
+ """Track from attr/attrs import statements."""
49
+ if node.module is None:
50
+ return True
51
+ module_name = self._get_module_name(node.module)
52
+ if module_name == "attr" or module_name.startswith("attr."):
53
+ self._has_attr_import = True
54
+ elif module_name == "attrs" or module_name.startswith("attrs."):
55
+ self._has_attrs_import = True
56
+ return True
57
+
58
+ def leave_Import(self, original_node: cst.Import, updated_node: cst.Import) -> cst.Import:
59
+ """Transform import attr to import attrs."""
60
+ if isinstance(updated_node.names, cst.ImportStar):
61
+ return updated_node
62
+
63
+ new_names = []
64
+ changed = False
65
+
66
+ for name in updated_node.names:
67
+ if isinstance(name, cst.ImportAlias):
68
+ if isinstance(name.name, cst.Name) and name.name.value == "attr":
69
+ # Transform import attr to import attrs
70
+ new_names.append(name.with_changes(name=cst.Name("attrs")))
71
+ changed = True
72
+ self.record_change(
73
+ description="Change 'import attr' to 'import attrs'",
74
+ line_number=1,
75
+ original="import attr",
76
+ replacement="import attrs",
77
+ transform_name="import_attr_to_attrs",
78
+ )
79
+ else:
80
+ new_names.append(name)
81
+ else:
82
+ new_names.append(name)
83
+
84
+ if changed:
85
+ return updated_node.with_changes(names=new_names)
86
+ return updated_node
87
+
88
+ def leave_ImportFrom(
89
+ self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom
90
+ ) -> cst.ImportFrom:
91
+ """Transform from attr import ... to from attrs import ..."""
92
+ if updated_node.module is None:
93
+ return updated_node
94
+
95
+ module_name = self._get_module_name(updated_node.module)
96
+
97
+ # Transform from attr import ...
98
+ if module_name == "attr":
99
+ self.record_change(
100
+ description="Change 'from attr import' to 'from attrs import'",
101
+ line_number=1,
102
+ original="from attr import ...",
103
+ replacement="from attrs import ...",
104
+ transform_name="from_attr_to_attrs",
105
+ )
106
+ # Also transform imported names
107
+ names = updated_node.names
108
+ if not isinstance(names, cst.ImportStar):
109
+ names = tuple(names)
110
+ new_names = self._transform_import_names(names)
111
+ return updated_node.with_changes(module=cst.Name("attrs"), names=new_names)
112
+
113
+ # Transform from attr.validators import ...
114
+ if module_name == "attr.validators":
115
+ self.record_change(
116
+ description="Change 'from attr.validators' to 'from attrs.validators'",
117
+ line_number=1,
118
+ original="from attr.validators import ...",
119
+ replacement="from attrs.validators import ...",
120
+ transform_name="attr_validators_to_attrs_validators",
121
+ )
122
+ return updated_node.with_changes(
123
+ module=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("validators"))
124
+ )
125
+
126
+ # Transform from attr.converters import ...
127
+ if module_name == "attr.converters":
128
+ self.record_change(
129
+ description="Change 'from attr.converters' to 'from attrs.converters'",
130
+ line_number=1,
131
+ original="from attr.converters import ...",
132
+ replacement="from attrs.converters import ...",
133
+ transform_name="attr_converters_to_attrs_converters",
134
+ )
135
+ return updated_node.with_changes(
136
+ module=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("converters"))
137
+ )
138
+
139
+ return updated_node
140
+
141
+ def _transform_import_names(
142
+ self, names: cst.ImportStar | tuple[cst.ImportAlias, ...]
143
+ ) -> cst.ImportStar | list[cst.ImportAlias]:
144
+ """Transform imported names from attr to attrs naming conventions."""
145
+ if isinstance(names, cst.ImportStar):
146
+ return names
147
+
148
+ new_names = []
149
+ name_mappings = {
150
+ "s": "define",
151
+ "attrs": "define",
152
+ "ib": "field",
153
+ "attrib": "field",
154
+ }
155
+
156
+ for name in names:
157
+ if isinstance(name, cst.ImportAlias) and isinstance(name.name, cst.Name):
158
+ old_name = name.name.value
159
+ if old_name in name_mappings:
160
+ new_name = name_mappings[old_name]
161
+ new_names.append(name.with_changes(name=cst.Name(new_name)))
162
+ self.record_change(
163
+ description=f"Rename import '{old_name}' to '{new_name}'",
164
+ line_number=1,
165
+ original=old_name,
166
+ replacement=new_name,
167
+ transform_name=f"import_{old_name}_to_{new_name}",
168
+ )
169
+ else:
170
+ new_names.append(name)
171
+ else:
172
+ new_names.append(name)
173
+
174
+ return new_names
175
+
176
+ def leave_Decorator(
177
+ self, original_node: cst.Decorator, updated_node: cst.Decorator
178
+ ) -> cst.Decorator:
179
+ """Transform decorators like @attr.s to @attrs.define."""
180
+ decorator = updated_node.decorator
181
+
182
+ # Handle @attr.s or @attr.attrs
183
+ if isinstance(decorator, cst.Attribute):
184
+ if isinstance(decorator.value, cst.Name) and decorator.value.value == "attr":
185
+ attr_name = decorator.attr.value
186
+ if attr_name in ("s", "attrs"):
187
+ self.record_change(
188
+ description=f"Transform @attr.{attr_name} to @attrs.define",
189
+ line_number=1,
190
+ original=f"@attr.{attr_name}",
191
+ replacement="@attrs.define",
192
+ transform_name="attr_s_to_attrs_define",
193
+ )
194
+ return updated_node.with_changes(
195
+ decorator=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("define"))
196
+ )
197
+
198
+ # Handle @attr.s(...) with arguments
199
+ if isinstance(decorator, cst.Call):
200
+ func = decorator.func
201
+ if isinstance(func, cst.Attribute):
202
+ if isinstance(func.value, cst.Name) and func.value.value == "attr":
203
+ attr_name = func.attr.value
204
+ if attr_name in ("s", "attrs"):
205
+ return self._transform_attr_s_call(updated_node, decorator)
206
+
207
+ return updated_node
208
+
209
+ def _transform_attr_s_call(
210
+ self, decorator_node: cst.Decorator, call: cst.Call
211
+ ) -> cst.Decorator:
212
+ """Transform @attr.s(...) to appropriate attrs decorator."""
213
+ # Check for frozen=True -> @attrs.frozen
214
+ # Check for auto_attribs=True, slots=True -> @attrs.define (these are defaults)
215
+ # Handle cmp parameter -> eq, order
216
+
217
+ has_frozen = False
218
+ new_args = []
219
+ removed_args = []
220
+
221
+ for arg in call.args:
222
+ if isinstance(arg.keyword, cst.Name):
223
+ keyword_name = arg.keyword.value
224
+
225
+ # frozen=True -> use @attrs.frozen instead
226
+ if keyword_name == "frozen":
227
+ if isinstance(arg.value, cst.Name) and arg.value.value == "True":
228
+ has_frozen = True
229
+ removed_args.append("frozen=True")
230
+ continue
231
+ else:
232
+ new_args.append(arg)
233
+ continue
234
+
235
+ # auto_attribs=True is default in @attrs.define, remove it
236
+ if keyword_name == "auto_attribs":
237
+ if isinstance(arg.value, cst.Name) and arg.value.value == "True":
238
+ removed_args.append("auto_attribs=True")
239
+ continue
240
+
241
+ # slots=True is default in @attrs.define, remove it
242
+ if keyword_name == "slots":
243
+ if isinstance(arg.value, cst.Name) and arg.value.value == "True":
244
+ removed_args.append("slots=True")
245
+ continue
246
+
247
+ # cmp parameter -> eq and order
248
+ if keyword_name == "cmp":
249
+ eq_order_args = self._transform_cmp_arg(arg)
250
+ new_args.extend(eq_order_args)
251
+ self.record_change(
252
+ description="Transform cmp parameter to eq and order",
253
+ line_number=1,
254
+ original="cmp=...",
255
+ replacement="eq=..., order=...",
256
+ transform_name="cmp_to_eq_order",
257
+ )
258
+ continue
259
+
260
+ new_args.append(arg)
261
+ else:
262
+ new_args.append(arg)
263
+
264
+ # Determine target decorator
265
+ if has_frozen:
266
+ target_decorator = "frozen"
267
+ self.record_change(
268
+ description="Transform @attr.s(frozen=True) to @attrs.frozen",
269
+ line_number=1,
270
+ original="@attr.s(frozen=True, ...)",
271
+ replacement="@attrs.frozen(...)",
272
+ transform_name="attr_s_frozen_to_attrs_frozen",
273
+ )
274
+ else:
275
+ target_decorator = "define"
276
+ self.record_change(
277
+ description="Transform @attr.s(...) to @attrs.define(...)",
278
+ line_number=1,
279
+ original="@attr.s(...)",
280
+ replacement="@attrs.define(...)",
281
+ transform_name="attr_s_to_attrs_define",
282
+ )
283
+
284
+ # Build new decorator
285
+ new_func = cst.Attribute(value=cst.Name("attrs"), attr=cst.Name(target_decorator))
286
+
287
+ if new_args:
288
+ # Fix trailing comma
289
+ if new_args:
290
+ last_arg = new_args[-1]
291
+ if last_arg.comma != cst.MaybeSentinel.DEFAULT:
292
+ new_args[-1] = last_arg.with_changes(comma=cst.MaybeSentinel.DEFAULT)
293
+ new_call = call.with_changes(func=new_func, args=new_args)
294
+ else:
295
+ # No args left, use simple attribute
296
+ return decorator_node.with_changes(decorator=new_func)
297
+
298
+ return decorator_node.with_changes(decorator=new_call)
299
+
300
+ def _transform_cmp_arg(self, arg: cst.Arg) -> list[cst.Arg]:
301
+ """Transform cmp=X to eq=X, order=X."""
302
+ value = arg.value
303
+ return [
304
+ cst.Arg(
305
+ keyword=cst.Name("eq"),
306
+ value=value,
307
+ equal=cst.AssignEqual(
308
+ whitespace_before=cst.SimpleWhitespace(""),
309
+ whitespace_after=cst.SimpleWhitespace(""),
310
+ ),
311
+ ),
312
+ cst.Arg(
313
+ keyword=cst.Name("order"),
314
+ value=value,
315
+ equal=cst.AssignEqual(
316
+ whitespace_before=cst.SimpleWhitespace(""),
317
+ whitespace_after=cst.SimpleWhitespace(""),
318
+ ),
319
+ ),
320
+ ]
321
+
322
+ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call:
323
+ """Transform function calls like attr.ib() to attrs.field()."""
324
+ func = updated_node.func
325
+
326
+ # Handle attr.ib() or attr.attrib()
327
+ if isinstance(func, cst.Attribute):
328
+ if isinstance(func.value, cst.Name) and func.value.value == "attr":
329
+ attr_name = func.attr.value
330
+
331
+ # attr.ib() / attr.attrib() -> attrs.field()
332
+ if attr_name in ("ib", "attrib"):
333
+ self.record_change(
334
+ description=f"Transform attr.{attr_name}() to attrs.field()",
335
+ line_number=1,
336
+ original=f"attr.{attr_name}(...)",
337
+ replacement="attrs.field(...)",
338
+ transform_name="attr_ib_to_attrs_field",
339
+ )
340
+ # Transform cmp parameter in field calls too
341
+ new_args = self._transform_field_args(tuple(updated_node.args))
342
+ return updated_node.with_changes(
343
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("field")),
344
+ args=new_args,
345
+ )
346
+
347
+ # attr.Factory -> attrs.Factory
348
+ if attr_name == "Factory":
349
+ self.record_change(
350
+ description="Transform attr.Factory to attrs.Factory",
351
+ line_number=1,
352
+ original="attr.Factory(...)",
353
+ replacement="attrs.Factory(...)",
354
+ transform_name="attr_factory_to_attrs_factory",
355
+ )
356
+ return updated_node.with_changes(
357
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("Factory"))
358
+ )
359
+
360
+ # attr.asdict -> attrs.asdict
361
+ if attr_name == "asdict":
362
+ self.record_change(
363
+ description="Transform attr.asdict() to attrs.asdict()",
364
+ line_number=1,
365
+ original="attr.asdict(...)",
366
+ replacement="attrs.asdict(...)",
367
+ transform_name="attr_asdict_to_attrs_asdict",
368
+ )
369
+ return updated_node.with_changes(
370
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("asdict"))
371
+ )
372
+
373
+ # attr.astuple -> attrs.astuple
374
+ if attr_name == "astuple":
375
+ self.record_change(
376
+ description="Transform attr.astuple() to attrs.astuple()",
377
+ line_number=1,
378
+ original="attr.astuple(...)",
379
+ replacement="attrs.astuple(...)",
380
+ transform_name="attr_astuple_to_attrs_astuple",
381
+ )
382
+ return updated_node.with_changes(
383
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("astuple"))
384
+ )
385
+
386
+ # attr.fields -> attrs.fields
387
+ if attr_name == "fields":
388
+ self.record_change(
389
+ description="Transform attr.fields() to attrs.fields()",
390
+ line_number=1,
391
+ original="attr.fields(...)",
392
+ replacement="attrs.fields(...)",
393
+ transform_name="attr_fields_to_attrs_fields",
394
+ )
395
+ return updated_node.with_changes(
396
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("fields"))
397
+ )
398
+
399
+ # attr.has -> attrs.has
400
+ if attr_name == "has":
401
+ self.record_change(
402
+ description="Transform attr.has() to attrs.has()",
403
+ line_number=1,
404
+ original="attr.has(...)",
405
+ replacement="attrs.has(...)",
406
+ transform_name="attr_has_to_attrs_has",
407
+ )
408
+ return updated_node.with_changes(
409
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("has"))
410
+ )
411
+
412
+ # attr.evolve -> attrs.evolve
413
+ if attr_name == "evolve":
414
+ self.record_change(
415
+ description="Transform attr.evolve() to attrs.evolve()",
416
+ line_number=1,
417
+ original="attr.evolve(...)",
418
+ replacement="attrs.evolve(...)",
419
+ transform_name="attr_evolve_to_attrs_evolve",
420
+ )
421
+ return updated_node.with_changes(
422
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("evolve"))
423
+ )
424
+
425
+ # attr.validate -> attrs.validate
426
+ if attr_name == "validate":
427
+ self.record_change(
428
+ description="Transform attr.validate() to attrs.validate()",
429
+ line_number=1,
430
+ original="attr.validate(...)",
431
+ replacement="attrs.validate(...)",
432
+ transform_name="attr_validate_to_attrs_validate",
433
+ )
434
+ return updated_node.with_changes(
435
+ func=cst.Attribute(value=cst.Name("attrs"), attr=cst.Name("validate"))
436
+ )
437
+
438
+ # Handle attr.validators.* calls
439
+ if isinstance(func, cst.Attribute):
440
+ if isinstance(func.value, cst.Attribute):
441
+ if (
442
+ isinstance(func.value.value, cst.Name)
443
+ and func.value.value.value == "attr"
444
+ and func.value.attr.value == "validators"
445
+ ):
446
+ validator_name = func.attr.value
447
+ self.record_change(
448
+ description=f"Transform attr.validators.{validator_name}() to attrs.validators.{validator_name}()",
449
+ line_number=1,
450
+ original=f"attr.validators.{validator_name}(...)",
451
+ replacement=f"attrs.validators.{validator_name}(...)",
452
+ transform_name="attr_validators_to_attrs_validators",
453
+ )
454
+ return updated_node.with_changes(
455
+ func=cst.Attribute(
456
+ value=cst.Attribute(
457
+ value=cst.Name("attrs"), attr=cst.Name("validators")
458
+ ),
459
+ attr=cst.Name(validator_name),
460
+ )
461
+ )
462
+
463
+ # Handle attr.converters.* calls
464
+ if isinstance(func, cst.Attribute):
465
+ if isinstance(func.value, cst.Attribute):
466
+ if (
467
+ isinstance(func.value.value, cst.Name)
468
+ and func.value.value.value == "attr"
469
+ and func.value.attr.value == "converters"
470
+ ):
471
+ converter_name = func.attr.value
472
+ self.record_change(
473
+ description=f"Transform attr.converters.{converter_name}() to attrs.converters.{converter_name}()",
474
+ line_number=1,
475
+ original=f"attr.converters.{converter_name}(...)",
476
+ replacement=f"attrs.converters.{converter_name}(...)",
477
+ transform_name="attr_converters_to_attrs_converters",
478
+ )
479
+ return updated_node.with_changes(
480
+ func=cst.Attribute(
481
+ value=cst.Attribute(
482
+ value=cst.Name("attrs"), attr=cst.Name("converters")
483
+ ),
484
+ attr=cst.Name(converter_name),
485
+ )
486
+ )
487
+
488
+ return updated_node
489
+
490
+ def _transform_field_args(self, args: tuple[cst.Arg, ...]) -> list[cst.Arg]:
491
+ """Transform field arguments, handling cmp -> eq, order."""
492
+ new_args = []
493
+ for arg in args:
494
+ if isinstance(arg.keyword, cst.Name) and arg.keyword.value == "cmp":
495
+ # cmp=X -> eq=X, order=X
496
+ new_args.extend(self._transform_cmp_arg(arg))
497
+ self.record_change(
498
+ description="Transform cmp parameter to eq and order in field",
499
+ line_number=1,
500
+ original="cmp=...",
501
+ replacement="eq=..., order=...",
502
+ transform_name="cmp_to_eq_order",
503
+ )
504
+ else:
505
+ new_args.append(arg)
506
+ return new_args
507
+
508
+ def leave_Attribute(
509
+ self, original_node: cst.Attribute, updated_node: cst.Attribute
510
+ ) -> cst.Attribute:
511
+ """Transform attribute accesses like attr.validators to attrs.validators."""
512
+ # Handle attr.validators, attr.converters as module access (not calls)
513
+ if isinstance(updated_node.value, cst.Name) and updated_node.value.value == "attr":
514
+ attr_name = updated_node.attr.value
515
+
516
+ # attr.validators -> attrs.validators
517
+ if attr_name == "validators":
518
+ self.record_change(
519
+ description="Transform attr.validators to attrs.validators",
520
+ line_number=1,
521
+ original="attr.validators",
522
+ replacement="attrs.validators",
523
+ transform_name="attr_validators_to_attrs_validators",
524
+ )
525
+ return updated_node.with_changes(value=cst.Name("attrs"))
526
+
527
+ # attr.converters -> attrs.converters
528
+ if attr_name == "converters":
529
+ self.record_change(
530
+ description="Transform attr.converters to attrs.converters",
531
+ line_number=1,
532
+ original="attr.converters",
533
+ replacement="attrs.converters",
534
+ transform_name="attr_converters_to_attrs_converters",
535
+ )
536
+ return updated_node.with_changes(value=cst.Name("attrs"))
537
+
538
+ return updated_node
539
+
540
+ def _get_module_name(self, module: cst.BaseExpression) -> str:
541
+ """Get the full module name from a Name or Attribute node."""
542
+ if isinstance(module, cst.Name):
543
+ return str(module.value)
544
+ elif isinstance(module, cst.Attribute):
545
+ return f"{self._get_module_name(module.value)}.{module.attr.value}"
546
+ return ""
547
+
548
+
549
+ def transform_attrs(source_code: str) -> tuple[str, list]:
550
+ """Transform attrs code from 21.x to 23.x+.
551
+
552
+ Args:
553
+ source_code: The source code to transform
554
+
555
+ Returns:
556
+ Tuple of (transformed_code, list of changes)
557
+ """
558
+ try:
559
+ tree = cst.parse_module(source_code)
560
+ except cst.ParserSyntaxError:
561
+ return source_code, []
562
+
563
+ transformer = AttrsTransformer()
564
+ transformer.set_source(source_code)
565
+
566
+ try:
567
+ transformed_tree = tree.visit(transformer)
568
+ return transformed_tree.code, transformer.changes
569
+ except Exception:
570
+ return source_code, []