codeshift 0.3.2__py3-none-any.whl → 0.3.4__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.
- codeshift/cli/commands/apply.py +24 -2
- codeshift/cli/commands/upgrade_all.py +4 -1
- codeshift/cli/package_manager.py +102 -0
- codeshift/knowledge/generator.py +11 -1
- codeshift/knowledge_base/libraries/aiohttp.yaml +186 -0
- codeshift/knowledge_base/libraries/attrs.yaml +181 -0
- codeshift/knowledge_base/libraries/celery.yaml +244 -0
- codeshift/knowledge_base/libraries/click.yaml +195 -0
- codeshift/knowledge_base/libraries/django.yaml +355 -0
- codeshift/knowledge_base/libraries/flask.yaml +270 -0
- codeshift/knowledge_base/libraries/httpx.yaml +183 -0
- codeshift/knowledge_base/libraries/marshmallow.yaml +238 -0
- codeshift/knowledge_base/libraries/numpy.yaml +429 -0
- codeshift/knowledge_base/libraries/pytest.yaml +192 -0
- codeshift/knowledge_base/libraries/sqlalchemy.yaml +2 -1
- codeshift/migrator/engine.py +60 -0
- codeshift/migrator/transforms/__init__.py +2 -0
- codeshift/migrator/transforms/aiohttp_transformer.py +608 -0
- codeshift/migrator/transforms/attrs_transformer.py +570 -0
- codeshift/migrator/transforms/celery_transformer.py +546 -0
- codeshift/migrator/transforms/click_transformer.py +526 -0
- codeshift/migrator/transforms/django_transformer.py +852 -0
- codeshift/migrator/transforms/fastapi_transformer.py +12 -7
- codeshift/migrator/transforms/flask_transformer.py +505 -0
- codeshift/migrator/transforms/httpx_transformer.py +419 -0
- codeshift/migrator/transforms/marshmallow_transformer.py +515 -0
- codeshift/migrator/transforms/numpy_transformer.py +413 -0
- codeshift/migrator/transforms/pydantic_v1_to_v2.py +53 -8
- codeshift/migrator/transforms/pytest_transformer.py +351 -0
- codeshift/migrator/transforms/requests_transformer.py +74 -1
- codeshift/migrator/transforms/sqlalchemy_transformer.py +692 -39
- codeshift/scanner/dependency_parser.py +1 -1
- {codeshift-0.3.2.dist-info → codeshift-0.3.4.dist-info}/METADATA +46 -4
- {codeshift-0.3.2.dist-info → codeshift-0.3.4.dist-info}/RECORD +38 -17
- {codeshift-0.3.2.dist-info → codeshift-0.3.4.dist-info}/WHEEL +0 -0
- {codeshift-0.3.2.dist-info → codeshift-0.3.4.dist-info}/entry_points.txt +0 -0
- {codeshift-0.3.2.dist-info → codeshift-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {codeshift-0.3.2.dist-info → codeshift-0.3.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""Django transformation using LibCST for upgrades from 2.2/3.x to 4.x/5.x."""
|
|
2
|
+
|
|
3
|
+
import libcst as cst
|
|
4
|
+
from libcst import matchers as m
|
|
5
|
+
|
|
6
|
+
from codeshift.migrator.ast_transforms import BaseTransformer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DjangoTransformer(BaseTransformer):
|
|
10
|
+
"""Transform Django code for major version upgrades."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
super().__init__()
|
|
14
|
+
# Track imports that need to be added
|
|
15
|
+
self._needs_urllib_parse_import = False
|
|
16
|
+
self._needs_datetime_import = False
|
|
17
|
+
self._urllib_parse_names: set[str] = set()
|
|
18
|
+
# Track which old imports to remove
|
|
19
|
+
self._imports_to_remove: set[str] = set()
|
|
20
|
+
|
|
21
|
+
# =========================================================================
|
|
22
|
+
# Import Transformations
|
|
23
|
+
# =========================================================================
|
|
24
|
+
|
|
25
|
+
def leave_ImportFrom(
|
|
26
|
+
self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom
|
|
27
|
+
) -> cst.ImportFrom | cst.RemovalSentinel:
|
|
28
|
+
"""Transform Django imports."""
|
|
29
|
+
if updated_node.module is None:
|
|
30
|
+
return updated_node
|
|
31
|
+
|
|
32
|
+
module_name = self._get_module_name(updated_node.module)
|
|
33
|
+
|
|
34
|
+
# Transform django.conf.urls imports
|
|
35
|
+
if module_name == "django.conf.urls":
|
|
36
|
+
return self._transform_conf_urls_import(updated_node)
|
|
37
|
+
|
|
38
|
+
# Transform django.utils.encoding imports
|
|
39
|
+
if module_name == "django.utils.encoding":
|
|
40
|
+
return self._transform_encoding_import(updated_node)
|
|
41
|
+
|
|
42
|
+
# Transform django.utils.translation imports
|
|
43
|
+
if module_name == "django.utils.translation":
|
|
44
|
+
return self._transform_translation_import(updated_node)
|
|
45
|
+
|
|
46
|
+
# Transform django.utils.http imports
|
|
47
|
+
if module_name == "django.utils.http":
|
|
48
|
+
return self._transform_http_import(updated_node)
|
|
49
|
+
|
|
50
|
+
# Transform django.contrib.postgres.fields imports (JSONField)
|
|
51
|
+
if module_name == "django.contrib.postgres.fields":
|
|
52
|
+
return self._transform_postgres_fields_import(updated_node)
|
|
53
|
+
|
|
54
|
+
# Transform django.contrib.admin.util to utils
|
|
55
|
+
if module_name == "django.contrib.admin.util":
|
|
56
|
+
self.record_change(
|
|
57
|
+
description="Import from django.contrib.admin.utils instead of util",
|
|
58
|
+
line_number=1,
|
|
59
|
+
original="from django.contrib.admin.util",
|
|
60
|
+
replacement="from django.contrib.admin.utils",
|
|
61
|
+
transform_name="admin_util_to_utils",
|
|
62
|
+
)
|
|
63
|
+
return updated_node.with_changes(
|
|
64
|
+
module=self._build_module_node("django.contrib.admin.utils")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Transform django.forms.util to utils
|
|
68
|
+
if module_name == "django.forms.util":
|
|
69
|
+
self.record_change(
|
|
70
|
+
description="Import from django.forms.utils instead of util",
|
|
71
|
+
line_number=1,
|
|
72
|
+
original="from django.forms.util",
|
|
73
|
+
replacement="from django.forms.utils",
|
|
74
|
+
transform_name="forms_util_to_utils",
|
|
75
|
+
)
|
|
76
|
+
return updated_node.with_changes(module=self._build_module_node("django.forms.utils"))
|
|
77
|
+
|
|
78
|
+
# Transform django.utils.timezone (for utc)
|
|
79
|
+
if module_name == "django.utils.timezone":
|
|
80
|
+
return self._transform_timezone_import(updated_node)
|
|
81
|
+
|
|
82
|
+
# Transform django.contrib.sessions.serializers (PickleSerializer)
|
|
83
|
+
if module_name == "django.contrib.sessions.serializers":
|
|
84
|
+
return self._transform_serializers_import(updated_node)
|
|
85
|
+
|
|
86
|
+
# Transform django.contrib.staticfiles.storage (CachedStaticFilesStorage)
|
|
87
|
+
if module_name == "django.contrib.staticfiles.storage":
|
|
88
|
+
return self._transform_staticfiles_storage_import(updated_node)
|
|
89
|
+
|
|
90
|
+
# Transform django.test.runner (reorder_suite)
|
|
91
|
+
if module_name == "django.test.runner":
|
|
92
|
+
return self._transform_test_runner_import(updated_node)
|
|
93
|
+
|
|
94
|
+
return updated_node
|
|
95
|
+
|
|
96
|
+
def _transform_conf_urls_import(
|
|
97
|
+
self, node: cst.ImportFrom
|
|
98
|
+
) -> cst.ImportFrom | cst.RemovalSentinel:
|
|
99
|
+
"""Transform imports from django.conf.urls."""
|
|
100
|
+
if isinstance(node.names, cst.ImportStar):
|
|
101
|
+
return node
|
|
102
|
+
|
|
103
|
+
new_names = []
|
|
104
|
+
has_changes = False
|
|
105
|
+
|
|
106
|
+
for name in node.names:
|
|
107
|
+
if not isinstance(name, cst.ImportAlias):
|
|
108
|
+
new_names.append(name)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
import_name = self._get_name_value(name.name)
|
|
112
|
+
|
|
113
|
+
if import_name == "url":
|
|
114
|
+
# url() removed - should import re_path from django.urls
|
|
115
|
+
self.record_change(
|
|
116
|
+
description="url() removed, import path/re_path from django.urls instead",
|
|
117
|
+
line_number=1,
|
|
118
|
+
original="from django.conf.urls import url",
|
|
119
|
+
replacement="from django.urls import re_path",
|
|
120
|
+
transform_name="url_to_path_or_re_path",
|
|
121
|
+
)
|
|
122
|
+
# Replace with re_path import (preserving any alias)
|
|
123
|
+
new_name = name.with_changes(name=cst.Name("re_path"))
|
|
124
|
+
new_names.append(new_name)
|
|
125
|
+
has_changes = True
|
|
126
|
+
elif import_name == "include":
|
|
127
|
+
# include() should come from django.urls
|
|
128
|
+
self.record_change(
|
|
129
|
+
description="Import include from django.urls instead of django.conf.urls",
|
|
130
|
+
line_number=1,
|
|
131
|
+
original="from django.conf.urls import include",
|
|
132
|
+
replacement="from django.urls import include",
|
|
133
|
+
transform_name="include_import_fix",
|
|
134
|
+
)
|
|
135
|
+
new_names.append(name)
|
|
136
|
+
has_changes = True
|
|
137
|
+
else:
|
|
138
|
+
new_names.append(name)
|
|
139
|
+
|
|
140
|
+
if not new_names:
|
|
141
|
+
return cst.RemovalSentinel.REMOVE
|
|
142
|
+
|
|
143
|
+
if has_changes:
|
|
144
|
+
# Change the module to django.urls
|
|
145
|
+
return node.with_changes(
|
|
146
|
+
module=self._build_module_node("django.urls"),
|
|
147
|
+
names=new_names,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return node
|
|
151
|
+
|
|
152
|
+
def _transform_encoding_import(self, node: cst.ImportFrom) -> cst.ImportFrom:
|
|
153
|
+
"""Transform imports from django.utils.encoding."""
|
|
154
|
+
if isinstance(node.names, cst.ImportStar):
|
|
155
|
+
return node
|
|
156
|
+
|
|
157
|
+
new_names = []
|
|
158
|
+
has_changes = False
|
|
159
|
+
|
|
160
|
+
for name in node.names:
|
|
161
|
+
if not isinstance(name, cst.ImportAlias):
|
|
162
|
+
new_names.append(name)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
import_name = self._get_name_value(name.name)
|
|
166
|
+
|
|
167
|
+
if import_name == "force_text":
|
|
168
|
+
self.record_change(
|
|
169
|
+
description="force_text() renamed to force_str()",
|
|
170
|
+
line_number=1,
|
|
171
|
+
original="from django.utils.encoding import force_text",
|
|
172
|
+
replacement="from django.utils.encoding import force_str",
|
|
173
|
+
transform_name="force_text_to_force_str",
|
|
174
|
+
)
|
|
175
|
+
new_name = name.with_changes(name=cst.Name("force_str"))
|
|
176
|
+
new_names.append(new_name)
|
|
177
|
+
has_changes = True
|
|
178
|
+
elif import_name == "smart_text":
|
|
179
|
+
self.record_change(
|
|
180
|
+
description="smart_text() renamed to smart_str()",
|
|
181
|
+
line_number=1,
|
|
182
|
+
original="from django.utils.encoding import smart_text",
|
|
183
|
+
replacement="from django.utils.encoding import smart_str",
|
|
184
|
+
transform_name="smart_text_to_smart_str",
|
|
185
|
+
)
|
|
186
|
+
new_name = name.with_changes(name=cst.Name("smart_str"))
|
|
187
|
+
new_names.append(new_name)
|
|
188
|
+
has_changes = True
|
|
189
|
+
else:
|
|
190
|
+
new_names.append(name)
|
|
191
|
+
|
|
192
|
+
if has_changes:
|
|
193
|
+
return node.with_changes(names=new_names)
|
|
194
|
+
|
|
195
|
+
return node
|
|
196
|
+
|
|
197
|
+
def _transform_translation_import(self, node: cst.ImportFrom) -> cst.ImportFrom:
|
|
198
|
+
"""Transform imports from django.utils.translation."""
|
|
199
|
+
if isinstance(node.names, cst.ImportStar):
|
|
200
|
+
return node
|
|
201
|
+
|
|
202
|
+
# Mapping of old names to new names
|
|
203
|
+
translation_mappings = {
|
|
204
|
+
"ugettext": "gettext",
|
|
205
|
+
"ugettext_lazy": "gettext_lazy",
|
|
206
|
+
"ugettext_noop": "gettext_noop",
|
|
207
|
+
"ungettext": "ngettext",
|
|
208
|
+
"ungettext_lazy": "ngettext_lazy",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
new_names = []
|
|
212
|
+
has_changes = False
|
|
213
|
+
|
|
214
|
+
for name in node.names:
|
|
215
|
+
if not isinstance(name, cst.ImportAlias):
|
|
216
|
+
new_names.append(name)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
import_name = self._get_name_value(name.name)
|
|
220
|
+
|
|
221
|
+
if import_name in translation_mappings:
|
|
222
|
+
new_import = translation_mappings[import_name]
|
|
223
|
+
self.record_change(
|
|
224
|
+
description=f"{import_name}() renamed to {new_import}()",
|
|
225
|
+
line_number=1,
|
|
226
|
+
original=f"from django.utils.translation import {import_name}",
|
|
227
|
+
replacement=f"from django.utils.translation import {new_import}",
|
|
228
|
+
transform_name=f"{import_name}_to_{new_import}",
|
|
229
|
+
)
|
|
230
|
+
new_name = name.with_changes(name=cst.Name(new_import))
|
|
231
|
+
new_names.append(new_name)
|
|
232
|
+
has_changes = True
|
|
233
|
+
else:
|
|
234
|
+
new_names.append(name)
|
|
235
|
+
|
|
236
|
+
if has_changes:
|
|
237
|
+
return node.with_changes(names=new_names)
|
|
238
|
+
|
|
239
|
+
return node
|
|
240
|
+
|
|
241
|
+
def _transform_http_import(self, node: cst.ImportFrom) -> cst.ImportFrom | cst.RemovalSentinel:
|
|
242
|
+
"""Transform imports from django.utils.http."""
|
|
243
|
+
if isinstance(node.names, cst.ImportStar):
|
|
244
|
+
return node
|
|
245
|
+
|
|
246
|
+
# Mapping to urllib.parse
|
|
247
|
+
urllib_mappings = {
|
|
248
|
+
"urlquote": "quote",
|
|
249
|
+
"urlquote_plus": "quote_plus",
|
|
250
|
+
"urlunquote": "unquote",
|
|
251
|
+
"urlunquote_plus": "unquote_plus",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
new_names = []
|
|
255
|
+
has_changes = False
|
|
256
|
+
|
|
257
|
+
for name in node.names:
|
|
258
|
+
if not isinstance(name, cst.ImportAlias):
|
|
259
|
+
new_names.append(name)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
import_name = self._get_name_value(name.name)
|
|
263
|
+
|
|
264
|
+
if import_name in urllib_mappings:
|
|
265
|
+
new_import = urllib_mappings[import_name]
|
|
266
|
+
self.record_change(
|
|
267
|
+
description=f"{import_name}() removed, use urllib.parse.{new_import}()",
|
|
268
|
+
line_number=1,
|
|
269
|
+
original=f"from django.utils.http import {import_name}",
|
|
270
|
+
replacement=f"from urllib.parse import {new_import}",
|
|
271
|
+
transform_name=f"{import_name}_to_urllib_{new_import}",
|
|
272
|
+
)
|
|
273
|
+
self._needs_urllib_parse_import = True
|
|
274
|
+
self._urllib_parse_names.add(new_import)
|
|
275
|
+
has_changes = True
|
|
276
|
+
# Don't add to new_names - we'll add urllib.parse import instead
|
|
277
|
+
elif import_name == "is_safe_url":
|
|
278
|
+
self.record_change(
|
|
279
|
+
description="is_safe_url() renamed to url_has_allowed_host_and_scheme()",
|
|
280
|
+
line_number=1,
|
|
281
|
+
original="from django.utils.http import is_safe_url",
|
|
282
|
+
replacement="from django.utils.http import url_has_allowed_host_and_scheme",
|
|
283
|
+
transform_name="is_safe_url_to_url_has_allowed",
|
|
284
|
+
)
|
|
285
|
+
new_name = name.with_changes(name=cst.Name("url_has_allowed_host_and_scheme"))
|
|
286
|
+
new_names.append(new_name)
|
|
287
|
+
has_changes = True
|
|
288
|
+
else:
|
|
289
|
+
new_names.append(name)
|
|
290
|
+
|
|
291
|
+
if not new_names:
|
|
292
|
+
return cst.RemovalSentinel.REMOVE
|
|
293
|
+
|
|
294
|
+
if has_changes:
|
|
295
|
+
return node.with_changes(names=new_names)
|
|
296
|
+
|
|
297
|
+
return node
|
|
298
|
+
|
|
299
|
+
def _transform_postgres_fields_import(self, node: cst.ImportFrom) -> cst.ImportFrom:
|
|
300
|
+
"""Transform imports from django.contrib.postgres.fields."""
|
|
301
|
+
if isinstance(node.names, cst.ImportStar):
|
|
302
|
+
return node
|
|
303
|
+
|
|
304
|
+
new_names = []
|
|
305
|
+
has_changes = False
|
|
306
|
+
|
|
307
|
+
for name in node.names:
|
|
308
|
+
if not isinstance(name, cst.ImportAlias):
|
|
309
|
+
new_names.append(name)
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
import_name = self._get_name_value(name.name)
|
|
313
|
+
|
|
314
|
+
if import_name == "JSONField":
|
|
315
|
+
self.record_change(
|
|
316
|
+
description="JSONField moved from django.contrib.postgres.fields to django.db.models",
|
|
317
|
+
line_number=1,
|
|
318
|
+
original="from django.contrib.postgres.fields import JSONField",
|
|
319
|
+
replacement="from django.db.models import JSONField",
|
|
320
|
+
transform_name="postgres_jsonfield_to_models",
|
|
321
|
+
)
|
|
322
|
+
# Change the module for this import
|
|
323
|
+
has_changes = True
|
|
324
|
+
# Keep the name but will return a different import
|
|
325
|
+
else:
|
|
326
|
+
new_names.append(name)
|
|
327
|
+
|
|
328
|
+
if has_changes:
|
|
329
|
+
# If only JSONField was imported, change module to django.db.models
|
|
330
|
+
if not new_names:
|
|
331
|
+
return node.with_changes(
|
|
332
|
+
module=self._build_module_node("django.db.models"),
|
|
333
|
+
)
|
|
334
|
+
# If there were other imports too, we need to keep the postgres import
|
|
335
|
+
# and add a separate django.db.models import - this is handled in leave_Module
|
|
336
|
+
|
|
337
|
+
return node
|
|
338
|
+
|
|
339
|
+
def _transform_timezone_import(
|
|
340
|
+
self, node: cst.ImportFrom
|
|
341
|
+
) -> cst.ImportFrom | cst.RemovalSentinel:
|
|
342
|
+
"""Transform imports from django.utils.timezone (for utc alias)."""
|
|
343
|
+
if isinstance(node.names, cst.ImportStar):
|
|
344
|
+
return node
|
|
345
|
+
|
|
346
|
+
new_names = []
|
|
347
|
+
has_changes = False
|
|
348
|
+
|
|
349
|
+
for name in node.names:
|
|
350
|
+
if not isinstance(name, cst.ImportAlias):
|
|
351
|
+
new_names.append(name)
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
import_name = self._get_name_value(name.name)
|
|
355
|
+
|
|
356
|
+
if import_name == "utc":
|
|
357
|
+
self.record_change(
|
|
358
|
+
description="django.utils.timezone.utc removed, use datetime.timezone.utc",
|
|
359
|
+
line_number=1,
|
|
360
|
+
original="from django.utils.timezone import utc",
|
|
361
|
+
replacement="from datetime import timezone",
|
|
362
|
+
transform_name="timezone_utc_to_datetime",
|
|
363
|
+
notes="Use timezone.utc instead of utc",
|
|
364
|
+
)
|
|
365
|
+
self._needs_datetime_import = True
|
|
366
|
+
has_changes = True
|
|
367
|
+
# Don't include this import - we'll add datetime import instead
|
|
368
|
+
else:
|
|
369
|
+
new_names.append(name)
|
|
370
|
+
|
|
371
|
+
if has_changes:
|
|
372
|
+
if not new_names:
|
|
373
|
+
return cst.RemovalSentinel.REMOVE
|
|
374
|
+
return node.with_changes(names=new_names)
|
|
375
|
+
|
|
376
|
+
return node
|
|
377
|
+
|
|
378
|
+
def _transform_serializers_import(self, node: cst.ImportFrom) -> cst.ImportFrom:
|
|
379
|
+
"""Transform imports from django.contrib.sessions.serializers."""
|
|
380
|
+
if isinstance(node.names, cst.ImportStar):
|
|
381
|
+
return node
|
|
382
|
+
|
|
383
|
+
new_names = []
|
|
384
|
+
has_changes = False
|
|
385
|
+
|
|
386
|
+
for name in node.names:
|
|
387
|
+
if not isinstance(name, cst.ImportAlias):
|
|
388
|
+
new_names.append(name)
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
import_name = self._get_name_value(name.name)
|
|
392
|
+
|
|
393
|
+
if import_name == "PickleSerializer":
|
|
394
|
+
self.record_change(
|
|
395
|
+
description="PickleSerializer removed, use JSONSerializer instead",
|
|
396
|
+
line_number=1,
|
|
397
|
+
original="from django.contrib.sessions.serializers import PickleSerializer",
|
|
398
|
+
replacement="from django.contrib.sessions.serializers import JSONSerializer",
|
|
399
|
+
transform_name="pickle_serializer_to_json",
|
|
400
|
+
)
|
|
401
|
+
new_name = name.with_changes(name=cst.Name("JSONSerializer"))
|
|
402
|
+
new_names.append(new_name)
|
|
403
|
+
has_changes = True
|
|
404
|
+
else:
|
|
405
|
+
new_names.append(name)
|
|
406
|
+
|
|
407
|
+
if has_changes:
|
|
408
|
+
return node.with_changes(names=new_names)
|
|
409
|
+
|
|
410
|
+
return node
|
|
411
|
+
|
|
412
|
+
def _transform_staticfiles_storage_import(self, node: cst.ImportFrom) -> cst.ImportFrom:
|
|
413
|
+
"""Transform imports from django.contrib.staticfiles.storage."""
|
|
414
|
+
if isinstance(node.names, cst.ImportStar):
|
|
415
|
+
return node
|
|
416
|
+
|
|
417
|
+
new_names = []
|
|
418
|
+
has_changes = False
|
|
419
|
+
|
|
420
|
+
for name in node.names:
|
|
421
|
+
if not isinstance(name, cst.ImportAlias):
|
|
422
|
+
new_names.append(name)
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
import_name = self._get_name_value(name.name)
|
|
426
|
+
|
|
427
|
+
if import_name == "CachedStaticFilesStorage":
|
|
428
|
+
self.record_change(
|
|
429
|
+
description="CachedStaticFilesStorage removed, use ManifestStaticFilesStorage",
|
|
430
|
+
line_number=1,
|
|
431
|
+
original="from django.contrib.staticfiles.storage import CachedStaticFilesStorage",
|
|
432
|
+
replacement="from django.contrib.staticfiles.storage import ManifestStaticFilesStorage",
|
|
433
|
+
transform_name="cached_storage_to_manifest",
|
|
434
|
+
)
|
|
435
|
+
new_name = name.with_changes(name=cst.Name("ManifestStaticFilesStorage"))
|
|
436
|
+
new_names.append(new_name)
|
|
437
|
+
has_changes = True
|
|
438
|
+
else:
|
|
439
|
+
new_names.append(name)
|
|
440
|
+
|
|
441
|
+
if has_changes:
|
|
442
|
+
return node.with_changes(names=new_names)
|
|
443
|
+
|
|
444
|
+
return node
|
|
445
|
+
|
|
446
|
+
def _transform_test_runner_import(self, node: cst.ImportFrom) -> cst.ImportFrom:
|
|
447
|
+
"""Transform imports from django.test.runner."""
|
|
448
|
+
if isinstance(node.names, cst.ImportStar):
|
|
449
|
+
return node
|
|
450
|
+
|
|
451
|
+
new_names = []
|
|
452
|
+
has_changes = False
|
|
453
|
+
|
|
454
|
+
for name in node.names:
|
|
455
|
+
if not isinstance(name, cst.ImportAlias):
|
|
456
|
+
new_names.append(name)
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
import_name = self._get_name_value(name.name)
|
|
460
|
+
|
|
461
|
+
if import_name == "reorder_suite":
|
|
462
|
+
self.record_change(
|
|
463
|
+
description="reorder_suite() renamed to reorder_tests()",
|
|
464
|
+
line_number=1,
|
|
465
|
+
original="from django.test.runner import reorder_suite",
|
|
466
|
+
replacement="from django.test.runner import reorder_tests",
|
|
467
|
+
transform_name="reorder_suite_to_reorder_tests",
|
|
468
|
+
)
|
|
469
|
+
new_name = name.with_changes(name=cst.Name("reorder_tests"))
|
|
470
|
+
new_names.append(new_name)
|
|
471
|
+
has_changes = True
|
|
472
|
+
else:
|
|
473
|
+
new_names.append(name)
|
|
474
|
+
|
|
475
|
+
if has_changes:
|
|
476
|
+
return node.with_changes(names=new_names)
|
|
477
|
+
|
|
478
|
+
return node
|
|
479
|
+
|
|
480
|
+
# =========================================================================
|
|
481
|
+
# Call Expression Transformations
|
|
482
|
+
# =========================================================================
|
|
483
|
+
|
|
484
|
+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.BaseExpression:
|
|
485
|
+
"""Transform function/method calls."""
|
|
486
|
+
# Handle url() calls - convert to re_path()
|
|
487
|
+
if m.matches(updated_node.func, m.Name("url")):
|
|
488
|
+
self.record_change(
|
|
489
|
+
description="url() replaced with re_path()",
|
|
490
|
+
line_number=1,
|
|
491
|
+
original="url(...)",
|
|
492
|
+
replacement="re_path(...)",
|
|
493
|
+
transform_name="url_to_re_path_call",
|
|
494
|
+
)
|
|
495
|
+
return updated_node.with_changes(func=cst.Name("re_path"))
|
|
496
|
+
|
|
497
|
+
# Handle force_text() calls
|
|
498
|
+
if m.matches(updated_node.func, m.Name("force_text")):
|
|
499
|
+
self.record_change(
|
|
500
|
+
description="force_text() renamed to force_str()",
|
|
501
|
+
line_number=1,
|
|
502
|
+
original="force_text(...)",
|
|
503
|
+
replacement="force_str(...)",
|
|
504
|
+
transform_name="force_text_to_force_str_call",
|
|
505
|
+
)
|
|
506
|
+
return updated_node.with_changes(func=cst.Name("force_str"))
|
|
507
|
+
|
|
508
|
+
# Handle smart_text() calls
|
|
509
|
+
if m.matches(updated_node.func, m.Name("smart_text")):
|
|
510
|
+
self.record_change(
|
|
511
|
+
description="smart_text() renamed to smart_str()",
|
|
512
|
+
line_number=1,
|
|
513
|
+
original="smart_text(...)",
|
|
514
|
+
replacement="smart_str(...)",
|
|
515
|
+
transform_name="smart_text_to_smart_str_call",
|
|
516
|
+
)
|
|
517
|
+
return updated_node.with_changes(func=cst.Name("smart_str"))
|
|
518
|
+
|
|
519
|
+
# Handle translation function calls
|
|
520
|
+
translation_call_mappings = {
|
|
521
|
+
"ugettext": "gettext",
|
|
522
|
+
"ugettext_lazy": "gettext_lazy",
|
|
523
|
+
"ugettext_noop": "gettext_noop",
|
|
524
|
+
"ungettext": "ngettext",
|
|
525
|
+
"ungettext_lazy": "ngettext_lazy",
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if isinstance(updated_node.func, cst.Name):
|
|
529
|
+
func_name = updated_node.func.value
|
|
530
|
+
if func_name in translation_call_mappings:
|
|
531
|
+
new_name = translation_call_mappings[func_name]
|
|
532
|
+
self.record_change(
|
|
533
|
+
description=f"{func_name}() renamed to {new_name}()",
|
|
534
|
+
line_number=1,
|
|
535
|
+
original=f"{func_name}(...)",
|
|
536
|
+
replacement=f"{new_name}(...)",
|
|
537
|
+
transform_name=f"{func_name}_to_{new_name}_call",
|
|
538
|
+
)
|
|
539
|
+
return updated_node.with_changes(func=cst.Name(new_name))
|
|
540
|
+
|
|
541
|
+
# Handle urlquote family function calls
|
|
542
|
+
url_quote_mappings = {
|
|
543
|
+
"urlquote": "quote",
|
|
544
|
+
"urlquote_plus": "quote_plus",
|
|
545
|
+
"urlunquote": "unquote",
|
|
546
|
+
"urlunquote_plus": "unquote_plus",
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if isinstance(updated_node.func, cst.Name):
|
|
550
|
+
func_name = updated_node.func.value
|
|
551
|
+
if func_name in url_quote_mappings:
|
|
552
|
+
new_name = url_quote_mappings[func_name]
|
|
553
|
+
self.record_change(
|
|
554
|
+
description=f"{func_name}() removed, use {new_name}() from urllib.parse",
|
|
555
|
+
line_number=1,
|
|
556
|
+
original=f"{func_name}(...)",
|
|
557
|
+
replacement=f"{new_name}(...)",
|
|
558
|
+
transform_name=f"{func_name}_to_{new_name}_call",
|
|
559
|
+
)
|
|
560
|
+
return updated_node.with_changes(func=cst.Name(new_name))
|
|
561
|
+
|
|
562
|
+
# Handle is_safe_url() calls
|
|
563
|
+
if m.matches(updated_node.func, m.Name("is_safe_url")):
|
|
564
|
+
self.record_change(
|
|
565
|
+
description="is_safe_url() renamed to url_has_allowed_host_and_scheme()",
|
|
566
|
+
line_number=1,
|
|
567
|
+
original="is_safe_url(...)",
|
|
568
|
+
replacement="url_has_allowed_host_and_scheme(...)",
|
|
569
|
+
transform_name="is_safe_url_to_url_has_allowed_call",
|
|
570
|
+
)
|
|
571
|
+
return updated_node.with_changes(func=cst.Name("url_has_allowed_host_and_scheme"))
|
|
572
|
+
|
|
573
|
+
# Handle request.is_ajax() calls
|
|
574
|
+
if isinstance(updated_node.func, cst.Attribute):
|
|
575
|
+
if updated_node.func.attr.value == "is_ajax":
|
|
576
|
+
# Transform request.is_ajax() to
|
|
577
|
+
# request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
|
578
|
+
self.record_change(
|
|
579
|
+
description="is_ajax() method removed, check X-Requested-With header",
|
|
580
|
+
line_number=1,
|
|
581
|
+
original="request.is_ajax()",
|
|
582
|
+
replacement="request.headers.get('X-Requested-With') == 'XMLHttpRequest'",
|
|
583
|
+
transform_name="is_ajax_to_header_check",
|
|
584
|
+
)
|
|
585
|
+
# Build: <object>.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
|
586
|
+
headers_attr = cst.Attribute(
|
|
587
|
+
value=updated_node.func.value,
|
|
588
|
+
attr=cst.Name("headers"),
|
|
589
|
+
)
|
|
590
|
+
get_call = cst.Call(
|
|
591
|
+
func=cst.Attribute(value=headers_attr, attr=cst.Name("get")),
|
|
592
|
+
args=[cst.Arg(cst.SimpleString("'X-Requested-With'"))],
|
|
593
|
+
)
|
|
594
|
+
return cst.Comparison(
|
|
595
|
+
left=get_call,
|
|
596
|
+
comparisons=[
|
|
597
|
+
cst.ComparisonTarget(
|
|
598
|
+
operator=cst.Equal(),
|
|
599
|
+
comparator=cst.SimpleString("'XMLHttpRequest'"),
|
|
600
|
+
)
|
|
601
|
+
],
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Handle reorder_suite() calls
|
|
605
|
+
if m.matches(updated_node.func, m.Name("reorder_suite")):
|
|
606
|
+
self.record_change(
|
|
607
|
+
description="reorder_suite() renamed to reorder_tests()",
|
|
608
|
+
line_number=1,
|
|
609
|
+
original="reorder_suite(...)",
|
|
610
|
+
replacement="reorder_tests(...)",
|
|
611
|
+
transform_name="reorder_suite_to_reorder_tests_call",
|
|
612
|
+
)
|
|
613
|
+
return updated_node.with_changes(func=cst.Name("reorder_tests"))
|
|
614
|
+
|
|
615
|
+
return updated_node
|
|
616
|
+
|
|
617
|
+
# =========================================================================
|
|
618
|
+
# Attribute Transformations
|
|
619
|
+
# =========================================================================
|
|
620
|
+
|
|
621
|
+
def leave_Attribute(
|
|
622
|
+
self, original_node: cst.Attribute, updated_node: cst.Attribute
|
|
623
|
+
) -> cst.BaseExpression:
|
|
624
|
+
"""Transform attribute access patterns."""
|
|
625
|
+
# Handle timezone.utc -> datetime.timezone.utc
|
|
626
|
+
# This handles cases where django.utils.timezone was imported as a module
|
|
627
|
+
attr_str = self._get_full_attribute_safe(updated_node)
|
|
628
|
+
|
|
629
|
+
if attr_str == "timezone.utc":
|
|
630
|
+
# Check if this is likely django.utils.timezone.utc
|
|
631
|
+
self.record_change(
|
|
632
|
+
description="timezone.utc should be datetime.timezone.utc",
|
|
633
|
+
line_number=1,
|
|
634
|
+
original="timezone.utc",
|
|
635
|
+
replacement="datetime.timezone.utc",
|
|
636
|
+
transform_name="timezone_utc_attr_fix",
|
|
637
|
+
confidence=0.8,
|
|
638
|
+
notes="Assuming this refers to django.utils.timezone.utc",
|
|
639
|
+
)
|
|
640
|
+
self._needs_datetime_import = True
|
|
641
|
+
# Return datetime.timezone.utc
|
|
642
|
+
return cst.Attribute(
|
|
643
|
+
value=cst.Attribute(value=cst.Name("datetime"), attr=cst.Name("timezone")),
|
|
644
|
+
attr=cst.Name("utc"),
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
return updated_node
|
|
648
|
+
|
|
649
|
+
# =========================================================================
|
|
650
|
+
# Assignment Transformations (for default_app_config)
|
|
651
|
+
# =========================================================================
|
|
652
|
+
|
|
653
|
+
def leave_Assign(
|
|
654
|
+
self, original_node: cst.Assign, updated_node: cst.Assign
|
|
655
|
+
) -> cst.Assign | cst.RemovalSentinel:
|
|
656
|
+
"""Transform assignments, particularly default_app_config removal."""
|
|
657
|
+
# Check for default_app_config = '...'
|
|
658
|
+
for target in updated_node.targets:
|
|
659
|
+
if isinstance(target.target, cst.Name):
|
|
660
|
+
if target.target.value == "default_app_config":
|
|
661
|
+
self.record_change(
|
|
662
|
+
description="default_app_config is no longer needed in Django 4.0+",
|
|
663
|
+
line_number=1,
|
|
664
|
+
original="default_app_config = '...'",
|
|
665
|
+
replacement="(removed)",
|
|
666
|
+
transform_name="remove_default_app_config",
|
|
667
|
+
)
|
|
668
|
+
return cst.RemovalSentinel.REMOVE
|
|
669
|
+
|
|
670
|
+
return updated_node
|
|
671
|
+
|
|
672
|
+
# =========================================================================
|
|
673
|
+
# Class Definition Transformations (for NullBooleanField)
|
|
674
|
+
# =========================================================================
|
|
675
|
+
|
|
676
|
+
def leave_AnnAssign(
|
|
677
|
+
self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign
|
|
678
|
+
) -> cst.AnnAssign:
|
|
679
|
+
"""Transform annotated assignments (for field type annotations)."""
|
|
680
|
+
return updated_node
|
|
681
|
+
|
|
682
|
+
def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.BaseExpression:
|
|
683
|
+
"""Transform name references."""
|
|
684
|
+
# Handle NullBooleanField -> BooleanField
|
|
685
|
+
# Note: The full transformation to BooleanField(null=True) is complex
|
|
686
|
+
# and is handled in leave_Call for field instantiations
|
|
687
|
+
if updated_node.value == "NullBooleanField":
|
|
688
|
+
self.record_change(
|
|
689
|
+
description="NullBooleanField removed, use BooleanField(null=True)",
|
|
690
|
+
line_number=1,
|
|
691
|
+
original="NullBooleanField",
|
|
692
|
+
replacement="BooleanField",
|
|
693
|
+
transform_name="null_boolean_field_to_boolean_field",
|
|
694
|
+
notes="Remember to add null=True argument",
|
|
695
|
+
)
|
|
696
|
+
return cst.Name("BooleanField")
|
|
697
|
+
|
|
698
|
+
# Handle PickleSerializer -> JSONSerializer
|
|
699
|
+
if updated_node.value == "PickleSerializer":
|
|
700
|
+
self.record_change(
|
|
701
|
+
description="PickleSerializer removed, use JSONSerializer",
|
|
702
|
+
line_number=1,
|
|
703
|
+
original="PickleSerializer",
|
|
704
|
+
replacement="JSONSerializer",
|
|
705
|
+
transform_name="pickle_serializer_to_json_name",
|
|
706
|
+
)
|
|
707
|
+
return cst.Name("JSONSerializer")
|
|
708
|
+
|
|
709
|
+
# Handle CachedStaticFilesStorage -> ManifestStaticFilesStorage
|
|
710
|
+
if updated_node.value == "CachedStaticFilesStorage":
|
|
711
|
+
self.record_change(
|
|
712
|
+
description="CachedStaticFilesStorage removed, use ManifestStaticFilesStorage",
|
|
713
|
+
line_number=1,
|
|
714
|
+
original="CachedStaticFilesStorage",
|
|
715
|
+
replacement="ManifestStaticFilesStorage",
|
|
716
|
+
transform_name="cached_storage_to_manifest_name",
|
|
717
|
+
)
|
|
718
|
+
return cst.Name("ManifestStaticFilesStorage")
|
|
719
|
+
|
|
720
|
+
return updated_node
|
|
721
|
+
|
|
722
|
+
# =========================================================================
|
|
723
|
+
# Module-Level Transformations (for adding imports)
|
|
724
|
+
# =========================================================================
|
|
725
|
+
|
|
726
|
+
def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> cst.Module:
|
|
727
|
+
"""Add any required imports at the module level."""
|
|
728
|
+
new_imports: list[cst.SimpleStatementLine] = []
|
|
729
|
+
|
|
730
|
+
# Add urllib.parse imports if needed
|
|
731
|
+
if self._needs_urllib_parse_import and self._urllib_parse_names:
|
|
732
|
+
import_names = [
|
|
733
|
+
cst.ImportAlias(name=cst.Name(n)) for n in sorted(self._urllib_parse_names)
|
|
734
|
+
]
|
|
735
|
+
new_import = cst.SimpleStatementLine(
|
|
736
|
+
body=[
|
|
737
|
+
cst.ImportFrom(
|
|
738
|
+
module=cst.Attribute(value=cst.Name("urllib"), attr=cst.Name("parse")),
|
|
739
|
+
names=import_names,
|
|
740
|
+
)
|
|
741
|
+
]
|
|
742
|
+
)
|
|
743
|
+
new_imports.append(new_import)
|
|
744
|
+
|
|
745
|
+
# Add datetime import if needed
|
|
746
|
+
if self._needs_datetime_import:
|
|
747
|
+
new_import = cst.SimpleStatementLine(
|
|
748
|
+
body=[
|
|
749
|
+
cst.ImportFrom(
|
|
750
|
+
module=cst.Name("datetime"),
|
|
751
|
+
names=[cst.ImportAlias(name=cst.Name("timezone"))],
|
|
752
|
+
)
|
|
753
|
+
]
|
|
754
|
+
)
|
|
755
|
+
new_imports.append(new_import)
|
|
756
|
+
|
|
757
|
+
if not new_imports:
|
|
758
|
+
return updated_node
|
|
759
|
+
|
|
760
|
+
# Find position to insert imports (after docstrings and __future__ imports)
|
|
761
|
+
insert_pos = 0
|
|
762
|
+
for i, statement in enumerate(updated_node.body):
|
|
763
|
+
if isinstance(statement, cst.SimpleStatementLine):
|
|
764
|
+
# Check for docstring
|
|
765
|
+
if (
|
|
766
|
+
i == 0
|
|
767
|
+
and len(statement.body) == 1
|
|
768
|
+
and isinstance(statement.body[0], cst.Expr)
|
|
769
|
+
and isinstance(
|
|
770
|
+
statement.body[0].value, cst.SimpleString | cst.ConcatenatedString
|
|
771
|
+
)
|
|
772
|
+
):
|
|
773
|
+
insert_pos = i + 1
|
|
774
|
+
continue
|
|
775
|
+
# Check for __future__ import
|
|
776
|
+
if len(statement.body) == 1 and isinstance(statement.body[0], cst.ImportFrom):
|
|
777
|
+
import_from = statement.body[0]
|
|
778
|
+
if (
|
|
779
|
+
isinstance(import_from.module, cst.Name)
|
|
780
|
+
and import_from.module.value == "__future__"
|
|
781
|
+
):
|
|
782
|
+
insert_pos = i + 1
|
|
783
|
+
continue
|
|
784
|
+
break
|
|
785
|
+
|
|
786
|
+
new_body = (
|
|
787
|
+
list(updated_node.body[:insert_pos])
|
|
788
|
+
+ new_imports
|
|
789
|
+
+ list(updated_node.body[insert_pos:])
|
|
790
|
+
)
|
|
791
|
+
return updated_node.with_changes(body=new_body)
|
|
792
|
+
|
|
793
|
+
# =========================================================================
|
|
794
|
+
# Helper Methods
|
|
795
|
+
# =========================================================================
|
|
796
|
+
|
|
797
|
+
def _get_module_name(self, module: cst.BaseExpression) -> str:
|
|
798
|
+
"""Get the full module name from a Name or Attribute node."""
|
|
799
|
+
if isinstance(module, cst.Name):
|
|
800
|
+
return str(module.value)
|
|
801
|
+
elif isinstance(module, cst.Attribute):
|
|
802
|
+
return f"{self._get_module_name(module.value)}.{module.attr.value}"
|
|
803
|
+
return ""
|
|
804
|
+
|
|
805
|
+
def _get_name_value(self, node: cst.BaseExpression) -> str | None:
|
|
806
|
+
"""Extract the string value from a Name node."""
|
|
807
|
+
if isinstance(node, cst.Name):
|
|
808
|
+
return str(node.value)
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
def _build_module_node(self, module_name: str) -> cst.Name | cst.Attribute:
|
|
812
|
+
"""Build a module node from a dotted name string."""
|
|
813
|
+
parts = module_name.split(".")
|
|
814
|
+
if len(parts) == 1:
|
|
815
|
+
return cst.Name(parts[0])
|
|
816
|
+
|
|
817
|
+
result: cst.Name | cst.Attribute = cst.Name(parts[0])
|
|
818
|
+
for part in parts[1:]:
|
|
819
|
+
result = cst.Attribute(value=result, attr=cst.Name(part))
|
|
820
|
+
return result
|
|
821
|
+
|
|
822
|
+
def _get_full_attribute_safe(self, node: cst.Attribute) -> str:
|
|
823
|
+
"""Get the full attribute path as a string, safely handling non-attribute values."""
|
|
824
|
+
if isinstance(node.value, cst.Name):
|
|
825
|
+
return f"{node.value.value}.{node.attr.value}"
|
|
826
|
+
elif isinstance(node.value, cst.Attribute):
|
|
827
|
+
return f"{self._get_full_attribute_safe(node.value)}.{node.attr.value}"
|
|
828
|
+
return str(node.attr.value)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def transform_django(source_code: str) -> tuple[str, list]:
|
|
832
|
+
"""Transform Django code for version upgrades.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
source_code: The source code to transform
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
Tuple of (transformed_code, list of changes)
|
|
839
|
+
"""
|
|
840
|
+
try:
|
|
841
|
+
tree = cst.parse_module(source_code)
|
|
842
|
+
except cst.ParserSyntaxError:
|
|
843
|
+
return source_code, []
|
|
844
|
+
|
|
845
|
+
transformer = DjangoTransformer()
|
|
846
|
+
transformer.set_source(source_code)
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
transformed_tree = tree.visit(transformer)
|
|
850
|
+
return transformed_tree.code, transformer.changes
|
|
851
|
+
except Exception:
|
|
852
|
+
return source_code, []
|