#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The layering guard: dependencies between the three packages point one way.docs/package-guide.md § 1 — `euler_webshell → euler_site → solver`. Each package lives inits own folder under `packages/`; this checker reads every module of each and fails on animport of a package *above* it — top-level or lazily inside a function, `import x` or`from x import y`:- `packages/solver/solver` may import neither `euler_site` nor `euler_webshell`;- `packages/site/euler_site` may not import `euler_webshell`.The solver's loader manifest (`packages/solver/solver/modules.csv`) counts too: a commandmodule listed there is loaded into every shell, so it may name only the solver's own.Known exceptions would be recorded in `layers.allow` (one `<path> <module>` per line); it isempty, and it only ratchets down: a new violation fails, and so does an entry that no longermatches anything.Usage: `python scripts/linters/layers.py [repo_root]` — exits 0 when clean."""from__future__importannotationsimportastimportcsvimportsysfrompathlibimportPath#: (the package's folder in the checkout, its import name, the packages it may not import).LAYERS:tuple[tuple[str,str,tuple[str,...]],...]=(('packages/solver','solver',('euler_site','euler_webshell')),('packages/site','euler_site',('euler_webshell',)),)#: The allowlist, next to this script.ALLOW_FILE:Path=Path(__file__).with_name('layers.allow')#: The loader manifest, repo-relative, and the one package it may name.MANIFEST:str='packages/solver/solver/modules.csv'MANIFEST_PACKAGE:str='solver'Violation=tuple[str,str]# (repo-relative path, imported module from a higher layer)defis_under(module:str,packages:tuple[str,...])->bool:"""True if *module* is, or lives under, one of *packages*."""returnany(module==pkgormodule.startswith(f'{pkg}.')forpkginpackages)defimported_modules(tree:ast.AST)->set[str]:"""Every absolute module a parsed file imports, anywhere in it. `from a import b` yields `a` and `a.b` — `b` may be a submodule — so a check on either form of a forbidden package catches it. """found:set[str]=set()fornodeinast.walk(tree):ifisinstance(node,ast.Import):found.update(alias.nameforaliasinnode.names)elifisinstance(node,ast.ImportFrom)andnode.moduleandnotnode.level:found.add(node.module)returnfounddefviolations(root:Path)->set[Violation]:"""Every import of a higher layer, from every package of *root*, plus manifest rows."""found:set[Violation]=set()forfolder,name,forbiddeninLAYERS:forpathinsorted((root/folder/name).rglob('*.py')):tree=ast.parse(path.read_text(encoding='utf-8'),filename=str(path))rel=path.relative_to(root).as_posix()found.update((rel,module)formoduleinimported_modules(tree)ifis_under(module,forbidden))manifest=root/MANIFESTifmanifest.exists():withopen(manifest,newline='')asf:next(csv.reader(f),None)forrowincsv.reader(f):iflen(row)>1androw[1].strip().lower()in('true','1','yes') \andnotis_under(row[0],(MANIFEST_PACKAGE,)):found.add((MANIFEST,row[0]))returnfounddefread_allow(path:Path)->set[Violation]:"""The allowlisted `(path, module)` pairs; `#` starts a comment."""allowed:set[Violation]=set()ifpath.exists():forlineinpath.read_text(encoding='utf-8').splitlines():fields=line.split('#',1)[0].split()iflen(fields)==2:allowed.add((fields[0],fields[1]))returnalloweddefcheck(root:Path,allow_file:Path=ALLOW_FILE)->int:"""Report new violations and stale allowlist entries; 0 when there are neither."""found,allowed=violations(root),read_allow(allow_file)new,stale=sorted(found-allowed),sorted(allowed-found)forrel,moduleinnew:print(f'{rel}: imports {module}, a layer above it')forrel,moduleinstale:print(f'{allow_file.name}: stale entry "{rel}{module}" — remove it')ifnew:print('^ dependencies point one way: euler_webshell → euler_site → solver (docs/package-guide.md § 1)')return1ifneworstaleelse0if__name__=='__main__':sys.exit(check(Path(sys.argv[1]iflen(sys.argv)>1else'.').resolve()))