A walkthrough of what broke each time we re-ran a Django test suite against an obfuscated workspace — and what we had to add to the detector to make the next round green.
The setup
PromptCape obfuscates source code before it reaches an AI assistant (Claude Code, Cursor, etc.) so the AI works on renamed identifiers rather than your real class, method, and field names. We've covered the Java pipeline and the general Python flow in earlier posts.
This post is about Django specifically. Django has more invisible name contracts than any framework we've integrated so far — strings inside Python code, in templates, in migration files, in URL configurations, in admin registrations, all referencing identifier names. The compiler can't catch them because Python has no compiler. The static import verifier can't catch them because they aren't imports. They surface only at the moment Django's introspection layer reaches for getattr(form, 'clean_<field>', None) and finds nothing.
The story below is the literal sequence of bugs that surfaced when we ran an obfuscated django-blog test app (Post / Comment / Tag models, CBVs + FBVs, ModelForm + standalone Form, admin registrations, pytest-django). Each bug exposed a contract; each contract drove a change to the DjangoDetector. Sixteen tests, six iterations to green.
Iteration 1 — 'django.db.migrations' has no attribute 'Cls_270e5090'
First obfuscation. Tests fail immediately on collection:
ERROR tests/test_blog.py::test_post_creation_persists_all_fields
E AttributeError: module 'django.db.migrations' has no attribute 'Cls_270e5090'
blog/migrations/0001_initial.py:7: AttributeError
Line 7 of every Django migration file is:
class Migration(migrations.Migration):
The obfuscator picked up Migration from the class definition, registered it, and rewrote every subsequent occurrence — including migrations.Migration (the base class reference). Django's migrations module doesn't have an attribute Cls_270e5090, so the class can't be loaded.
The temptation: add Migration to a list of protected names. That handles one occurrence; the next line down is migrations.CreateModel(...), then migrations.AddField(...), then migrations.RunPython(...). There are roughly twenty operation classes and a handful of class-level attributes (initial, dependencies, operations, replaces, atomic). Worse, each CreateModel(name=..., fields=..., options=..., bases=..., managers=...) call uses kwargs that are extremely generic — name, fields, options, bases collide with every second user identifier in a real codebase. Adding them all to a project-wide exclusion list would gut obfuscation.
The right fix recognises that migration files are machine-generated. Django writes them via python manage.py makemigrations. The user never edits them by hand. They reference framework internals exclusively. There is nothing in them that needs to be renamed.
So: skip migrations/, alembic/, versions/ directories entirely. The obfuscator's collection pass doesn't visit them; the obfuscation pass doesn't rewrite them; they're copied to the workspace verbatim. Django's migrate command sees its expected files unchanged.
// ObfuscationEngine.java
private static final Set<String> MACHINE_GENERATED_DIR_NAMES = Set.of(
"migrations", "alembic", "versions"
);
private boolean isInMachineGeneratedDir(Path relative) {
for (int i = 0; i < relative.getNameCount(); i++) {
if (MACHINE_GENERATED_DIR_NAMES.contains(relative.getName(i).toString())) {
return true;
}
}
return false;
}
There's no leak: the migration files reference model class names and field names that are already visible in models.py (which the AI does see, obfuscated). Migrations contain the same names the AI already has, in machine-generated form. Skipping them costs nothing.
Iteration 2 — urlpatterns doesn't appear to have any patterns
The same test, re-run:
django.core.exceptions.ImproperlyConfigured: The included URLconf 'mysite.urls'
does not appear to have any patterns in it. If you see the 'urlpatterns'
variable with valid patterns in the file then the issue is probably caused
by a circular import.
mysite/urls.py defines:
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("blog.urls")),
]
The obfuscator picked up urlpatterns as a module-level assignment target and rewrote it. Django's URL resolver looks for the literal name urlpatterns in the imported URLconf module via getattr(module, "urlpatterns") — when it finds nothing, it reports "no patterns found."
This is the module-level convention pattern. Django reads a handful of named constants from urls.py files by exact name:
| Name | Purpose |
|---|---|
urlpatterns |
The list of URL routes |
app_name |
Application namespace for reverse('app_name:view_name')
|
handler400, handler403, handler404, handler500
|
Custom error-view callables |
These all need protection. They go into the project-wide Django API name list, applied unconditionally as soon as from django or import django appears anywhere in the project.
A few names in the same category that surfaced separately:
-
INSTALLED_APPS,MIDDLEWARE,DATABASES,TEMPLATES,ROOT_URLCONF,STATIC_URL,MEDIA_URL,LANGUAGE_CODE,SECRET_KEY,DEBUG,ALLOWED_HOSTS,AUTH_USER_MODEL,DEFAULT_AUTO_FIELD, etc. —settings.pymodule-level constants, all introspected by name.
Iteration 3 — ModelForm has no model class specified
Forms next:
ValueError: ModelForm has no model class specified.
File ".../django/forms/models.py", line 362, in __init__
if opts.model is None:
raise ValueError("ModelForm has no model class specified.")
The user's PostForm:
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "slug", "body", "author", "published"]
def clean(self):
...
After obfuscation:
class Cls_d367f020(forms.ModelForm):
class Cls_7f994c64: # <- was Meta
fld_08db7fa3 = Cls_b8106b41 # <- was model = Post
fld_b4a3ded4 = ["title", ...] # <- was fields = [...]
Three layers of damage:
-
Metais the magic inner-class name Django introspects on every Form/ModelForm/Serializer/Model subclass. Renaming it makes the inner class invisible. -
modelis the attribute insideMetathat tellsModelFormwhich model to bind to. -
fieldsis the attribute that lists which model fields to expose.
The fix: add Meta, model, fields (and the dozen other Meta attributes — widgets, error_messages, field_classes, localized_fields, help_texts, labels, read_only_fields, extra_kwargs, abstract, proxy, managed, app_label, indexes, constraints, get_latest_by, default_manager_name, default_related_name, ...) to the protected list.
model and fields are uncomfortably generic — they're going to over-protect plenty of unrelated user code. But the alternative is Django ModelForms not working at all. We took the trade.
This is the inner-class convention pattern: Django uses class Meta: inside another class to attach metadata. The outer class's behavior depends on Django finding Meta by literal name and reading its attributes by literal name.
Iteration 4 — no such table: blog_cls_b8106b41
Tests progressed to the DB layer:
django.db.utils.OperationalError: no such table: blog_cls_b8106b41
Django's ORM generates default DB table names from the model class name via app_label_classname.lower(). With:
class Post(models.Model):
title = models.CharField(...)
Django creates table blog_post. The migration file (copied verbatim, see Iteration 1) declares that table. The obfuscated models.py declares:
class Cls_b8106b41(models.Model):
title = models.CharField(...)
Django's ORM now expects table blog_cls_b8106b41. The migration created blog_post. The two never reconcile.
The fix is a small extension to the AST scan that detects models. The detector was already scanning classes that inherit from models.Model to extract field names — the same scan can emit the class name alongside the fields, with the reason "Django model class (drives db_table + migration refs)". Once the class name is protected, both the model declaration and the migration's CreateModel(name='Post', ...) references stay consistent.
# sidecar's detect_django_models command
for stmt in module.body:
if not isinstance(stmt, libcst.ClassDef):
continue
if not _class_inherits_from(stmt, _DJANGO_MODEL_BASE_CLASSES, libcst):
continue
class_name = stmt.name.value
fields = _scan_django_model_fields(stmt, libcst)
if not fields:
continue
models_found += 1
# Class name protects the DB table name and the migration's name='X' refs
identifiers.append({"name": class_name, "kind": "class", ...})
# Plus each field
for field_name, factory in fields:
identifiers.append({"name": field_name, "kind": "field", ...})
This is the string-derived name pattern: Django uses a Python identifier as a source for a string that gets stored in another system (DB schema, migration JSON, etc.). The class name has to be preserved end-to-end across the layers that aren't directly visible to the obfuscator.
Iteration 5 — empty post list when the database has posts
Render tests next:
def test_post_list_view_renders_published_posts(client, post, draft_post):
resp = client.get(reverse("blog:post_list"))
assert "Hello" in resp.content.decode()
Fixture post creates a Post with title="Hello" and published=True. The view should render it. The assertion fails — the page contains "No posts yet" instead.
The obfuscated PostListView:
class PostListView(ListView):
model = Post
fld_ede69551 = "blog/post_list.html" # was template_name
fld_475e135f = "posts" # was context_object_name
fld_6b917593 = 10 # was paginate_by
def mtd_0729535b(self): # was get_queryset
return Post.objects.filter(published=True)
Django's CBV machinery looks for template_name, context_object_name, paginate_by, and get_queryset by literal getattr name. When they're renamed, the base ListView.get() method picks up its defaults instead: no template (falls back to a synthesised one), default context name (object_list), default queryset (Post.objects.all() — which excludes the published filter), no pagination. The test loaded the page successfully but saw an empty queryset because the user's get_queryset override was effectively gone.
The fix is to add the CBV introspection points to the API name list:
-
CBV class attributes:
template_name,template_name_field,template_name_suffix,context_object_name,paginate_by,paginator_class,page_kwarg,queryset,form_class,form_kwargs,success_url,initial,slug_url_kwarg,pk_url_kwarg,slug_field,raise_exception,redirect_field_name. -
CBV methods:
get_queryset,get_context_data,get_object,get_form_class,get_form_kwargs,get_form,get_initial,get_template_names,get_success_url,get_absolute_url,form_valid,form_invalid,dispatch,setup,as_view,http_method_not_allowed,http_method_names. -
Implicit hooks:
post_save,pre_save,post_delete,pre_delete.
CBV class-attribute names are the declarative-attribute convention pattern: you declare class-level attributes whose names match what the framework's base class expects to read, and you override methods whose names match what the base class's dispatcher calls. Same shape as Spring's @Bean-on-method or JPA's @Entity-on-class — declarative metadata, but expressed in Python via name conventions instead of decorators.
Iteration 6 — clean_body silently disappears
Last failure, on the standalone form:
class CommentForm(forms.Form):
author_name = forms.CharField(max_length=100)
body = forms.CharField(widget=forms.Textarea())
def clean_body(self):
body = self.cleaned_data["body"].strip()
if len(body) < 5:
raise forms.ValidationError("Comment body too short")
return body
The test expects a 5-char minimum to fail validation:
def test_comment_form_requires_minimum_body_length():
form = CommentForm(data={"author_name": "alice", "body": "tiny"})
assert not form.is_valid() # AssertionError: form IS valid
The obfuscated form:
class Cls_xxx(forms.Form):
author_name = forms.CharField(max_length=100)
body = forms.CharField(widget=forms.Textarea())
def mtd_f10d53d5(self): # was clean_body
body = self.cleaned_data["body"].strip()
if len(body) < 5:
raise forms.ValidationError("Comment body too short")
return body
The method body is identical. The method NAME changed. Django's BaseForm._clean_fields() iterates over declared fields and does:
clean_method = getattr(self, f"clean_{name}", None)
if clean_method is not None:
value = clean_method()
getattr(self, "clean_body", None) returns None. The user's validator silently never runs. The form accepts "tiny" and the test fails — not with a clear AttributeError, but with not form.is_valid() being False instead of True.
This is the discover-by-name pattern, analogous to:
- pytest's
def test_*discovery - Spring Data's
findByXderived queries - Lombok's
getX()from a fieldx
The fix extends the form AST scan: in addition to declared fields, walk the class body's FunctionDef children and collect any whose name matches clean_<field> or validate_<field> (DRF uses the latter prefix on serializers). Add them to the exclusion list with the reason "Django form clean_X method (Django discovers by name)".
# sidecar's detect_django_forms command
def _scan_django_form_clean_methods(class_def, libcst) -> list[str]:
methods: list[str] = []
for member in class_def.body.body:
if not isinstance(member, libcst.FunctionDef):
continue
name = member.name.value
if name.startswith("clean_") or name.startswith("validate_"):
methods.append(name)
return methods
The trade-off is mild: a user method called clean_audit_log (not actually a Django form clean method) won't be obfuscated. But the prefix is specific enough that the false-positive rate is low and the trade is worth it.
What the detector looks like at the end
After iterations 1-6, the DjangoDetector is the largest Python detector in PromptCape, with four complementary passes:
-
Fixed list, ~360 names, applied project-wide on any
from django/import django. Includes the Field types, Manager/QuerySet methods, ORM aggregates (Count/F/Q/OuterRef/...), CBV bases + class attribute names + method names, FBV decorators (@login_required,@require_POST,@api_view, ...), HTTP responses + shortcuts, URL routing (path,re_path,include,urlpatterns,app_name,handler400–500), Forms/ModelForms/Serializers, Admin (ModelAdmin,list_display, ...), Auth shortcuts, settings keys (INSTALLED_APPS,MIDDLEWARE, ...), theMetainner-class convention + its 25+ attribute names. -
AST scan of models via
detect_django_models: emits the model class name AND everyname = models.XField(...)field with the factory name. SkipsMeta,objects,_*. -
AST scan of views via
detect_django_views: emits CBV class names (subclasses ofView/ListView/...incl. DRFAPIView/ViewSet) AND FBV function names — detected byrequestfirst param in a file namedviews.pyOR by a known Django/DRF view decorator. -
AST scan of forms via
detect_django_forms: emits form/serializer class field names ANDclean_<field>/validate_<field>method names.
Plus the structural rule: migrations/, alembic/, versions/ directories are copied verbatim, never obfuscated, never collected.
On the test app it produces roughly:
Framework detection [Django]: 360 API + 12 model fields + 7 views + 6 form names = 385 rules in 1.4s
For comparison, the other Python detectors:
stdlib-common-attrs: 230 names (always on)
python-dotenv: 3 keys
pytest: 78 fixtures + 16 test names
PydanticDetector: 102 API + AST-scanned BaseModel fields
FastApiDetector: 76 names
FlaskDetector: 82 names + view function AST scan
CeleryDetector: ~130 names + AST-scanned task function + every parameter
ClickTyperDetector: ~80 names + AST-scanned command function + every parameter
RequestsHttpxDetector: ~110 names (Response attrs, request kwargs, exceptions; no AST scan)
Django needs every other framework combined, in rule count alone. The structural complexity is in the same ballpark.
Why discover-by-name is the recurring shape
Six iterations, six different name contracts, but the same underlying shape every time:
A Python framework asks "does this object have an attribute whose name is exactly X?" by string at runtime, where X is derived from the user's identifier names.
| Iteration | Where the lookup happens | Form of the string |
|---|---|---|
| 1 (migrations) | getattr(migrations_module, 'Migration') |
Imported class name |
| 2 (urlpatterns) | getattr(urlconf, 'urlpatterns') |
Module-level constant name |
| 3 (Meta) | getattr(form_class, 'Meta') |
Inner class name |
| 4 (model class name) | DB schema generation + migration name='Post'
|
Class name → string in another system |
| 5 (CBV attrs) |
getattr(self, 'template_name') etc. |
Class attribute / method name |
| 6 (clean_body) | getattr(self, 'clean_body') |
Method name following a prefix convention |
There is no compile-time check for any of these in Python. The framework's runtime does the lookup. If the lookup fails, the framework's fallback path runs — and the fallback is almost always silently wrong (default behavior instead of the user's override).
This is what makes proactive detection mandatory for Python frameworks. The reactive approach — obfuscate, run, see what breaks — doesn't even reliably surface the bugs. Django's CBV fallback to defaults (Iteration 5) didn't crash; it rendered an empty list. The form's silent validation skip (Iteration 6) didn't crash; the form accepted invalid data. A test suite is the only thing that catches them — and only if the test suite exercises the exact path that depends on the override.
The fix for each iteration is mechanical. The discipline is to enumerate every contract the framework has, in advance, and bake them into the detector before the first user reports a bug. The django-blog test app in PromptCape's repo exists specifically to surface these regressions on every release. Sixteen tests, one of which (test_comment_form_requires_minimum_body_length) exists because Iteration 6 silently happened to a real codebase first.
Conclusion
Django obfuscation for AI assistants isn't fundamentally different from any other Python framework integration: detect the framework, scan the project, build an exclusion list. What's different is the density of contracts. Models, views, forms, admin, settings, migrations, templates — every layer has names that double as strings, and the strings live in different places (other Python files, database schemas, generated migrations, HTML templates, URL configurations).
The four-pass design (fixed list + 3 AST scans) plus the verbatim-copy rule for machine-generated directories cover the surface as we currently understand it. New regressions will surface; each one will be one more entry in the detector. The pattern doesn't change — only the list grows.
Three takeaways for anyone integrating with a similar framework:
-
Enumerate name contracts upfront. Every place the framework does
getattr(obj, 'string')at runtime is a contract. Find them in the framework's source code if you have to; don't wait for production breakage. -
AST scans beat fixed lists when the contract is structural. "Every field of every
BaseModel/models.Modelsubclass" is shorter to express as a scanner than as a hand-maintained list of every field name ever used. - Machine-generated files don't belong in the obfuscation pipeline. Migrations, alembic, anything you can regenerate from a command — copy verbatim, never rewrite. Whatever they expose to the AI is already exposed via the human-written source code, and rewriting them creates more failure modes than the obfuscation it provides.
PromptCape is open for trial at https://promptcape.com/ — free for 3 months, no credit card required. The Django detector + the 15 other Python framework detectors ship in the same JAR; the language and framework set are auto-detected from the source tree. The django-blog test app that drove the iterations in this post is in the public docs repo at gitlab.com/gbreton7/promptcape-docs/-/tree/main/applications/django-blog for anyone who wants to reproduce the cycle.
Top comments (0)