Download bin/context.py from DBax127/nexa: direct link, hf CLI and curl.
- Browser
- Download file 37.7 kB
-
https://huggingface.co/DBax127/nexa/resolve/main/bin/context.py
- Command line
-
hf download hf://DBax127/nexa/bin/context.py
-
curl -L -o context.py https://huggingface.co/DBax127/nexa/resolve/main/bin/context.py
37.7 kB
| #!/usr/bin/env python3 | |
| """What is true about the place this code will actually live. | |
| WHY THIS EXISTS | |
| --------------- | |
| ARCHITECTURE records the context layer as absent by design, and names the reason | |
| honestly: odoo.ondelete-is-a-decision cannot be decided from a snippet, because | |
| whether a dangling reference matters depends on whether anything ever reads the | |
| field back -- context a snippet does not contain. | |
| That reasoning is correct about snippets and wrong about files. When the input is | |
| a real path in a real tree, the context is sitting on disk: the module manifest | |
| says which Odoo version this has to run on, the file says which model it extends, | |
| ir.model.access.csv says whether the model can be deleted at all. None of it | |
| needs a model to guess. | |
| So this module reads the surroundings, and the context layer reports UNKNOWN -- | |
| never PASS -- whenever it was handed a snippet with no surroundings to read. An | |
| absent context is a stated gap, not a clean bill of health. | |
| Stdlib only. | |
| """ | |
| import ast | |
| import csv | |
| import io | |
| import json | |
| import os | |
| import re | |
| CONTEXT_VERSION = "1.0.0" | |
| MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py") | |
| MAX_WALK_UP = 6 | |
| _VERSION_RE = re.compile(r"[\"']version[\"']\s*:\s*[\"']([0-9.]+)[\"']") | |
| # An <record model="ir.rule"> and the two fields that decide whether it filters | |
| # anything. Attribute order is not fixed -- one OpenSPP file writes ref= before | |
| # name= on every row, which is 10 of that tree's 239 rules -- so model_id is | |
| # matched without assuming which attribute comes first. | |
| _IR_RULE_RE = re.compile( | |
| r"<record[^>]*\bmodel=[\"']ir\.rule[\"'][^>]*>(.*?)</record>", re.S) | |
| _RULE_MODEL_RE = re.compile( | |
| r"<field\b(?=[^>]*\bname=[\"']model_id[\"'])[^>]*\bref=[\"']([\w.]+)[\"']") | |
| _RULE_DOMAIN_RE = re.compile( | |
| r"<field\b[^>]*\bname=[\"']domain_force[\"'][^>]*>(.*?)</field>", re.S) | |
| # A domain that matches every row filters nothing, so sudo cannot drop it. This | |
| # is the whole of `[(1, '=', 1)]` in any spacing; anything else is treated as a | |
| # real filter, because deciding that a domain is vacuous in general means | |
| # evaluating it. | |
| _VACUOUS_DOMAIN = re.compile(r"^\[\s*\(\s*1\s*,\s*[\"']=[\"']\s*,\s*1\s*\)\s*\]$") | |
| # The left operand of every leaf in a domain: the fields the rule filters ON. | |
| # A domain is a flat list of triples once the operators are ignored, and the | |
| # first element of each is a field path. Read for one purpose only -- deciding | |
| # whether a call under sudo names the field the rule it bypasses is about -- | |
| # and a field this fails to read simply is not offered as an exemption. | |
| _RULE_FIELD_RE = re.compile(r"[\(\[]\s*[\"']([a-z_][a-z0-9_.]*)[\"']\s*,") | |
| _NAME_RE = re.compile(r"[\"']name[\"']\s*:\s*[\"']([^\"']+)[\"']") | |
| _MODEL_RE = re.compile(r"^\s*_(?:name|inherit)\s*=\s*[\"']([a-z0-9_.]+)[\"']", re.M) | |
| _ODOO_IMPORT_RE = re.compile(r"^\s*from\s+odoo[\w.]*\s+import\s+(.+)$", re.M) | |
| # What this corpus is able to review, and nothing else. One table, read by | |
| # subject.route as well, so a file cannot be refused by the command and | |
| # contextualised here anyway. | |
| # | |
| # .py is where every rule but one lives; .xml carries the noupdate rule, whose | |
| # violation is a <record> in a data file. Anything else is refused by name | |
| # rather than reviewed: the Odoo rules run over a .ts file find nothing, and | |
| # nothing reads as a clean bill of health. | |
| REVIEWABLE = (".py", ".xml") | |
| def stack_for(path): | |
| """The rule stack a file belongs to. There is one, and this says whether | |
| the file is in it. | |
| Returns "odoo" for a file the corpus can review and None for one it cannot. | |
| Both an Odoo module and a plain Python script return "odoo": the corpus | |
| holds the ORM rules and the plain-Python rules together, so there is no | |
| longer a second stack to send a script to, and a script inside an Odoo | |
| repository -- scripts/, tools/, a migration helper -- is reviewed against | |
| the language rules, with the ORM rules finding nothing in it. | |
| """ | |
| if os.path.splitext(path)[1].lower() in REVIEWABLE: | |
| return "odoo" | |
| return None | |
| def _walk_up(start, names): | |
| """Nearest ancestor directory containing any of `names`, with the hit.""" | |
| directory = os.path.dirname(os.path.abspath(start)) | |
| for _ in range(MAX_WALK_UP): | |
| for name in names: | |
| candidate = os.path.join(directory, name) | |
| if os.path.isfile(candidate): | |
| return candidate | |
| parent = os.path.dirname(directory) | |
| if parent == directory: | |
| break | |
| directory = parent | |
| return None | |
| _READ_LIMIT = 200000 | |
| def _read(path, limit=_READ_LIMIT): | |
| try: | |
| with io.open(path, encoding="utf-8", errors="ignore") as fh: | |
| return fh.read(limit) | |
| except OSError: | |
| return "" | |
| def _odoo_series(version): | |
| """'18.0.1.0.3' -> '18.0'. Odoo module versions carry the series in front.""" | |
| parts = version.split(".") | |
| return ".".join(parts[:2]) if len(parts) >= 2 else version | |
| def from_file(path, stack=None): | |
| """Everything knowable about a file's environment without running anything. | |
| Every key is either a real finding or None. None means 'not determinable | |
| here', and the context layer reports it as UNKNOWN rather than assuming a | |
| default -- an assumed Odoo version is worse than a missing one, because it | |
| silently answers a version question nobody verified. | |
| `stack` overrides the extension guess, and callers that have one should pass | |
| it: the extension cannot tell an Odoo module from a plain Python script, and | |
| a context labelled odoo goes looking for a __manifest__.py that a script was | |
| never going to have. | |
| """ | |
| ctx = { | |
| "path": os.path.abspath(path), | |
| "stack": stack or stack_for(path), | |
| "source": "file", | |
| "odoo_series": None, | |
| "module": None, | |
| "manifest": None, | |
| "models": [], | |
| "odoo_imports": [], | |
| "is_test": False, | |
| # How much of the file the answer was about. Filled in by subject.plan, | |
| # which is the only thing that knows; None means nobody chose an | |
| # excerpt, which is the case for a bare question. | |
| "reviewed": None, | |
| } | |
| body = _read(path) | |
| base = os.path.basename(path).lower() | |
| ctx["is_test"] = base.startswith("test_") or ".test." in base or ".spec." in base \ | |
| or os.sep + "tests" + os.sep in ctx["path"].lower() | |
| if ctx["stack"] in ("odoo", "python"): | |
| # Both gather the same way. A .py file that turns out to be plain Python | |
| # has no manifest above it -- that is how it was routed to python in the | |
| # first place -- so the walk costs nothing and the alternative is two | |
| # branches that have to stay in step. | |
| manifest = _walk_up(path, MANIFEST_NAMES) | |
| if manifest: | |
| text = _read(manifest) | |
| ctx["manifest"] = manifest | |
| ctx["module"] = os.path.basename(os.path.dirname(manifest)) | |
| found = _VERSION_RE.search(text) | |
| if found: | |
| ctx["odoo_series"] = _odoo_series(found.group(1)) | |
| elif _NAME_RE.search(text): | |
| ctx["odoo_series"] = None # a manifest with no version pin | |
| ctx["models"] = sorted(set(_MODEL_RE.findall(body))) | |
| imports = [] | |
| for line in _ODOO_IMPORT_RE.findall(body): | |
| imports.extend(part.strip() for part in line.split(",") if part.strip()) | |
| ctx["odoo_imports"] = sorted(set(imports)) | |
| return ctx | |
| def empty(stack): | |
| """The context for a bare question: nothing known, and said so. | |
| Returned for `nexa ask`, where there is no file and therefore no | |
| surroundings. The layer above turns this into UNKNOWN. | |
| """ | |
| return { | |
| "path": None, | |
| "stack": stack, | |
| "source": "none", | |
| "odoo_series": None, | |
| "module": None, | |
| "manifest": None, | |
| "models": [], | |
| "odoo_imports": [], | |
| "is_test": False, | |
| # How much of the file the answer was about. Filled in by subject.plan, | |
| # which is the only thing that knows; None means nobody chose an | |
| # excerpt, which is the case for a bare question. | |
| "reviewed": None, | |
| } | |
| # ------------------------------------------------------------ tree triggers | |
| # | |
| # Some rules are conditional on a fact no single file contains. | |
| # | |
| # ondelete-is-a-decision the harm is a dangling read after the PARENT is | |
| # deleted. If nothing ever deletes the parent -- if | |
| # it is master data -- there is no dangling read to | |
| # have. | |
| # check-company-on-relations the harm is a record in company A referencing | |
| # company B's. A codebase running one company cannot | |
| # exhibit it. | |
| # | |
| # Both are answerable from the tree, and answering them is what turns a rule that | |
| # describes a CONSTRUCT into one that describes a DEFECT -- for the subset where | |
| # the answer is available, and only there. The three-valued result is the whole | |
| # point: PRESENT promotes the finding, ABSENT drops it, and UNKNOWN leaves the | |
| # rule exactly where it was rather than inventing a default. This module already | |
| # reports UNKNOWN over an assumed Odoo version for the same reason. | |
| PRESENT = "present" | |
| ABSENT = "absent" | |
| UNKNOWN = "unknown" | |
| # ir.model.access.csv is where Odoo records whether a model can be deleted AT | |
| # ALL. A model with perm_unlink 0 in every row cannot be deleted through the UI | |
| # by anybody, which is the half of the question reading Python can never answer. | |
| _PERM_UNLINK_TRUE = ("1", "true", "True", "TRUE") | |
| # For the module-sized walk only. fieldtest owns the list for whole-tree walks, | |
| # because it owns the walk. | |
| _SKIP_DIRS = frozenset([".git", ".hg", "node_modules", "static", "__pycache__", | |
| ".tox", ".venv", "venv", "dist", "build", "migrations"]) | |
| _TRIGGER_EXTENSIONS = (".py", ".csv", ".xml") | |
| _MODULE_FILE_CAP = 400 | |
| # The modules a checked module depends on are read for their ORM declarations | |
| # alone (module_facts). Test directories are left out of that read, as | |
| # fieldtest's trigger walk leaves them out: a model a test declares is not | |
| # installed with the module. Past the cap the read costs seconds, about two | |
| # for a thousand files of Odoo core on the machine it was measured on. | |
| _TEST_DIRS = frozenset(["tests", "test", "__tests__", "spec"]) | |
| _CLOSURE_FILE_CAP = 1000 | |
| _MODULE_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$") | |
| def no_tree(): | |
| """The facts for a file with no tree around it: nothing walked, nothing | |
| claimed. Every trigger reads UNKNOWN off this, which is what `nexa check` | |
| on a single path gets and is the honest answer there.""" | |
| return {"walked": False, "complete": False, "files": 0, | |
| "models_declared": set(), "models_deleted": set(), | |
| "multi_company": None, "odoo_series": None} | |
| def tree_facts(paths, complete=True): | |
| """Walk a tree once and record what the triggers need to know. | |
| `paths` is an iterable of file paths -- the caller owns the walk, because the | |
| module that already knows how to skip a node_modules is the one that should | |
| keep doing it. Python files, ir.model.access.csv and XML data files are each | |
| read for what only they can say. | |
| `complete` is whether those paths were the WHOLE codebase. It decides what | |
| the facts may be used for, and the two uses have different bars: | |
| promoting positive evidence stays positive however little was read. A | |
| module that deletes spp.vocabulary deletes it. | |
| suppressing "nothing deletes this" is only true of everything that was | |
| read, so a partial walk must not say it. A model declared in | |
| one module and deleted in another would otherwise vanish from | |
| the report on the strength of not looking. | |
| So `nexa check` walks one module and gets promotions only; `fieldtest` walks | |
| the roots it was given and gets both. | |
| The ORM facts keep what they were made from, `orm_inputs`, so module_facts | |
| can add the modules a checked module depends on without reading its own | |
| files a second time. | |
| Returns a dict whose `walked` is True. That flag is the difference between | |
| "this tree does not delete res.partner" and "nobody looked", and every | |
| trigger below refuses to answer without it. | |
| """ | |
| import semantics # local: context is imported by modules semantics is not | |
| declared, deleted = set(), set() | |
| unlink_tokens = set() | |
| ruled = {} | |
| rule_sets = {} | |
| multi_company = False | |
| seen = 0 | |
| # Every series the manifests in this walk name. Kept as a set because | |
| # disagreement is not an average: a tree holding a 16 module beside a 19 | |
| # module has no single answer, and a checker that needs the series must | |
| # hear that it cannot have one rather than be handed whichever came first. | |
| series_seen = set() | |
| manifests = {} | |
| declarations = [] | |
| for path in paths: | |
| lower = path.lower() | |
| body = _read(path) | |
| if not body: | |
| continue | |
| seen += 1 | |
| if os.path.basename(lower) in MANIFEST_NAMES: | |
| found = _VERSION_RE.search(body) | |
| series_seen.add(_odoo_series(found.group(1)) if found else None) | |
| manifests[os.path.dirname(os.path.abspath(path))] = _manifest_depends(body) | |
| if lower.endswith(".py"): | |
| declared |= semantics.models_declared(body) | |
| deleted |= semantics.models_unlinked(body) | |
| if not multi_company and semantics.declares_multi_company(body): | |
| multi_company = True | |
| # Read whole for this: _read stops at 200,000 characters, and | |
| # account_move.py, account_tax.py and mail_thread.py are longer. | |
| # A file cut short does not parse, and would declare nothing. | |
| whole = _read(path, None) if len(body) >= _READ_LIMIT else body | |
| declarations.append((path, semantics.orm_declarations(whole))) | |
| elif lower.endswith(".csv"): | |
| if os.path.basename(lower).startswith("ir.model.access"): | |
| unlink_tokens |= _models_granted_unlink(body) | |
| elif lower.endswith(".xml"): | |
| # Multi-company record rules live in XML and nowhere else. The same | |
| # markers work here: a domain reading company_ids is the rule that | |
| # only exists when several companies do. | |
| if not multi_company and any(m in body for m in | |
| semantics.MULTI_COMPANY_MARKERS): | |
| multi_company = True | |
| if "ir.rule" in body: | |
| for model, fields in _models_with_a_filtering_rule(body).items(): | |
| ruled.setdefault(model, set()).update(fields) | |
| for model, rules in _rules_one_by_one(body).items(): | |
| rule_sets.setdefault(model, []).extend(rules) | |
| # Access tokens are matched FORWARDS, from each declared model to the token | |
| # it would have. Parsing `model_sale_order_line` back into a dotted name is | |
| # ambiguous -- sale.order.line and sale.order_line spell it identically -- | |
| # and going this way there is nothing to guess. | |
| for model in declared: | |
| if "model_" + model.replace(".", "_") in unlink_tokens: | |
| deleted.add(model) | |
| # One series, agreed by every manifest that pinned one, or nothing. | |
| pinned = {v for v in series_seen if v} | |
| facts = {"walked": True, "complete": complete, "files": seen, | |
| "models_declared": declared, "models_deleted": deleted, | |
| "models_record_ruled": ruled, | |
| "models_rule_fields": {m: tuple(sorted(f)) for m, f in ruled.items()}, | |
| "models_rule_sets": rule_sets, | |
| "multi_company": multi_company, | |
| "odoo_series": pinned.pop() if len(pinned) == 1 else None} | |
| facts.update(_orm_facts(declarations, manifests)) | |
| facts["orm_inputs"] = (declarations, manifests) | |
| return facts | |
| def _with_declarations(facts, paths): | |
| """The facts with what more files declare added to their ORM facts, and | |
| nothing else: the modules a checked module depends on (module_facts). | |
| Python files only, read for their models' fields and methods and their | |
| manifests' depends. What they declare can only add to what the depends | |
| check finds declared, so the walk is no more complete than it was, and | |
| every other fact stays that of the walk. | |
| """ | |
| import semantics # local, as in tree_facts | |
| declarations, manifests = facts["orm_inputs"] | |
| declarations, manifests = list(declarations), dict(manifests) | |
| for path in paths: | |
| lower = path.lower() | |
| body = _read(path) | |
| if not body or not lower.endswith(".py"): | |
| continue | |
| if os.path.basename(lower) in MANIFEST_NAMES: | |
| manifests[os.path.dirname(os.path.abspath(path))] = _manifest_depends(body) | |
| whole = _read(path, None) if len(body) >= _READ_LIMIT else body | |
| declarations.append((path, semantics.orm_declarations(whole))) | |
| wider = dict(facts) | |
| wider.pop("with_dependencies", None) | |
| wider.update(_orm_facts(declarations, manifests)) | |
| wider["orm_inputs"] = (declarations, manifests) | |
| return wider | |
| def _manifest_depends(body): | |
| """The modules a manifest depends on, or () when it cannot be read.""" | |
| try: | |
| manifest = ast.literal_eval(body.strip()) | |
| except (ValueError, SyntaxError, MemoryError, RecursionError): | |
| return () | |
| depends = manifest.get("depends") if isinstance(manifest, dict) else None | |
| return tuple(d for d in depends or () if isinstance(d, str)) | |
| def _orm_facts(declarations, manifests): | |
| """What the walk declares about its models, each part tagged with its module. | |
| The depends check asks three things no single file answers: whether a | |
| compute's field is stored, which @api.depends Odoo merges into a method from | |
| its other definitions, and what a declared computed field depends on in | |
| turn. Each answer is only good where the module that gives it is installed, | |
| so every declaration keeps the module it ships in, and a module is the | |
| directory holding its manifest. | |
| modules {module: the modules its manifest depends on} | |
| module_dirs {directory: module} | |
| orm_fields {model: {field: [(module, declaration)]}} | |
| orm_methods {model: {method: [(module, @api.depends or None)]}} | |
| orm_parents {model: [(module, a model it inherits)]} | |
| orm_named the models some class in the walk names with _name | |
| """ | |
| module_dirs = {directory: os.path.basename(directory) for directory in manifests} | |
| modules = {os.path.basename(directory): depends | |
| for directory, depends in manifests.items()} | |
| fields, methods, parents, named = {}, {}, {}, set() | |
| for path, records in declarations: | |
| module = _module_at(path, module_dirs) | |
| for record in records: | |
| model = record["model"] | |
| if record["named"]: | |
| named.add(model) | |
| for field, declaration in record["fields"].items(): | |
| fields.setdefault(model, {}).setdefault(field, []).append( | |
| (module, declaration)) | |
| for method, depends in record["methods"].items(): | |
| methods.setdefault(model, {}).setdefault(method, []).append( | |
| (module, depends)) | |
| for parent in record["parents"]: | |
| parents.setdefault(model, []).append((module, parent)) | |
| return {"modules": modules, "module_dirs": module_dirs, "orm_fields": fields, | |
| "orm_methods": methods, "orm_parents": parents, "orm_named": named} | |
| def _module_at(path, module_dirs): | |
| """The module a file ships in: the nearest directory above it holding a | |
| manifest the walk read. None for a file outside every module.""" | |
| directory = os.path.dirname(os.path.abspath(path)) | |
| while True: | |
| if directory in module_dirs: | |
| return module_dirs[directory] | |
| parent = os.path.dirname(directory) | |
| if parent == directory: | |
| return None | |
| directory = parent | |
| def for_file(facts, path): | |
| """The tree facts as one file sees them: the same facts, and its module. | |
| The ORM facts are answered per module, and which module is asking is the | |
| one thing about them that changes from file to file. A shallow copy, so | |
| the walk's facts are shared and only the key is the file's own. | |
| """ | |
| if not facts or not facts.get("walked"): | |
| return facts | |
| return dict(facts, module=_module_at(path, facts.get("module_dirs") or {})) | |
| def module_closure(facts, module): | |
| """Every module installed whenever `module` is: itself, what its manifest | |
| depends on, transitively, and base, which every module depends on. | |
| None when the module is unknown, which is not the same as having none. | |
| """ | |
| if not facts or not module: | |
| return None | |
| modules = facts.get("modules") or {} | |
| seen = {module, "base"} | |
| stack = [module] | |
| while stack: | |
| for depend in modules.get(stack.pop(), ()): | |
| if depend not in seen: | |
| seen.add(depend) | |
| stack.append(depend) | |
| return seen | |
| def _models_with_a_filtering_rule(body): | |
| """Models this XML gives an ir.rule that actually filters something. | |
| A rule whose domain_force is `[(1, '=', 1)]` is a grant, not a filter: it | |
| exists to widen access for a group and matches every row. sudo() drops it | |
| and nothing changes, so a model whose only rules are those is no different | |
| from a model with none. | |
| Measured on OpenSPP, that distinction is most of this trigger's value: of | |
| the 30 `.sudo().search(` findings sitting on a model with any rule at all, | |
| 10 were on spp.vocabulary.code, whose single rule is exactly that. | |
| The ref is turned into a dotted name forwards -- `model_spp_area` becomes | |
| spp.area -- which is ambiguous for a model whose own name contains an | |
| underscore. That is the same ambiguity _models_granted_unlink avoids by | |
| matching forwards from declared models, and it is not avoidable here | |
| because the rule names the model and nothing else does. | |
| A rule this fails to read therefore reads as "no rule", and for a model the | |
| tree declares that SUPPRESSES a finding -- the unsafe direction, and the one | |
| thing in this trigger worth being careful about. Two things hold it down: | |
| the parse is deliberately loose about attribute order, because one OpenSPP | |
| file writes `ref=` before `name=` on all ten of its rules and a stricter | |
| pattern silently lost them; and a model whose rules are all unreadable is a | |
| model nobody can show is protected, which is a finding worth making anyway. | |
| """ | |
| found = {} | |
| for block in _IR_RULE_RE.findall(body): | |
| ref = _RULE_MODEL_RE.search(block) | |
| if not ref: | |
| continue | |
| token = ref.group(1).rsplit(".", 1)[-1] | |
| if not token.startswith("model_"): | |
| continue | |
| domain = _RULE_DOMAIN_RE.search(block) | |
| text = " ".join(domain.group(1).split()) if domain else "" | |
| if text and _VACUOUS_DOMAIN.match(text): | |
| continue | |
| model = token[len("model_"):].replace("_", ".") | |
| fields = found.setdefault(model, set()) | |
| for path in _RULE_FIELD_RE.findall(text): | |
| # Both the path and the relation it starts from. A rule reading | |
| # `case_id.case_worker_id` is satisfied by a create that names | |
| # `case_id`, because the case it points at is one the caller | |
| # can already see and the rule follows the link to find out. | |
| fields.add(path) | |
| fields.add(path.split(".", 1)[0]) | |
| return found | |
| def _rules_one_by_one(body): | |
| """{model: [(is_global, fields)]}, one entry per filtering rule. | |
| _models_with_a_filtering_rule merges every rule's fields per model, which | |
| answers "is this model ruled" and cannot answer "did this call re-apply the | |
| rules". Odoo ANDs global rules -- the ones with no groups -- for everybody, | |
| and applies group rules per audience. product.template carries both: a | |
| global company rule, and a website rule for public and portal users only. | |
| A sudo() search naming website_published re-applied the second and not the | |
| first, and the merged set read that as re-scoped. | |
| """ | |
| found = {} | |
| for block in _IR_RULE_RE.findall(body): | |
| ref = _RULE_MODEL_RE.search(block) | |
| if not ref: | |
| continue | |
| token = ref.group(1).rsplit(".", 1)[-1] | |
| if not token.startswith("model_"): | |
| continue | |
| domain = _RULE_DOMAIN_RE.search(block) | |
| text = " ".join(domain.group(1).split()) if domain else "" | |
| if text and _VACUOUS_DOMAIN.match(text): | |
| continue | |
| fields = set() | |
| for path in _RULE_FIELD_RE.findall(text): | |
| fields.add(path) | |
| fields.add(path.split(".", 1)[0]) | |
| is_global = not re.search(r"name\s*=\s*[\"']groups[\"']", block) | |
| found.setdefault(token[len("model_"):].replace("_", "."), []).append( | |
| (is_global, tuple(sorted(fields)))) | |
| return found | |
| def model_rule_sets(facts, model): | |
| """This model's filtering rules one by one, as (is_global, fields). | |
| Empty when the tree declares no readable rule for it, which leaves callers | |
| on their fallback. | |
| """ | |
| if not facts or not facts.get("walked") or not model: | |
| return () | |
| return tuple(facts.get("models_rule_sets", {}).get(model, ())) | |
| def model_is_record_ruled(facts, model): | |
| """Would sudo() on this model actually drop a filter? | |
| PRESENT if the tree declares an ir.rule for it that filters. ABSENT if a | |
| whole tree was walked, the tree DECLARES the model, and no filtering rule | |
| came with it. UNKNOWN otherwise. | |
| The rule this backs says sudo "drops ir.rule filtering". Where there is no | |
| filtering to drop, the finding describes something that cannot happen, and | |
| on OpenSPP that was 83 of 106 findings. | |
| The model must be declared here for ABSENT to mean anything, and getting | |
| that wrong is measurable rather than theoretical. A walk of an addons tree | |
| sees the addons' rules and not Odoo's own: product.template carries core's | |
| company rule, which lives in the `product` module and not in the tree being | |
| read. Answering ABSENT from that silence dropped three true positives in | |
| spp_api_v2_products on the first run of this trigger -- a sudo lookup of | |
| every company's products, which is exactly the leak the rule is about. | |
| So silence about a model nobody declared is UNKNOWN, and the finding | |
| stands. The tree can say "we declare this and give it no rule". It cannot | |
| say anything about a model it never saw. | |
| """ | |
| if not facts or not facts.get("walked") or not model: | |
| return UNKNOWN | |
| if model in facts.get("models_record_ruled", ()): | |
| return PRESENT | |
| if not facts.get("complete"): | |
| return UNKNOWN | |
| return ABSENT if model in facts.get("models_declared", ()) else UNKNOWN | |
| def model_rule_fields(facts, model): | |
| """The fields this model's own filtering rules are written about. | |
| `[('case_worker_id', '=', user.id)]` yields `case_worker_id`, and | |
| `[('case_id.case_worker_id', '=', user.id)]` yields both the path and | |
| `case_id`, the relation it is reached through. | |
| This exists so a call under sudo can be compared against the rule it | |
| would actually bypass, instead of against the word `company_id`. A | |
| per-user rule is as real as a multi-company one -- OpenSPP's case models | |
| are governed entirely by `case_worker_id` and `case_id.case_worker_id`, | |
| and not one of them has a company rule at all -- so a checker that knows | |
| only the multi-company spelling cannot see when the advice was followed. | |
| Empty for a model nobody declared a readable rule for, which offers no | |
| exemption and leaves the finding standing. | |
| """ | |
| if not facts or not facts.get("walked") or not model: | |
| return () | |
| return tuple(facts.get("models_rule_fields", {}).get(model, ())) | |
| def _models_granted_unlink(body): | |
| """The `model_id:id` tokens an ir.model.access.csv grants perm_unlink on.""" | |
| rows = list(csv.reader(io.StringIO(body))) | |
| if not rows: | |
| return set() | |
| header = [cell.strip() for cell in rows[0]] | |
| try: | |
| model_at = header.index("model_id:id") | |
| unlink_at = header.index("perm_unlink") | |
| except ValueError: | |
| return set() | |
| granted = set() | |
| for row in rows[1:]: | |
| if len(row) <= max(model_at, unlink_at): | |
| continue | |
| if row[unlink_at].strip() not in _PERM_UNLINK_TRUE: | |
| continue | |
| # Rows carry either `model_x_y` or `module.model_x_y`. | |
| token = row[model_at].strip().split(".")[-1] | |
| if token: | |
| granted.add(token) | |
| return granted | |
| def module_facts(path, stack, addons=()): | |
| """Tree facts for the Odoo module a single file sits in, or nothing. | |
| `nexa check` is handed one path, and the tree that would answer its triggers | |
| is on disk around it. Walking the whole repository for one file would cost | |
| more than the review; walking the MODULE is bounded, and a module is the unit | |
| Odoo itself ships and installs. | |
| The modules its manifest depends on, transitively, can be read as well, | |
| beside it or in `addons`, for their ORM declarations alone | |
| (_dependency_sources). Each is installed whenever this one is, and the | |
| depends check needs them: Odoo merges a compute's @api.depends from their | |
| definitions and triggers through their fields (ARCHITECTURE 3.84). They | |
| are read only when the depends check asks (with_dependencies), which it | |
| does for a read the module's own declarations leave uncovered: most files | |
| never have one, and reading them cost seconds (ARCHITECTURE 3.86). | |
| Marked incomplete, so nothing it did not read can drop a finding. What it | |
| read can promote one, and a declaration it read can answer the depends | |
| check. See tree_facts for why those need different bars. | |
| """ | |
| if stack != "odoo" or not path: | |
| return no_tree() | |
| manifest = _walk_up(path, MANIFEST_NAMES) | |
| if not manifest: | |
| return no_tree() | |
| root = os.path.dirname(manifest) | |
| found = [] | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] | |
| for name in sorted(filenames): | |
| if name.endswith(_TRIGGER_EXTENSIONS): | |
| found.append(os.path.join(dirpath, name)) | |
| if len(found) > _MODULE_FILE_CAP: | |
| # A module this large is not the bounded walk this was meant to be. | |
| # Better to say nothing than to make `check` feel like a build. | |
| return no_tree() | |
| facts = for_file(tree_facts(found, complete=False), path) | |
| def wider(): | |
| sources = _dependency_sources(manifest, addons) | |
| return _with_declarations(facts, sources) if sources else None | |
| facts["with_dependencies"] = _OnFirstUse(wider) | |
| return facts | |
| class _OnFirstUse(object): | |
| """A value made the first time it is asked for, and kept for every later ask. | |
| for_file copies the facts shallowly, so every file of a module shares one, | |
| and the modules it depends on are read at most once however many ask. | |
| """ | |
| def __init__(self, make): | |
| self._make, self._made, self._value = make, False, None | |
| def __call__(self): | |
| if not self._made: | |
| self._value, self._made = self._make(), True | |
| return self._value | |
| def with_dependencies(facts): | |
| """The facts with the modules the checked module depends on read as well. | |
| None when there is nothing more to read: a fieldtest walk read the whole | |
| tree already, and a module whose dependencies are not to be found, or | |
| come to more than the cap, has nothing to add. | |
| """ | |
| more = (facts or {}).get("with_dependencies") | |
| return more() if more else None | |
| def _dependency_sources(manifest, addons=()): | |
| """The Python files of every module this manifest's module depends on, | |
| transitively, that can be found: manifests included, tests left out. | |
| A dependency is looked for beside the module first, in the same directory, | |
| where an addons path keeps its modules, then in each directory of `addons` | |
| in turn, the first one holding it winning, as Odoo's --addons-path does. | |
| One installed anywhere else is not looked for, so for a module of Odoo | |
| core `base`, which core keeps apart from the rest, is read only when it is | |
| named. Breadth first and in name order, so a walk reads the same files | |
| every run. Nothing at all when they come to more than _CLOSURE_FILE_CAP: | |
| `check` then has the facts of the module alone, which can leave a finding | |
| standing and never makes one. | |
| """ | |
| root = os.path.dirname(os.path.abspath(manifest)) | |
| places = [os.path.dirname(root)] + [os.path.abspath(a) for a in addons] | |
| seen = {os.path.basename(root)} | |
| queue = sorted(_manifest_depends(_read(manifest))) | |
| found = [] | |
| while queue: | |
| module = queue.pop(0) | |
| if module in seen or not _MODULE_NAME_RE.match(module): | |
| continue | |
| seen.add(module) | |
| declared = next((m for m in (_manifest_in(os.path.join(place, module)) | |
| for place in places) if m), None) | |
| if declared is None: | |
| continue | |
| directory = os.path.dirname(declared) | |
| queue.extend(sorted(_manifest_depends(_read(declared)))) | |
| for dirpath, dirnames, filenames in os.walk(directory): | |
| dirnames[:] = sorted(d for d in dirnames | |
| if d not in _SKIP_DIRS and d.lower() not in _TEST_DIRS) | |
| found.extend(os.path.join(dirpath, filename) for filename in sorted(filenames) | |
| if filename.endswith(".py")) | |
| if len(found) > _CLOSURE_FILE_CAP: | |
| return [] | |
| return found | |
| def _manifest_in(directory): | |
| """The manifest a module directory holds, or None when it holds none.""" | |
| for name in MANIFEST_NAMES: | |
| candidate = os.path.join(directory, name) | |
| if os.path.isfile(candidate): | |
| return candidate | |
| return None | |
| def is_demo_data(path): | |
| """Is this file listed under its module manifest's `demo` or `demo_xml`? | |
| Odoo loads demo files with noupdate forced on (odoo/modules/loading.py: | |
| "noupdate is False, unless it is demo data"), so a record in one is never | |
| rewritten by an upgrade, whatever the file itself says. A file whose | |
| manifest cannot be found or read is not demo data as far as this can tell. | |
| """ | |
| manifest = _walk_up(path, MANIFEST_NAMES) | |
| if not manifest: | |
| return False | |
| try: | |
| declared = ast.literal_eval(_read(manifest)) | |
| except (ValueError, SyntaxError, TypeError): | |
| return False | |
| if not isinstance(declared, dict): | |
| return False | |
| root = os.path.dirname(manifest) | |
| target = os.path.normcase(os.path.abspath(path)) | |
| for key in ("demo", "demo_xml"): | |
| for rel in declared.get(key) or (): | |
| if isinstance(rel, str) and os.path.normcase( | |
| os.path.abspath(os.path.join(root, rel))) == target: | |
| return True | |
| return False | |
| def parent_is_deleted(facts, model): | |
| """Does anything actually delete `model`? | |
| PRESENT the tree deletes it: an unlink call, an unlink override, or an | |
| access rule granting perm_unlink. | |
| ABSENT the tree DEFINES the model and never deletes it anywhere. Master | |
| data, as far as this codebase is concerned. | |
| UNKNOWN no tree was walked, or the model is not one this tree defines. | |
| The ABSENT bar is deliberately the strict one, and has two conditions. The | |
| model must be one this tree DEFINES: a model reached by `_inherit` belongs to | |
| core Odoo or to a module this walk never saw, so a relation to res.partner | |
| reads UNKNOWN however much of the tree was read. And the walk must have been | |
| complete, because a module-sized walk cannot see the other module that does | |
| the deleting. | |
| """ | |
| if not facts or not facts.get("walked") or not model: | |
| return UNKNOWN | |
| if model in facts["models_deleted"]: | |
| return PRESENT | |
| if model in facts["models_declared"] and facts.get("complete"): | |
| return ABSENT | |
| return UNKNOWN | |
| def multi_company_in_use(facts): | |
| """Does this codebase run more than one company? | |
| PRESENT if the tree shows the machinery -- a user in several companies, code | |
| reading the active set, an existing check_company, a company record rule. | |
| ABSENT if a tree was walked and shows none of it. UNKNOWN if none was. | |
| """ | |
| if not facts or not facts.get("walked"): | |
| return UNKNOWN | |
| if facts["multi_company"]: | |
| return PRESENT | |
| return ABSENT if facts.get("complete") else UNKNOWN | |
| def describe(ctx): | |
| """One line per known fact, for the prompt and for the evidence bundle.""" | |
| lines = [] | |
| if ctx.get("module"): | |
| lines.append("Odoo module: {0}".format(ctx["module"])) | |
| if ctx.get("odoo_series"): | |
| lines.append("Odoo series: {0}".format(ctx["odoo_series"])) | |
| if ctx.get("models"): | |
| lines.append("Models in this file: {0}".format(", ".join(ctx["models"]))) | |
| if ctx.get("odoo_imports"): | |
| lines.append("Already imported from odoo: {0}".format(", ".join(ctx["odoo_imports"]))) | |
| if ctx.get("is_test"): | |
| lines.append("This file is a test, so throwing on bad input is acceptable here.") | |
| return lines | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) > 1: | |
| print(json.dumps(from_file(sys.argv[1]), indent=2)) | |
| else: | |
| print(__doc__) | |