Download tests/test_context.py from DBax127/nexa: direct link, hf CLI and curl.
- Browser
- Download file 31.5 kB
-
https://huggingface.co/DBax127/nexa/resolve/main/tests/test_context.py
- Command line
-
hf download hf://DBax127/nexa/tests/test_context.py
-
curl -L -o test_context.py https://huggingface.co/DBax127/nexa/resolve/main/tests/test_context.py
31.5 kB
| """What the surroundings of a file say, without asking a model to guess. | |
| This is the layer that used to return PASS having checked nothing, so it is | |
| worth knowing exactly what it can and cannot establish from a real directory | |
| tree rather than from a hand-built dict. | |
| """ | |
| import json | |
| import pytest | |
| import context | |
| def module(tmp_path, version="18.0.1.0.0", manifest=True): | |
| """A minimal Odoo addon: manifest at the root, source a directory down.""" | |
| root = tmp_path / "addons" / "my_module" | |
| (root / "models").mkdir(parents=True) | |
| if manifest: | |
| (root / "__manifest__.py").write_text( | |
| "{'name': 'My Module', 'version': '%s'}" % version, encoding="utf-8") | |
| target = root / "models" / "sale_order_line.py" | |
| target.write_text("from odoo import api, models\n", encoding="utf-8") | |
| return target | |
| def test_the_reviewable_extensions_are_the_ones_the_corpus_has_rules_for(tmp_path): | |
| """.py holds every rule but one; .xml holds the noupdate rule, whose | |
| violation is a <record> in a data file. Both answer with the one stack.""" | |
| for name in ("a.py", "views.xml"): | |
| assert context.stack_for(str(tmp_path / name)) == "odoo" | |
| def test_a_file_in_another_language_is_not_routed_anywhere(tmp_path): | |
| """None, not a stack. Running 27 Odoo rules over a .ts file produces an | |
| empty finding list, and an empty finding list reads as a clean bill of | |
| health -- so the router refuses instead, and subject.route raises.""" | |
| for name in ("a.ts", "a.tsx", "a.mts", "a.js", "a.jsx", "a.mjs", | |
| "a.go", "a.unknown"): | |
| assert context.stack_for(str(tmp_path / name)) is None | |
| def test_the_caller_that_read_the_file_can_say_which_stack(tmp_path): | |
| ctx = context.from_file(str(module(tmp_path)), stack="odoo") | |
| assert ctx["stack"] == "odoo" | |
| assert ctx["odoo_series"] == "18.0", "and the odoo facts are still gathered" | |
| def test_manifest_is_found_by_walking_up(tmp_path): | |
| """The manifest sits two directories above the file being checked.""" | |
| ctx = context.from_file(str(module(tmp_path))) | |
| assert ctx["source"] == "file" | |
| assert ctx["odoo_series"] == "18.0" | |
| assert ctx["manifest"].endswith("__manifest__.py") | |
| def test_no_manifest_leaves_the_series_unknown(tmp_path): | |
| """A plain Python file in no addon at all. The series is not guessed, and | |
| the absence is what `nexa check` now warns about.""" | |
| ctx = context.from_file(str(module(tmp_path, manifest=False))) | |
| assert ctx["odoo_series"] is None | |
| assert not ctx["manifest"] | |
| def test_manifest_without_a_version_is_not_a_series(tmp_path): | |
| root = tmp_path / "addons" / "m" | |
| root.mkdir(parents=True) | |
| (root / "__manifest__.py").write_text("{'name': 'M'}", encoding="utf-8") | |
| target = root / "x.py" | |
| target.write_text("x = 1", encoding="utf-8") | |
| ctx = context.from_file(str(target)) | |
| assert ctx["odoo_series"] is None | |
| def test_series_is_the_first_two_components(tmp_path, version, series): | |
| ctx = context.from_file(str(module(tmp_path, version=version))) | |
| assert ctx["odoo_series"] == series | |
| def test_empty_context_declares_that_it_has_no_file(): | |
| ctx = context.empty("odoo") | |
| assert ctx["source"] == "none" | |
| assert ctx["stack"] == "odoo" | |
| assert ctx["odoo_series"] is None | |
| def test_describe_reports_only_what_was_established(tmp_path): | |
| facts = context.describe(context.from_file(str(module(tmp_path)))) | |
| assert any("18.0" in f for f in facts) | |
| assert context.describe(context.empty("odoo")) == [] | |
| def test_unreadable_file_does_not_raise(tmp_path): | |
| """from_file runs before anything else in `nexa check`; an exception here | |
| would take down the command rather than report a gap.""" | |
| ctx = context.from_file(str(tmp_path / "does_not_exist.py")) | |
| assert ctx["stack"] == "odoo" | |
| assert ctx["odoo_series"] is None | |
| # -------------------------------------------------------------- tree triggers | |
| MODEL_WITH_A_PARENT = """from odoo import fields, models | |
| class Thing(models.Model): | |
| _name = 'my.thing' | |
| parent_id = fields.Many2one('my.parent') | |
| def label(self): | |
| return self.parent_id.name | |
| """ | |
| PARENT_DECLARED = """from odoo import models | |
| class Parent(models.Model): | |
| _name = 'my.parent' | |
| """ | |
| PARENT_DELETED = PARENT_DECLARED + """ | |
| class Cleaner(models.Model): | |
| _name = 'my.cleaner' | |
| def purge(self): | |
| self.env['my.parent'].search([]).unlink() | |
| """ | |
| def tree(tmp_path, files): | |
| root = tmp_path / "addons" / "mod" | |
| root.mkdir(parents=True) | |
| (root / "__manifest__.py").write_text("{'name': 'Mod'}", encoding="utf-8") | |
| for name, body in files.items(): | |
| path = root / name | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(body, encoding="utf-8") | |
| return root | |
| def paths_under(root): | |
| import os | |
| for dirpath, _dirnames, filenames in os.walk(str(root)): | |
| for name in sorted(filenames): | |
| yield os.path.join(dirpath, name) | |
| def test_a_model_nobody_deletes_reads_absent(tmp_path): | |
| """The trigger the ondelete rule is conditional on. The tree defines the | |
| parent and never deletes it, so there is no dangling read to have.""" | |
| root = tree(tmp_path, {"thing.py": MODEL_WITH_A_PARENT, | |
| "parent.py": PARENT_DECLARED}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.parent_is_deleted(facts, "my.parent") == context.ABSENT | |
| def test_a_model_the_tree_unlinks_reads_present(tmp_path): | |
| root = tree(tmp_path, {"thing.py": MODEL_WITH_A_PARENT, | |
| "parent.py": PARENT_DELETED}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.parent_is_deleted(facts, "my.parent") == context.PRESENT | |
| def test_an_access_rule_granting_unlink_counts_as_a_delete(tmp_path): | |
| """Nothing in the Python deletes it, but somebody can through the UI. That | |
| is the half of the question reading Python can never answer.""" | |
| csv = ("id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create," | |
| "perm_unlink\naccess_p,p,model_my_parent,base.group_user,1,1,1,1\n") | |
| root = tree(tmp_path, {"thing.py": MODEL_WITH_A_PARENT, | |
| "parent.py": PARENT_DECLARED, | |
| "security/ir.model.access.csv": csv}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.parent_is_deleted(facts, "my.parent") == context.PRESENT | |
| def test_an_access_rule_refusing_unlink_leaves_it_absent(tmp_path): | |
| csv = ("id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create," | |
| "perm_unlink\naccess_p,p,model_my_parent,base.group_user,1,1,1,0\n") | |
| root = tree(tmp_path, {"thing.py": MODEL_WITH_A_PARENT, | |
| "parent.py": PARENT_DECLARED, | |
| "security/ir.model.access.csv": csv}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.parent_is_deleted(facts, "my.parent") == context.ABSENT | |
| def test_a_foreign_model_reads_unknown_however_much_was_walked(tmp_path): | |
| """res.partner is core Odoo's. The delete could be anywhere in a codebase | |
| this walk never saw, so the honest answer is not 'absent'.""" | |
| root = tree(tmp_path, {"thing.py": MODEL_WITH_A_PARENT}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.parent_is_deleted(facts, "res.partner") == context.UNKNOWN | |
| def test_an_incomplete_walk_can_promote_but_never_suppress(tmp_path): | |
| """A module-sized walk cannot see the other module doing the deleting, so | |
| it is allowed to raise a finding and never to drop one.""" | |
| root = tree(tmp_path, {"thing.py": MODEL_WITH_A_PARENT, | |
| "parent.py": PARENT_DECLARED}) | |
| partial = context.tree_facts(paths_under(root), complete=False) | |
| assert context.parent_is_deleted(partial, "my.parent") == context.UNKNOWN | |
| root2 = tree(tmp_path / "other", {"parent.py": PARENT_DELETED}) | |
| promoting = context.tree_facts(paths_under(root2), complete=False) | |
| assert context.parent_is_deleted(promoting, "my.parent") == context.PRESENT | |
| def test_no_tree_answers_unknown_to_everything(): | |
| """`nexa check` on a bare path. Nothing was walked, so nothing is claimed.""" | |
| facts = context.no_tree() | |
| assert context.parent_is_deleted(facts, "my.parent") == context.UNKNOWN | |
| assert context.multi_company_in_use(facts) == context.UNKNOWN | |
| assert context.parent_is_deleted(None, "my.parent") == context.UNKNOWN | |
| def test_multi_company_needs_more_than_a_company_id(tmp_path): | |
| """Nearly every Odoo model declares a company_id, in single- and | |
| multi-company deployments alike, so it separates nothing.""" | |
| single = tree(tmp_path, {"a.py": "company_id = fields.Many2one('res.company')"}) | |
| assert context.multi_company_in_use( | |
| context.tree_facts(paths_under(single))) == context.ABSENT | |
| several = tree(tmp_path / "multi", | |
| {"a.py": "company_ids = fields.Many2many('res.company')"}) | |
| assert context.multi_company_in_use( | |
| context.tree_facts(paths_under(several))) == context.PRESENT | |
| def test_a_company_record_rule_in_xml_counts(tmp_path): | |
| """Multi-company record rules live in XML and nowhere else.""" | |
| xml = ("<odoo><record id='r' model='ir.rule'><field name='domain_force'>" | |
| "[('company_id','in',company_ids)]</field></record></odoo>") | |
| root = tree(tmp_path, {"a.py": "x = 1", "security/rules.xml": xml}) | |
| assert context.multi_company_in_use( | |
| context.tree_facts(paths_under(root))) == context.PRESENT | |
| def test_module_facts_are_marked_incomplete(tmp_path): | |
| """So they can promote and never suppress. The flag is the whole safety | |
| property of walking one module instead of the tree.""" | |
| root = tree(tmp_path, {"models/thing.py": MODEL_WITH_A_PARENT, | |
| "models/parent.py": PARENT_DECLARED}) | |
| facts = context.module_facts(str(root / "models" / "thing.py"), "odoo") | |
| assert facts["walked"] and not facts["complete"] | |
| assert context.parent_is_deleted(facts, "my.parent") == context.UNKNOWN | |
| def test_module_facts_decline_a_file_with_no_module(tmp_path): | |
| loose = tmp_path / "loose.py" | |
| loose.write_text(MODEL_WITH_A_PARENT, encoding="utf-8") | |
| assert context.module_facts(str(loose), "odoo")["walked"] is False | |
| assert context.module_facts(None, "odoo")["walked"] is False | |
| def test_module_facts_are_odoo_only(tmp_path): | |
| """A TypeScript file has no ir.model.access.csv above it and no model to | |
| ask about, and walking its tree would cost the review nothing but time.""" | |
| root = tree(tmp_path, {"models/thing.py": MODEL_WITH_A_PARENT}) | |
| target = str(root / "models" / "thing.py") | |
| assert context.module_facts(target, "typescript")["walked"] is False | |
| def test_demo_data_is_read_off_the_manifest(tmp_path): | |
| """Odoo loads a manifest's demo files with noupdate forced on, so the one | |
| rule that reads XML has nothing to say about them.""" | |
| module = tmp_path / "my_module" | |
| (module / "data").mkdir(parents=True) | |
| (module / "__manifest__.py").write_text( | |
| "{'name': 'x', 'data': ['data/params.xml'], 'demo': ['data/demo.xml']}", | |
| encoding="utf-8") | |
| for name in ("params.xml", "demo.xml"): | |
| (module / "data" / name).write_text("<odoo/>", encoding="utf-8") | |
| assert context.is_demo_data(str(module / "data" / "demo.xml")) | |
| assert not context.is_demo_data(str(module / "data" / "params.xml")) | |
| assert not context.is_demo_data(str(tmp_path / "loose.xml")) | |
| # product.template's shape in odoo core: a global company rule in `product`, | |
| # and a visibility rule on website_published that only portal and public users | |
| # get, in `website_sale`. | |
| TWO_RULES = ( | |
| "<odoo>" | |
| "<record id='comp' model='ir.rule'>" | |
| "<field name='model_id' ref='model_product_template'/>" | |
| "<field name='domain_force'>['|', ('company_id', 'parent_of', company_ids)," | |
| " ('company_id', '=', False)]</field></record>" | |
| "<record id='public' model='ir.rule'>" | |
| "<field name='model_id' ref='product.model_product_template'/>" | |
| "<field name='domain_force'>[('website_published', '=', True)]</field>" | |
| "<field name='groups' eval=\"[(4, ref('base.group_public'))]\"/></record>" | |
| "</odoo>") | |
| TEMPLATE_DECLARED = ("from odoo import models\n\n\n" | |
| "class Template(models.Model):\n" | |
| " _name = 'product.template'\n") | |
| def test_rules_are_kept_one_by_one_with_their_audience(tmp_path): | |
| """The merged field set answers 'is this model ruled'. It cannot answer | |
| 'did this call re-apply the rules', which needs to know which rule every | |
| user gets and which only some do.""" | |
| root = tree(tmp_path, {"models/t.py": TEMPLATE_DECLARED, | |
| "security/rules.xml": TWO_RULES}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert sorted(context.model_rule_sets(facts, "product.template")) == [ | |
| (False, ("website_published",)), (True, ("company_id",))] | |
| assert context.model_rule_sets(context.no_tree(), "product.template") == () | |
| def _sudo_search(domain): | |
| return ("from odoo import models\n\n\n" | |
| "class Template(models.Model):\n" | |
| " _inherit = 'product.template'\n\n" | |
| " def swap(self):\n" | |
| " return self.sudo().search(" + domain + ", limit=1)\n") | |
| def test_naming_one_rule_of_two_does_not_re_scope_a_sudo_call(tmp_path): | |
| """website_sale's set_sequence_up: the call names website_published, the | |
| portal rule's field, and the global company rule is dropped all the same.""" | |
| import semantics | |
| root = tree(tmp_path, {"models/t.py": TEMPLATE_DECLARED, | |
| "security/rules.xml": TWO_RULES}) | |
| facts = context.tree_facts(paths_under(root)) | |
| only_one = _sudo_search("[('website_published', '=', True)]") | |
| assert semantics.run_check("sudo_drops_record_rules", only_one, facts=facts) | |
| both = _sudo_search("[('website_published', '=', True)," | |
| " ('company_id', 'in', self.env.companies.ids)]") | |
| assert semantics.run_check("sudo_drops_record_rules", both, facts=facts) == [] | |
| def test_with_no_global_rule_any_rule_field_still_re_scopes(tmp_path): | |
| """Group rules are ORed per audience, so the old test stands where every | |
| rule the model has belongs to a group.""" | |
| import semantics | |
| group_only = TWO_RULES.split("<record id='public'")[0].replace( | |
| "</record>", "<field name='groups' eval=\"[(4, ref('base.group_user'))]\"/>" | |
| "</record>") + "</odoo>" | |
| root = tree(tmp_path, {"models/t.py": TEMPLATE_DECLARED, | |
| "security/rules.xml": group_only}) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.model_rule_sets(facts, "product.template") == ( | |
| (False, ("company_id",)),) | |
| body = _sudo_search("[('company_id', 'in', self.env.companies.ids)]") | |
| assert semantics.run_check("sudo_drops_record_rules", body, facts=facts) == [] | |
| # ------------------------------------------- what the depends check reads off a tree | |
| def odoo_modules(tmp_path, spec): | |
| """{module: (its depends, {relative path: source})}, each under its own manifest.""" | |
| for module, (depends, files) in spec.items(): | |
| root = tmp_path / "addons" / module | |
| root.mkdir(parents=True) | |
| (root / "__manifest__.py").write_text( | |
| "{'name': %r, 'depends': %r}" % (module, list(depends)), encoding="utf-8") | |
| for name, body in files.items(): | |
| path = root / name | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(body, encoding="utf-8") | |
| return tmp_path / "addons" | |
| SHOP = """from odoo import api, fields, models | |
| class Order(models.Model): | |
| _name = 'shop.order' | |
| line_ids = fields.One2many('shop.line', 'order_id') | |
| discount = fields.Float() | |
| total = fields.Float(compute='_compute_total'{store}) | |
| @api.depends('line_ids.price') | |
| def _compute_total(self): | |
| for order in self: | |
| order.total = sum(order.line_ids.mapped('price')) | |
| class Line(models.Model): | |
| _name = 'shop.line' | |
| order_id = fields.Many2one('shop.order') | |
| price = fields.Float() | |
| tax = fields.Float() | |
| """ | |
| DISCOUNT = """from odoo import api, models | |
| class Order(models.Model): | |
| _inherit = 'shop.order' | |
| @api.depends('discount') | |
| def _compute_total(self): | |
| for order in self: | |
| order.total = sum(order.line_ids.mapped('{read}')) - order.discount | |
| """ | |
| TAX = """from odoo import api, models | |
| class Order(models.Model): | |
| _inherit = 'shop.order' | |
| @api.depends('line_ids.tax') | |
| def _compute_total(self): | |
| super()._compute_total() | |
| """ | |
| def _depends_findings(root, module, facts): | |
| import semantics | |
| path = str(root / module / "models.py") | |
| with open(path, encoding="utf-8") as fh: | |
| body = fh.read() | |
| return semantics.run_check("depends_covers_compute_reads", body, | |
| facts=context.for_file(facts, path)) | |
| def test_a_dependency_the_parent_definition_declares_is_declared(tmp_path): | |
| """Odoo merges @api.depends from every definition of the method in the | |
| MRO, so an override reading line_ids.price is covered by the parent's | |
| declaration of it. "Depends merged from sale_project" was one of §3.71's | |
| false findings, and §3.80 counted five more.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True")}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "price")}), | |
| }) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert _depends_findings(root, "shop_discount", None), "the file alone cannot tell" | |
| assert _depends_findings(root, "shop_discount", facts) == [] | |
| def test_a_sibling_module_declaring_it_does_not_count(tmp_path): | |
| """shop_tax overrides the same compute and declares line_ids.tax, but | |
| shop_discount does not depend on it: installed without it, nothing | |
| recomputes the total when a line's tax changes.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True")}), | |
| "shop_tax": (("shop",), {"models.py": TAX}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "tax")}), | |
| }) | |
| facts = context.tree_facts(paths_under(root)) | |
| found = _depends_findings(root, "shop_discount", facts) | |
| assert any("'line_ids.tax'" in message for _, message, _ in found) | |
| def test_a_compute_every_declaration_leaves_unstored_is_outside_the_rule(tmp_path): | |
| """The rule is about stored computes. pos_sale's overrides of sale's | |
| amount_to_invoice were two of §3.78's false findings: sale declares the | |
| field without store=True, which the file holding the override cannot see.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", "")}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "tax")}), | |
| }) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert _depends_findings(root, "shop_discount", facts) == [] | |
| partial = context.tree_facts(paths_under(root), complete=False) | |
| assert _depends_findings(root, "shop_discount", partial), \ | |
| "a walk that is not the whole codebase cannot say nothing stores it" | |
| def test_one_declaration_storing_the_field_keeps_it_stored(tmp_path): | |
| stored = DISCOUNT.replace("{read}", "tax").replace( | |
| " @api.depends", " total = fields.Float(store=True)\n\n @api.depends").replace( | |
| "from odoo import api, models", "from odoo import api, fields, models") | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", "")}), | |
| "shop_discount": (("shop",), {"models.py": stored}), | |
| }) | |
| facts = context.tree_facts(paths_under(root)) | |
| found = _depends_findings(root, "shop_discount", facts) | |
| assert any("'line_ids.tax'" in message for _, message, _ in found) | |
| TASKS = """from odoo import api, fields, models | |
| class Task(models.Model): | |
| _name = 'my.task' | |
| parent_id = fields.Many2one('my.task') | |
| partner_id = fields.Many2one('my.partner') | |
| project_id = fields.Many2one('my.project', compute='_compute_project_id', store=True) | |
| has_parent_project = fields.Boolean(compute='_compute_has_parent_project', store=True) | |
| is_top_partner = fields.Boolean(compute='_compute_is_top_partner', store=True) | |
| @api.depends('parent_id.project_id') | |
| def _compute_project_id(self): | |
| for task in self: | |
| task.project_id = task.parent_id.project_id | |
| @api.depends('project_id') | |
| def _compute_has_parent_project(self): | |
| for task in self: | |
| task.has_parent_project = bool(task.parent_id.project_id) | |
| @api.depends('partner_id.commercial_partner_id') | |
| def _compute_is_top_partner(self): | |
| for task in self: | |
| task.is_top_partner = not task.partner_id.parent_id | |
| class Partner(models.Model): | |
| _name = 'my.partner' | |
| parent_id = fields.Many2one('my.partner') | |
| commercial_partner_id = fields.Many2one( | |
| 'my.partner', compute='_compute_commercial_partner', store=True) | |
| @api.depends('parent_id.commercial_partner_id') | |
| def _compute_commercial_partner(self): | |
| for partner in self: | |
| partner.commercial_partner_id = partner.parent_id.commercial_partner_id or partner | |
| """ | |
| def test_a_declared_computed_field_covers_what_it_depends_on(tmp_path): | |
| """Odoo's triggers are transitive. A task's project follows its parent's, | |
| so a compute declaring project_id already re-runs when the parent's project | |
| changes, and its read of parent_id.project_id is covered. So is a read | |
| through a relation to what the declared path's last field depends on. | |
| §3.80 counted 11 of its 76 false findings on core as this.""" | |
| root = odoo_modules(tmp_path, {"projects": ((), {"models.py": TASKS})}) | |
| facts = context.tree_facts(paths_under(root)) | |
| alone = _depends_findings(root, "projects", None) | |
| assert {m.split("'")[1] for _, m, _ in alone} == {"parent_id", "parent_id.project_id", | |
| "partner_id.parent_id"} | |
| assert _depends_findings(root, "projects", facts) == [] | |
| def test_a_module_walk_still_merges_and_follows_what_it_holds(tmp_path): | |
| """`nexa check` walks one module and never suppresses on absence, but a | |
| definition or a dependency it can see is positive evidence.""" | |
| root = odoo_modules(tmp_path, {"projects": ((), {"models.py": TASKS})}) | |
| path = str(root / "projects" / "models.py") | |
| facts = context.module_facts(path, "odoo") | |
| assert facts.get("module") == "projects" and not facts.get("complete") | |
| assert _depends_findings(root, "projects", facts) == [] | |
| def test_the_closure_of_a_module_is_what_it_depends_on(tmp_path): | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", "")}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "tax")}), | |
| "shop_tax": (("shop",), {"models.py": TAX}), | |
| }) | |
| facts = context.tree_facts(paths_under(root)) | |
| assert context.module_closure(facts, "shop_discount") == {"shop_discount", "shop", "base"} | |
| assert context.module_closure(facts, None) is None | |
| # ----------------------------------- what `nexa check` reads beside its module | |
| def _module_alone(root, module): | |
| """The facts `check` had before it read the modules beside: the module alone.""" | |
| path = str(root / module / "models.py") | |
| return context.for_file(context.tree_facts(paths_under(root / module), complete=False), | |
| path) | |
| def test_check_reads_the_modules_beside_it_that_it_depends_on(tmp_path): | |
| """spp_cel_event's override of _compute_cel_expression reads two fields | |
| that spp_cel_domain's definition declares, and spp_cel_event depends on | |
| spp_cel_domain. fieldtest, walking the whole tree, merged the two lists; | |
| `check`, walking the one module, reported both reads (ARCHITECTURE 3.84).""" | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True")}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "price")}), | |
| }) | |
| assert _depends_findings(root, "shop_discount", _module_alone(root, "shop_discount")) | |
| facts = context.module_facts(str(root / "shop_discount" / "models.py"), "odoo") | |
| assert facts.get("module") == "shop_discount" and not facts.get("complete") | |
| assert _depends_findings(root, "shop_discount", facts) == [] | |
| def test_check_reads_only_what_the_manifest_depends_on(tmp_path): | |
| """shop_tax sits beside shop_discount and declares line_ids.tax for the same | |
| compute, but shop_discount does not depend on it, so it is not read.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True")}), | |
| "shop_tax": (("shop",), {"models.py": TAX}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "tax")}), | |
| }) | |
| facts = context.module_facts(str(root / "shop_discount" / "models.py"), "odoo") | |
| assert set(facts["modules"]) == {"shop_discount"} | |
| assert set(context.with_dependencies(facts)["modules"]) == {"shop_discount", "shop"} | |
| found = _depends_findings(root, "shop_discount", facts) | |
| assert any("'line_ids.tax'" in message for _, message, _ in found) | |
| def test_check_follows_the_closure_and_reads_declarations_alone(tmp_path): | |
| """What a dependency depends on is installed too, and a model a test | |
| declares is not. The modules beside are read for their declarations and | |
| nothing else: the files walked and the models declared stay the module's.""" | |
| import os | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True"), | |
| "tests/test_shop.py": TAX}), | |
| "shop_tax": (("shop",), {"models.py": TAX}), | |
| "shop_discount": (("shop_tax",), {"models.py": DISCOUNT.replace("{read}", "tax")}), | |
| }) | |
| manifest = str(root / "shop_discount" / "__manifest__.py") | |
| read = [os.path.relpath(p, str(root)).replace(os.sep, "/") | |
| for p in context._dependency_sources(manifest)] | |
| assert read == ["shop_tax/__manifest__.py", "shop_tax/models.py", | |
| "shop/__manifest__.py", "shop/models.py"] | |
| facts = context.module_facts(str(root / "shop_discount" / "models.py"), "odoo") | |
| assert _depends_findings(root, "shop_discount", facts) == [] | |
| wider = context.with_dependencies(facts) | |
| assert wider["files"] == 2 | |
| assert "shop.line" not in wider["models_declared"] | |
| def test_check_reads_no_dependency_past_the_cap(tmp_path, monkeypatch): | |
| """Past the cap the read would cost seconds, so `check` keeps the facts of | |
| the module alone, which can leave a finding standing and never makes one.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True")}), | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "price")}), | |
| }) | |
| monkeypatch.setattr(context, "_CLOSURE_FILE_CAP", 1) | |
| assert context._dependency_sources(str(root / "shop_discount" / "__manifest__.py")) == [] | |
| facts = context.module_facts(str(root / "shop_discount" / "models.py"), "odoo") | |
| assert _depends_findings(root, "shop_discount", facts) | |
| def test_check_looks_for_a_dependency_beside_it_and_nowhere_else(tmp_path): | |
| """A dependency installed in another directory is not looked for, and a | |
| name that is not a module name is not followed out of the directory.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop_discount": (("shop", "../elsewhere/shop"), | |
| {"models.py": DISCOUNT.replace("{read}", "price")}), | |
| }) | |
| other = tmp_path / "elsewhere" / "shop" | |
| other.mkdir(parents=True) | |
| (other / "__manifest__.py").write_text("{'name': 'shop', 'depends': []}", | |
| encoding="utf-8") | |
| (other / "models.py").write_text(SHOP.replace("{store}", ", store=True"), | |
| encoding="utf-8") | |
| assert context._dependency_sources(str(root / "shop_discount" / "__manifest__.py")) == [] | |
| def test_check_finds_a_dependency_in_an_addons_path_it_is_given(tmp_path): | |
| """OCA's modules depend on core's, which sit in another directory. Named | |
| as an addons path, as Odoo's own --addons-path names them, they are read; | |
| a module beside the checked one comes first, as the nearer copy.""" | |
| root = odoo_modules(tmp_path, { | |
| "shop_discount": (("shop",), {"models.py": DISCOUNT.replace("{read}", "price")}), | |
| }) | |
| core = tmp_path / "core" | |
| (core / "shop").mkdir(parents=True) | |
| (core / "shop" / "__manifest__.py").write_text("{'name': 'shop', 'depends': []}", | |
| encoding="utf-8") | |
| (core / "shop" / "models.py").write_text(SHOP.replace("{store}", ", store=True"), | |
| encoding="utf-8") | |
| path = str(root / "shop_discount" / "models.py") | |
| assert _depends_findings(root, "shop_discount", context.module_facts(path, "odoo")) | |
| named = context.module_facts(path, "odoo", addons=[str(core)]) | |
| assert _depends_findings(root, "shop_discount", named) == [] | |
| manifest = str(root / "shop_discount" / "__manifest__.py") | |
| assert context._dependency_sources(manifest, [str(core)])[0].startswith(str(core)) | |
| odoo_modules(tmp_path / "beside", {"shop": ((), {"models.py": SHOP})}) | |
| beside = tmp_path / "beside" / "addons" | |
| (beside / "shop_discount").mkdir() | |
| (beside / "shop_discount" / "__manifest__.py").write_text( | |
| "{'name': 'shop_discount', 'depends': ['shop']}", encoding="utf-8") | |
| nearer = context._dependency_sources(str(beside / "shop_discount" / "__manifest__.py"), | |
| [str(core)]) | |
| assert nearer and all(p.startswith(str(beside)) for p in nearer) | |
| def test_check_reads_the_dependencies_only_when_a_read_is_left(tmp_path, monkeypatch): | |
| """Most files have no read their module's own declarations leave uncovered, | |
| and reading the modules beside cost seconds on core (ARCHITECTURE 3.86). | |
| They are read once, at the first read left, and never for a file without one.""" | |
| import semantics | |
| root = odoo_modules(tmp_path, { | |
| "shop": ((), {"models.py": SHOP.replace("{store}", ", store=True")}), | |
| "shop_discount": (("shop",), { | |
| "models.py": DISCOUNT.replace("{read}", "price"), | |
| "plain.py": "from odoo import models\n\n\nclass Plain(models.Model):\n" | |
| " _inherit = 'shop.order'\n"}), | |
| }) | |
| calls = [] | |
| real = context._dependency_sources | |
| monkeypatch.setattr(context, "_dependency_sources", | |
| lambda *args: calls.append(args) or real(*args)) | |
| facts = context.module_facts(str(root / "shop_discount" / "plain.py"), "odoo") | |
| with open(str(root / "shop_discount" / "plain.py"), encoding="utf-8") as fh: | |
| assert semantics.run_check("depends_covers_compute_reads", fh.read(), | |
| facts=facts) == [] | |
| assert calls == [] | |
| path = str(root / "shop_discount" / "models.py") | |
| assert _depends_findings(root, "shop_discount", context.for_file(facts, path)) == [] | |
| assert _depends_findings(root, "shop_discount", context.for_file(facts, path)) == [] | |
| assert len(calls) == 1 | |