nexa / bin /context.py
DBax127's picture
check: dependencies are read when the depends check asks, and where named
f6ab2f2
Raw History Blame Contribute Delete
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__)