diff --git a/CHANGELOG.md b/CHANGELOG.md index 7387efb..ae2b22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Models whose base class lives outside `models.py` disappeared (#135).** + `class Person(MyCustomModel)` with `MyCustomModel` in `core/base.py` was + missing from the sidebar, the ER diagram and every `scan`: only + `models.py`, `abstract_models.py` and `models/*.py` are read, so the base had + no definition and nothing below it was recognized as a model. The scan now + follows the subclass's imports to the file that defines the base — plain and + relative imports, `from core import base` aliases, and re-exports through a + package `__init__.py` — and inherited fields come along. Only classes that a + model actually inherits from are read from those files, and nothing outside + the workspace root is opened. Same fix in the CLI and the extension. +- **`djangoOrmLens.excludeGlobs` wildcard patterns other than `**/dir/**` + were matched as plain substrings in the extension**, so `**/*_test.py` + excluded nothing there while the CLI's `fnmatch` excluded it. They now + match the way the CLI matches them. + ## [0.19.3] - 2026-10-06 ### Fixed diff --git a/cli/django_orm_lens/parser.py b/cli/django_orm_lens/parser.py index ec08b8d..6dadce1 100644 --- a/cli/django_orm_lens/parser.py +++ b/cli/django_orm_lens/parser.py @@ -734,6 +734,160 @@ def _resolve_and_filter(all_defs: list[ParsedModel]) -> list[ParsedModel]: return result +_FROM_IMPORT_RE = re.compile( + r"^[ \t]*from[ \t]+(\.+[\w.]*|[\w.]+)[ \t]+import[ \t]+(?:\(([^)]*)\)|([^\n]*))", + re.M, +) +_IMPORT_RE = re.compile(r"^[ \t]*import[ \t]+([^\n]+)", re.M) + +#: How many re-exports a base may sit behind (``core/db/__init__.py`` doing +#: ``from .bases import SoftDeletable`` is one). Bounds a malformed import +#: cycle rather than any real project. +_MAX_IMPORT_HOPS = 5 + + +def _parse_imports(content: str) -> dict[str, tuple[str, str | None]]: + """Map each name a module binds by import to ``(module, imported_name)``. + + ``from core.base import MyModel as M`` gives ``M -> ("core.base", "MyModel")``; + ``import core.base as cb`` gives ``cb -> ("core.base", None)``, and a bare + ``import core.base`` binds its head, ``core -> ("core", None)``. Regex + rather than ``ast`` so ``src/parser.ts`` can read imports the same way. + """ + out: dict[str, tuple[str, str | None]] = {} + for m in _FROM_IMPORT_RE.finditer(content): + names = m.group(2) if m.group(2) is not None else m.group(3) + for raw in names.split(","): + item = raw.split("#")[0].strip().rstrip("\\").strip() + parts = item.split() + if not parts or parts[0] == "*": + continue + alias = parts[2] if len(parts) == 3 and parts[1] == "as" else parts[0] + out.setdefault(alias, (m.group(1), parts[0])) + for m in _IMPORT_RE.finditer(content): + for raw in m.group(1).split("#")[0].split(","): + parts = raw.strip().split() + if not parts: + continue + if len(parts) == 3 and parts[1] == "as": + out.setdefault(parts[2], (parts[0], None)) + else: + head = parts[0].split(".")[0] + out.setdefault(head, (head, None)) + return out + + +def _join_module(module: str, name: str) -> str: + return module + name if module.endswith(".") else f"{module}.{name}" + + +def _module_files(module: str, from_file: str, root: Path) -> list[Path]: + """Candidate files for ``module`` as imported from ``from_file``. + + A relative module resolves against the importing file. An absolute one is + tried from every directory between the importing file and ``root``, since + the scan root is not necessarily the import root (``src/`` layouts, a repo + holding several projects). Files outside ``root`` are never returned. + """ + dots = len(module) - len(module.lstrip(".")) + parts = [p for p in module[dots:].split(".") if p] + here = Path(from_file).resolve().parent + # ``from .x`` is this package, each extra dot one directory up. + lineage = [here, *here.parents] + bases = lineage[dots - 1 : dots] if dots else lineage + out: list[Path] = [] + for base in bases: + if base != root and root not in base.parents: + break + target = base.joinpath(*parts) + if parts: + out.append(target.with_name(target.name + ".py")) + out.append(target / "__init__.py") + return out + + +def _pull_imported_bases( + all_defs: list[ParsedModel], root: Path, excludes: Sequence[str] +) -> list[ParsedModel]: + """Definitions of base classes that live outside the scanned model files. + + The scan reads ``models.py``-style files only, so ``class Person(MyBase)`` + with ``MyBase`` in ``core/base.py`` had nothing to resolve against and + ``Person`` vanished (issue #135). This follows the subclass's own imports + to the file that defines each unknown base — through re-exports and + relative imports — and returns those classes so ``_resolve_and_filter`` + sees the whole chain. Only classes something actually inherits from are + pulled, so helpers sharing that file never show up as models. + """ + loaded: dict[Path, tuple[dict[str, ParsedModel], dict] | None] = {} + + def load(path: Path) -> tuple[dict[str, ParsedModel], dict] | None: + if path in loaded: + return loaded[path] + loaded[path] = None + try: + rel = path.relative_to(root).as_posix() + except ValueError: + return None + if any(fnmatch(rel, pat) for pat in excludes) or not path.is_file(): + return None + try: + content = path.read_text(encoding="utf-8", errors="replace") + defs: dict[str, ParsedModel] = {} + for d in _collect_defs(str(path), content): + defs.setdefault(d.name, d) + except Exception: + return None + loaded[path] = (defs, _parse_imports(content)) + return loaded[path] + + def find(path: Path, base: str, hops: int) -> ParsedModel | None: + entry = load(path) + if entry is None or hops > _MAX_IMPORT_HOPS: + return None + defs, imports = entry + *qual, name = base.split(".") + if not qual: + if name in defs: + return defs[name] + target = imports.get(name) + if target is None or target[1] is None: + return None + module, name = target[0], target[1] + else: + head = imports.get(qual[0]) + if head is None: + module = qual[0] + elif head[1] is None: + module = head[0] + else: + module = _join_module(head[0], head[1]) + for part in qual[1:]: + module = _join_module(module, part) + for candidate in _module_files(module, str(path), root): + found = find(candidate, name, hops + 1) + if found is not None: + return found + return None + + known = {m.name for m in all_defs} + queue = [(m.file_path, b) for m in all_defs for b in m.base_classes] + asked: set[tuple[str, str]] = set() + pulled: list[ParsedModel] = [] + while queue: + file_path, base = queue.pop() + if base.split(".")[-1] in known or (file_path, base) in asked: + continue + asked.add((file_path, base)) + found = find(Path(file_path).resolve(), base, 0) + if found is None or found.name in known: + continue + known.add(found.name) + pulled.append(found) + queue.extend((found.file_path, b) for b in found.base_classes) + return pulled + + #: Files that declare models but are not named ``models.py``. Pluggable #: Django frameworks keep their abstract bases here and leave ``models.py`` #: holding only the concrete subclasses — django-oscar does this in all 14 of @@ -833,6 +987,7 @@ def scan_workspace( file=sys.stderr, ) continue + all_defs.extend(_pull_imported_bases(all_defs, root_path, exclude_globs)) apps: dict = {} for model in _resolve_and_filter(all_defs): diff --git a/cli/tests/test_base_outside_models.py b/cli/tests/test_base_outside_models.py new file mode 100644 index 0000000..967fcfe --- /dev/null +++ b/cli/tests/test_base_outside_models.py @@ -0,0 +1,64 @@ +"""Regression test for issue #135 — a model base class outside ``models.py``. + +``class Person(MyCustomModel)`` with ``MyCustomModel`` in ``core/base.py`` +vanished from the scan: only ``models.py``-style files are read, so the base +had no definition to resolve against. The scanner now follows the subclass's +imports to the base's file. + +Fixture: ``test/fixtures/base_outside_models/`` — shared with +``test/base-outside-models.test.js`` so the CLI and the extension are held to +the same answer (see ``test_parity.py``). +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from django_orm_lens.parser import scan_workspace + +FIXTURE = ( + Path(__file__).resolve().parents[2] / "test" / "fixtures" / "base_outside_models" +) + + +class BaseOutsideModelsTest(unittest.TestCase): + def setUp(self) -> None: + index = scan_workspace(str(FIXTURE)) + self.models = {m.name: m for a in index.apps for m in a.models} + + def inherited(self, name: str) -> list[tuple[str, str | None]]: + return [(f.name, f.inherited_from) for f in self.models[name].inherited_fields] + + def test_exactly_the_concrete_models(self) -> None: + self.assertEqual( + sorted(self.models), ["Archive", "City", "Located", "Note", "Person"] + ) + + def test_base_imported_from_plain_module(self) -> None: + self.assertEqual(self.inherited("Person"), [("created", "MyCustomModel")]) + + def test_base_behind_a_reexport_two_hops_away(self) -> None: + self.assertEqual( + self.inherited("Archive"), + [("created", "MyCustomModel"), ("deleted", "SoftDeletable")], + ) + + def test_base_reached_through_a_module_alias(self) -> None: + self.assertEqual(self.inherited("Note"), [("created", "MyCustomModel")]) + + def test_concrete_base_via_relative_import_is_its_own_model(self) -> None: + self.assertEqual( + [f.name for f in self.models["Located"].fields], ["lat", "lng"] + ) + self.assertEqual(self.models["Located"].app_name, "places") + # Multi-table inheritance: City gets a pointer, not Located's columns. + self.assertEqual(self.inherited("City"), []) + + def test_plain_class_in_base_module_does_not_make_a_model(self) -> None: + self.assertNotIn("Plain", self.models) + self.assertNotIn("NotAModel", self.models) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/parser.ts b/src/parser.ts index 76a4b67..304aba0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -534,7 +534,7 @@ function appDirFor(fsPath: string): { dir: string; name: string } { // We only need enough coverage for the default exclude patterns // (**/migrations/**, **/venv/**, **/node_modules/**, etc.) — anything more // exotic falls back to matching the raw pattern as a substring. -function excludeMatcher(patterns: string[]): (relPosix: string) => boolean { +export function excludeMatcher(patterns: string[]): (relPosix: string) => boolean { const segments = patterns .map((p) => { const m = p.match(/^\*\*\/([^/*]+)\/\*\*$/); @@ -542,14 +542,42 @@ function excludeMatcher(patterns: string[]): (relPosix: string) => boolean { }) .filter((s): s is string => !!s); const raw = patterns.filter((p) => !/^\*\*\/([^/*]+)\/\*\*$/.test(p)); + // Wildcard patterns match like Python's fnmatch, which the CLI applies to + // the same setting, so `**/*_test.py` excludes the same files on both sides. + const globs = raw.filter((p) => /[*?[]/.test(p)).map(fnmatchRegex); + const plain = raw.filter((p) => !/[*?[]/.test(p)); return (rel: string) => { const parts = rel.split('/'); for (const seg of segments) if (parts.includes(seg)) return true; - for (const r of raw) if (rel.includes(r)) return true; + for (const g of globs) if (g.test(rel)) return true; + for (const r of plain) if (rel.includes(r)) return true; return false; }; } +/** Python `fnmatch.translate`: `*` spans `/`, `[!x]` negates a class. */ +function fnmatchRegex(pattern: string): RegExp { + let out = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') out += '.*'; + else if (c === '?') out += '.'; + else if (c === '[') { + const end = pattern.indexOf(']', i + 2); + if (end === -1) { + out += '\\['; + continue; + } + let body = pattern.slice(i + 1, end).replace(/\\/g, '\\\\'); + if (body.startsWith('!')) body = '^' + body.slice(1); + else if (body.startsWith('^')) body = '\\' + body; + out += `[${body}]`; + i = end; + } else out += c.replace(/[.+^${}()|\\\]]/g, '\\$&'); + } + return new RegExp(`^${out}$`, 's'); +} + /** * Files that declare models but are not named `models.py`. * @@ -602,6 +630,182 @@ function walkForModels(root: string, isExcluded: (rel: string) => boolean): stri return results; } +const FROM_IMPORT_RE = + /^[ \t]*from[ \t]+(\.+[\w.]*|[\w.]+)[ \t]+import[ \t]+(?:\(([^)]*)\)|([^\n]*))/gm; +const IMPORT_RE = /^[ \t]*import[ \t]+([^\n]+)/gm; + +// How many re-exports a base may sit behind (`core/db/__init__.py` doing +// `from .bases import SoftDeletable` is one). Bounds a malformed import cycle +// rather than any real project. +const MAX_IMPORT_HOPS = 5; + +type ImportTarget = { module: string; name: string | null }; + +/** + * Map each name a module binds by import to the module (and name) it came + * from. Mirrors `_parse_imports` in cli/django_orm_lens/parser.py. + */ +export function parseImports(content: string): Map { + const out = new Map(); + const put = (alias: string, t: ImportTarget) => { + if (!out.has(alias)) out.set(alias, t); + }; + for (const m of content.matchAll(FROM_IMPORT_RE)) { + const names = m[2] ?? m[3] ?? ''; + for (const raw of names.split(',')) { + const item = raw.split('#')[0].trim().replace(/\\$/, '').trim(); + const parts = item.split(/\s+/).filter(Boolean); + if (!parts.length || parts[0] === '*') continue; + const alias = parts.length === 3 && parts[1] === 'as' ? parts[2] : parts[0]; + put(alias, { module: m[1], name: parts[0] }); + } + } + for (const m of content.matchAll(IMPORT_RE)) { + for (const raw of m[1].split('#')[0].split(',')) { + const parts = raw.trim().split(/\s+/).filter(Boolean); + if (!parts.length) continue; + if (parts.length === 3 && parts[1] === 'as') { + put(parts[2], { module: parts[0], name: null }); + } else { + const head = parts[0].split('.')[0]; + put(head, { module: head, name: null }); + } + } + } + return out; +} + +function joinModule(module: string, name: string): string { + return module.endsWith('.') ? module + name : `${module}.${name}`; +} + +function isInside(dir: string, root: string): boolean { + const rel = path.relative(root, dir); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +/** Candidate files for `module` imported from `fromFile`. Mirrors `_module_files`. */ +function moduleFiles(module: string, fromFile: string, root: string): string[] { + const dots = module.length - module.replace(/^\.+/, '').length; + const parts = module.slice(dots).split('.').filter(Boolean); + const here = path.dirname(path.resolve(fromFile)); + const bases: string[] = []; + if (dots) { + let b = here; + for (let k = 1; k < dots; k++) b = path.dirname(b); + bases.push(b); + } else { + for (let b = here; ; b = path.dirname(b)) { + bases.push(b); + if (path.dirname(b) === b) break; + } + } + const out: string[] = []; + for (const b of bases) { + if (!isInside(b, root)) break; + const target = path.join(b, ...parts); + if (parts.length) out.push(target + '.py'); + out.push(path.join(target, '__init__.py')); + } + return out; +} + +/** + * Definitions of base classes that live outside the scanned model files. + * + * The scan reads `models.py`-style files only, so `class Person(MyBase)` with + * `MyBase` in `core/base.py` had nothing to resolve against and `Person` + * vanished (issue #135). This follows the subclass's own imports to the file + * defining each unknown base — through re-exports and relative imports — and + * returns those classes so `resolveAndFilter` sees the whole chain. Only + * classes something inherits from are pulled. Mirrors `_pull_imported_bases` + * in cli/django_orm_lens/parser.py. + */ +export function pullImportedBases( + allDefs: ParsedModel[], + roots: string[], + isExcluded: (relPosix: string) => boolean = () => false +): ParsedModel[] { + const resolvedRoots = roots.map((r) => path.resolve(r)); + const rootOf = (file: string) => + resolvedRoots + .filter((r) => isInside(path.dirname(file), r)) + .sort((a, b) => b.length - a.length)[0]; + type Entry = { defs: Map; imports: Map }; + const loaded = new Map(); + + const load = (file: string, root: string): Entry | null => { + if (loaded.has(file)) return loaded.get(file)!; + loaded.set(file, null); + const rel = path.relative(root, file).split(path.sep).join('/'); + if (rel.startsWith('..') || isExcluded(rel)) return null; + let content: string; + try { + // A directory or a missing file throws here (EISDIR / ENOENT). + content = fs.readFileSync(file, 'utf-8'); + } catch { + return null; + } + const defs = new Map(); + try { + for (const d of collectDefs(file, content)) if (!defs.has(d.name)) defs.set(d.name, d); + } catch { + return null; + } + const entry = { defs, imports: parseImports(content) }; + loaded.set(file, entry); + return entry; + }; + + const find = (file: string, base: string, root: string, hops: number): ParsedModel | null => { + const entry = load(file, root); + if (!entry || hops > MAX_IMPORT_HOPS) return null; + const qual = base.split('.'); + let name = qual.pop()!; + let module: string; + if (!qual.length) { + const own = entry.defs.get(name); + if (own) return own; + const target = entry.imports.get(name); + if (!target || target.name === null) return null; + module = target.module; + name = target.name; + } else { + const head = entry.imports.get(qual[0]); + if (!head) module = qual[0]; + else if (head.name === null) module = head.module; + else module = joinModule(head.module, head.name); + for (const part of qual.slice(1)) module = joinModule(module, part); + } + for (const candidate of moduleFiles(module, file, root)) { + const found = find(candidate, name, root, hops + 1); + if (found) return found; + } + return null; + }; + + const known = new Set(allDefs.map((m) => m.name)); + const queue: Array<[string, string]> = []; + for (const m of allDefs) for (const b of m.baseClasses) queue.push([m.filePath, b]); + const asked = new Set(); + const pulled: ParsedModel[] = []; + while (queue.length) { + const [filePath, base] = queue.pop()!; + const key = `${filePath}\0${base}`; + if (known.has(base.split('.').pop() ?? '') || asked.has(key)) continue; + asked.add(key); + const file = path.resolve(filePath); + const root = rootOf(file); + if (!root) continue; + const found = find(file, base, root, 0); + if (!found || known.has(found.name)) continue; + known.add(found.name); + pulled.push(found); + for (const b of found.baseClasses) queue.push([found.filePath, b]); + } + return pulled; +} + export async function scanWorkspace( excludeGlobs: string[] ): Promise { @@ -676,6 +880,13 @@ export async function scanWorkspace( console.error(`django-orm-lens: parser error in ${uri.fsPath}`, err); } } + allDefs.push( + ...pullImportedBases( + allDefs, + folders.filter((f) => f.uri.scheme === 'file').map((f) => f.uri.fsPath), + excludeMatcher(excludeGlobs) + ) + ); for (const model of resolveAndFilter(allDefs)) { const { dir: appDir, name: appName } = appDirFor(model.filePath); diff --git a/test/base-outside-models.test.js b/test/base-outside-models.test.js new file mode 100644 index 0000000..77e2f1d --- /dev/null +++ b/test/base-outside-models.test.js @@ -0,0 +1,92 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const Module = require('node:module'); +const test = require('node:test'); + +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === 'vscode') return {}; + return originalLoad.call(this, request, parent, isMain); +}; + +const { collectDefs, resolveAndFilter, pullImportedBases } = require('../out/parser'); + +test.after(() => { + Module._load = originalLoad; +}); + +// Issue #135: `class Person(MyCustomModel)` with the base in `core/base.py` +// vanished, because only models.py-style files are read and the base had no +// definition to resolve against. Same fixture and the same answers as +// cli/tests/test_base_outside_models.py, so the sidebar and `scan` agree. +const root = path.join(__dirname, 'fixtures', 'base_outside_models'); +const modelFiles = ['people/models.py', 'places/models.py'].map((p) => path.join(root, p)); + +function scan() { + const defs = modelFiles.flatMap((f) => collectDefs(f, fs.readFileSync(f, 'utf-8'))); + defs.push(...pullImportedBases(defs, [root])); + return new Map(resolveAndFilter(defs).map((m) => [m.name, m])); +} + +const inherited = (m) => m.inheritedFields.map((f) => [f.name, f.inheritedFrom]); + +test('without following imports every model is lost — the bug', () => { + const defs = modelFiles.flatMap((f) => collectDefs(f, fs.readFileSync(f, 'utf-8'))); + assert.deepEqual(resolveAndFilter(defs).map((m) => m.name), []); +}); + +test('exactly the concrete models are found', () => { + assert.deepEqual([...scan().keys()].sort(), ['Archive', 'City', 'Located', 'Note', 'Person']); +}); + +test('a base imported from a plain module', () => { + assert.deepEqual(inherited(scan().get('Person')), [['created', 'MyCustomModel']]); +}); + +test('a base behind a re-export, two hops away', () => { + assert.deepEqual(inherited(scan().get('Archive')), [ + ['created', 'MyCustomModel'], + ['deleted', 'SoftDeletable'], + ]); +}); + +test('a base reached through a module alias', () => { + assert.deepEqual(inherited(scan().get('Note')), [['created', 'MyCustomModel']]); +}); + +test('a concrete base via a relative import is a model of its own', () => { + const models = scan(); + assert.deepEqual(models.get('Located').fields.map((f) => f.name), ['lat', 'lng']); + assert.equal(models.get('Located').appName, 'places'); + assert.deepEqual(inherited(models.get('City')), []); +}); + +test('a plain class in the base module does not make a model', () => { + const models = scan(); + assert.ok(!models.has('Plain')); + assert.ok(!models.has('NotAModel')); +}); + +test('files outside the root are never read', () => { + const defs = modelFiles.flatMap((f) => collectDefs(f, fs.readFileSync(f, 'utf-8'))); + assert.deepEqual(pullImportedBases(defs, [path.join(root, 'places')]).map((m) => m.name), ['Located']); +}); + +test('exclude globs match like fnmatch in the CLI', () => { + const { excludeMatcher } = require('../out/parser'); + const ex = excludeMatcher(['**/migrations/**', '**/*_test.py', 'core/db/*', 'legacy']); + assert.ok(ex('migrations/0001.py'), 'segment form still matches at the top level'); + assert.ok(ex('core/base_test.py')); + assert.ok(ex('core/db/bases.py')); + assert.ok(ex('old/legacy/models.py'), 'a pattern without wildcards keeps substring matching'); + assert.ok(!ex('core/base.py')); +}); + +test('an excluded base file is not read', () => { + const { excludeMatcher } = require('../out/parser'); + const defs = modelFiles.flatMap((f) => collectDefs(f, fs.readFileSync(f, 'utf-8'))); + const pulled = pullImportedBases(defs, [root], excludeMatcher(['core/db/*'])).map((m) => m.name); + assert.ok(!pulled.includes('SoftDeletable')); + assert.ok(pulled.includes('MyCustomModel')); +}); diff --git a/test/fixtures/base_outside_models/README.md b/test/fixtures/base_outside_models/README.md new file mode 100644 index 0000000..b162969 --- /dev/null +++ b/test/fixtures/base_outside_models/README.md @@ -0,0 +1,12 @@ +Shared fixture for issue #135, read by both `cli/tests/test_base_outside_models.py` +and `test/base-outside-models.test.js`. + +Every base class here lives outside the files the scanner reads +(`models.py`, `abstract_models.py`, `models/*.py`), so the only way to find +it is to follow the subclass's `import`: + +- `core/base.py` — abstract `MyCustomModel`, the shape from the issue. +- `core/db/` — abstract `SoftDeletable(MyCustomModel)`, re-exported through + `core/db/__init__.py`, so reaching it takes two hops. +- `places/base.py` — a concrete `Located`, pulled in by a relative import. + Django creates a table for it, so it is a model in its own right. diff --git a/test/fixtures/base_outside_models/core/__init__.py b/test/fixtures/base_outside_models/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/base_outside_models/core/base.py b/test/fixtures/base_outside_models/core/base.py new file mode 100644 index 0000000..f6d528a --- /dev/null +++ b/test/fixtures/base_outside_models/core/base.py @@ -0,0 +1,12 @@ +from django.db import models + + +class MyCustomModel(models.Model): + created = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True + + +class NotAModel: + pass diff --git a/test/fixtures/base_outside_models/core/db/__init__.py b/test/fixtures/base_outside_models/core/db/__init__.py new file mode 100644 index 0000000..bc3e4fe --- /dev/null +++ b/test/fixtures/base_outside_models/core/db/__init__.py @@ -0,0 +1 @@ +from .bases import SoftDeletable # noqa: F401 diff --git a/test/fixtures/base_outside_models/core/db/bases.py b/test/fixtures/base_outside_models/core/db/bases.py new file mode 100644 index 0000000..c7df086 --- /dev/null +++ b/test/fixtures/base_outside_models/core/db/bases.py @@ -0,0 +1,10 @@ +from django.db import models + +from core.base import MyCustomModel + + +class SoftDeletable(MyCustomModel): + deleted = models.BooleanField(default=False) + + class Meta: + abstract = True diff --git a/test/fixtures/base_outside_models/people/models.py b/test/fixtures/base_outside_models/people/models.py new file mode 100644 index 0000000..a89fe7e --- /dev/null +++ b/test/fixtures/base_outside_models/people/models.py @@ -0,0 +1,24 @@ +from django.db import models + +from core import base as core_base +from core.base import ( + MyCustomModel, + NotAModel, # a plain class, must not turn its subclass into a model +) +from core.db import SoftDeletable + + +class Person(MyCustomModel): + name = models.CharField(max_length=50) + + +class Archive(SoftDeletable): + title = models.CharField(max_length=100) + + +class Note(core_base.MyCustomModel): + text = models.TextField() + + +class Plain(NotAModel): + pass diff --git a/test/fixtures/base_outside_models/places/base.py b/test/fixtures/base_outside_models/places/base.py new file mode 100644 index 0000000..4a02d26 --- /dev/null +++ b/test/fixtures/base_outside_models/places/base.py @@ -0,0 +1,6 @@ +from django.db import models + + +class Located(models.Model): + lat = models.FloatField() + lng = models.FloatField() diff --git a/test/fixtures/base_outside_models/places/models.py b/test/fixtures/base_outside_models/places/models.py new file mode 100644 index 0000000..673e074 --- /dev/null +++ b/test/fixtures/base_outside_models/places/models.py @@ -0,0 +1,7 @@ +from django.db import models + +from .base import Located + + +class City(Located): + name = models.CharField(max_length=80)