#!/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 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"]*\bmodel=[\"']ir\.rule[\"'][^>]*>(.*?)", re.S) _RULE_MODEL_RE = re.compile( r"]*\bname=[\"']model_id[\"'])[^>]*\bref=[\"']([\w.]+)[\"']") _RULE_DOMAIN_RE = re.compile( r"]*\bname=[\"']domain_force[\"'][^>]*>(.*?)", 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 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__)