@shortlink-org/portolan 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +116 -4
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The rules a model field states, read one option at a time."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from domain import FieldDef
|
|
7
|
+
from rules import rules_of
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Module:
|
|
11
|
+
"""The slice of a Module the rules reader touches: the tree and its classes."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, tree: ast.Module):
|
|
14
|
+
self.tree = tree
|
|
15
|
+
|
|
16
|
+
def classes(self):
|
|
17
|
+
return [node for node in self.tree.body if isinstance(node, ast.ClassDef)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Model:
|
|
21
|
+
def __init__(self, node: ast.ClassDef, module: Module):
|
|
22
|
+
self.node = node
|
|
23
|
+
self.module = module
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fields(source: str):
|
|
27
|
+
"""Every field of the last class in `source`, as the extractor reads it,
|
|
28
|
+
with the integer options it resolves ahead of time."""
|
|
29
|
+
tree = ast.parse(source)
|
|
30
|
+
node = [n for n in tree.body if isinstance(n, ast.ClassDef)][-1]
|
|
31
|
+
model = Model(node, Module(tree))
|
|
32
|
+
out = {}
|
|
33
|
+
for stmt in node.body:
|
|
34
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call):
|
|
35
|
+
name = stmt.targets[0].id
|
|
36
|
+
call = stmt.value
|
|
37
|
+
kind = call.func.attr if isinstance(call.func, ast.Attribute) else call.func.id
|
|
38
|
+
integers = {}
|
|
39
|
+
for kw in call.keywords:
|
|
40
|
+
if kw.arg == "max_length" and isinstance(kw.value, ast.Constant):
|
|
41
|
+
integers["max_length"] = kw.value.value
|
|
42
|
+
out[name] = rules_of(FieldDef(name=name, kind=kind, call=call, node=stmt, integers=integers), model)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Options(unittest.TestCase):
|
|
47
|
+
def test_the_options_become_rules_in_the_order_written(self):
|
|
48
|
+
got = fields(
|
|
49
|
+
"class Invoice(models.Model):\n"
|
|
50
|
+
" number = models.CharField(max_length=32, unique=True, null=True)\n"
|
|
51
|
+
" currency = models.CharField(unique=True, max_length=3)\n"
|
|
52
|
+
)
|
|
53
|
+
self.assertEqual(got["number"], {"rules": [{"name": "max_len", "value": "32"}, {"name": "unique"}]})
|
|
54
|
+
self.assertEqual(got["currency"], {"required": True, "rules": [{"name": "unique"}, {"name": "max_len", "value": "3"}]})
|
|
55
|
+
|
|
56
|
+
def test_the_field_class_speaks_first(self):
|
|
57
|
+
got = fields(
|
|
58
|
+
"class Line(models.Model):\n"
|
|
59
|
+
" email = models.EmailField(max_length=254)\n"
|
|
60
|
+
" quantity = models.PositiveIntegerField()\n"
|
|
61
|
+
" site = models.URLField(blank=True)\n"
|
|
62
|
+
" owner = models.UUIDField(default=uuid.uuid4)\n"
|
|
63
|
+
)
|
|
64
|
+
self.assertEqual(got["email"]["rules"], [{"name": "format", "value": "email"}, {"name": "max_len", "value": "254"}])
|
|
65
|
+
self.assertEqual(got["quantity"], {"required": True, "rules": [{"name": "gte", "value": "0"}]})
|
|
66
|
+
self.assertEqual(got["site"], {"rules": [{"name": "format", "value": "uri"}]})
|
|
67
|
+
self.assertEqual(got["owner"], {"rules": [{"name": "format", "value": "uuid"}]})
|
|
68
|
+
|
|
69
|
+
def test_validators_carry_their_bound(self):
|
|
70
|
+
got = fields(
|
|
71
|
+
"class Line(models.Model):\n"
|
|
72
|
+
" rate = models.DecimalField(validators=[MinValueValidator(0), MaxValueValidator(limit_value=1)])\n"
|
|
73
|
+
" code = models.CharField(validators=[RegexValidator(r'^[A-Z]{3}$'), RegexValidator('x', inverse_match=True), validators.MinLengthValidator(2)])\n"
|
|
74
|
+
" other = models.CharField(validators=[custom(), MinValueValidator(LIMIT)])\n"
|
|
75
|
+
)
|
|
76
|
+
self.assertEqual(got["rate"]["rules"], [{"name": "gte", "value": "0"}, {"name": "lte", "value": "1"}])
|
|
77
|
+
self.assertEqual(got["code"]["rules"], [{"name": "pattern", "value": "^[A-Z]{3}$"}, {"name": "min_len", "value": "2"}])
|
|
78
|
+
self.assertEqual(got["other"], {"required": True})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Choices(unittest.TestCase):
|
|
82
|
+
def test_a_choices_class_is_read_by_its_stored_values(self):
|
|
83
|
+
got = fields(
|
|
84
|
+
"class Invoice(models.Model):\n"
|
|
85
|
+
" class Status(models.TextChoices):\n"
|
|
86
|
+
" DRAFT = 'draft', 'Draft'\n"
|
|
87
|
+
" PAID = 'paid', 'Paid'\n"
|
|
88
|
+
" status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)\n"
|
|
89
|
+
)
|
|
90
|
+
self.assertEqual(got["status"], {"rules": [{"name": "max_len", "value": "16"}, {"name": "in", "value": "draft, paid"}]})
|
|
91
|
+
|
|
92
|
+
def test_a_module_level_class_and_an_integer_choices_read_the_same(self):
|
|
93
|
+
got = fields(
|
|
94
|
+
"class Priority(models.IntegerChoices):\n"
|
|
95
|
+
" LOW = 1\n"
|
|
96
|
+
" HIGH = 2, 'High'\n"
|
|
97
|
+
"class Ticket(models.Model):\n"
|
|
98
|
+
" priority = models.IntegerField(choices=Priority.choices)\n"
|
|
99
|
+
)
|
|
100
|
+
self.assertEqual(got["priority"], {"required": True, "rules": [{"name": "in", "value": "1, 2"}]})
|
|
101
|
+
|
|
102
|
+
def test_literal_lists_bare_values_and_groups(self):
|
|
103
|
+
got = fields(
|
|
104
|
+
"SIZES = [('s', 'Small'), ('m', 'Medium')]\n"
|
|
105
|
+
"class Shirt(models.Model):\n"
|
|
106
|
+
" size = models.CharField(choices=SIZES)\n"
|
|
107
|
+
" fit = models.CharField(choices=['slim', 'regular'])\n"
|
|
108
|
+
" colour = models.CharField(choices=[('Warm', [('red', 'Red')]), ('Cold', [('blue', 'Blue')])])\n"
|
|
109
|
+
" made = models.CharField(choices=load_choices())\n"
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(got["size"]["rules"], [{"name": "in", "value": "s, m"}])
|
|
112
|
+
self.assertEqual(got["fit"]["rules"], [{"name": "in", "value": "slim, regular"}])
|
|
113
|
+
self.assertEqual(got["colour"]["rules"], [{"name": "in", "value": "red, blue"}])
|
|
114
|
+
self.assertEqual(got["made"], {"required": True})
|
|
115
|
+
|
|
116
|
+
def test_a_pair_naming_a_member_reads_its_value(self):
|
|
117
|
+
got = fields(
|
|
118
|
+
"class Kind(models.TextChoices):\n"
|
|
119
|
+
" ORDER = 'order', 'Order'\n"
|
|
120
|
+
"class Invoice(models.Model):\n"
|
|
121
|
+
" kind = models.CharField(choices=[(Kind.ORDER, 'An order')])\n"
|
|
122
|
+
)
|
|
123
|
+
self.assertEqual(got["kind"]["rules"], [{"name": "in", "value": "order"}])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Required(unittest.TestCase):
|
|
127
|
+
def test_the_model_fills_or_excuses_the_value(self):
|
|
128
|
+
got = fields(
|
|
129
|
+
"class Invoice(models.Model):\n"
|
|
130
|
+
" id = models.BigAutoField(primary_key=True)\n"
|
|
131
|
+
" order_id = models.UUIDField()\n"
|
|
132
|
+
" number = models.CharField(max_length=32, null=True)\n"
|
|
133
|
+
" note = models.TextField(blank=True)\n"
|
|
134
|
+
" created = models.DateTimeField(auto_now_add=True)\n"
|
|
135
|
+
" status = models.CharField(max_length=8, default='draft')\n"
|
|
136
|
+
" paid = models.BooleanField()\n"
|
|
137
|
+
" tags = models.ManyToManyField('Tag')\n"
|
|
138
|
+
" invoice = models.ForeignKey('Invoice', on_delete=models.CASCADE)\n"
|
|
139
|
+
)
|
|
140
|
+
required = {name: value.get("required", False) for name, value in got.items()}
|
|
141
|
+
self.assertEqual(
|
|
142
|
+
required,
|
|
143
|
+
{
|
|
144
|
+
"id": False,
|
|
145
|
+
"order_id": True,
|
|
146
|
+
"number": False,
|
|
147
|
+
"note": False,
|
|
148
|
+
"created": False,
|
|
149
|
+
"status": False,
|
|
150
|
+
"paid": False,
|
|
151
|
+
"tags": False,
|
|
152
|
+
"invoice": True,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
unittest.main()
|
|
@@ -345,7 +345,7 @@ def model_table(
|
|
|
345
345
|
columns,
|
|
346
346
|
indexes(model, name, by_field),
|
|
347
347
|
{"aggregate": aggregate.id, "block": block} if aggregate is not None else None,
|
|
348
|
-
"aggregate-root" if aggregate is not None and model is aggregate.root else "child" if aggregate is not None else "",
|
|
348
|
+
"aggregate-root" if aggregate is not None and model is aggregate.root else "child" if aggregate is not None and aggregate.root is not None else "",
|
|
349
349
|
meta_str(model, "verbose_name"),
|
|
350
350
|
)
|
|
351
351
|
|
|
@@ -11,12 +11,13 @@ from __future__ import annotations
|
|
|
11
11
|
import ast
|
|
12
12
|
import re
|
|
13
13
|
from dataclasses import dataclass, field as dc_field
|
|
14
|
-
from typing import Dict, List, Optional
|
|
14
|
+
from typing import Dict, List, Optional, Tuple
|
|
15
15
|
|
|
16
|
+
import verbs as verbs_module
|
|
16
17
|
from apps import App
|
|
17
18
|
from ids import slug
|
|
18
19
|
from routing import Route, Routes
|
|
19
|
-
from source import Module, const_str, doc, dotted, keyword, methods
|
|
20
|
+
from source import Module, Project, const_str, doc, dotted, keyword, methods
|
|
20
21
|
|
|
21
22
|
# The actions a ViewSet has without writing one.
|
|
22
23
|
ACTIONS = {
|
|
@@ -61,6 +62,10 @@ class Endpoint:
|
|
|
61
62
|
doc: str = ""
|
|
62
63
|
use_cases: List[str] = dc_field(default_factory=list)
|
|
63
64
|
path_parameters: Dict[str, str] = dc_field(default_factory=dict)
|
|
65
|
+
# Where the verb was read when the handler's name did not say it: a
|
|
66
|
+
# decorator, ``http_method_names``, a ``request.method`` branch or a
|
|
67
|
+
# project wrapper, with the file:line. Empty when the verb is unknown.
|
|
68
|
+
verb_source: str = ""
|
|
64
69
|
|
|
65
70
|
|
|
66
71
|
def basenames(app: App) -> Dict[str, str]:
|
|
@@ -95,18 +100,29 @@ def view_name(name: str) -> str:
|
|
|
95
100
|
return slug(name).replace("-", "_")
|
|
96
101
|
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
HANDLER_NAMES = ("get", "post", "put", "patch", "delete")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def verbs_of(node: ast.AST, action: str) -> Tuple[str, ...]:
|
|
107
|
+
"""The verbs a handler answers by its own declaration: every method an
|
|
108
|
+
``@action``/``@api_view`` lists, else what its conventional name means.
|
|
109
|
+
Empty for a handler whose name is the project's own."""
|
|
100
110
|
for dec in getattr(node, "decorator_list", []):
|
|
101
111
|
if isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] in ("action", "api_view"):
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
listed = verbs_module.listed_verbs(dec)
|
|
113
|
+
if listed:
|
|
114
|
+
return listed
|
|
115
|
+
if action in ACTIONS:
|
|
116
|
+
return (ACTIONS[action],)
|
|
117
|
+
return (action.upper(),) if action in HANDLER_NAMES else ()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_action(node: ast.AST) -> bool:
|
|
121
|
+
return any(isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "action" for dec in getattr(node, "decorator_list", []))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_api_view(node: ast.AST) -> bool:
|
|
125
|
+
return any(isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "api_view" for dec in getattr(node, "decorator_list", []))
|
|
110
126
|
|
|
111
127
|
|
|
112
128
|
def route_base(route: Route, fallback: str) -> str:
|
|
@@ -134,8 +150,10 @@ def action_path(route: Route, node: ast.AST, action: str) -> str:
|
|
|
134
150
|
return base + ("/{id}" if detail else "") + ("/" + suffix if action not in ACTIONS else "") + "/"
|
|
135
151
|
|
|
136
152
|
|
|
137
|
-
def endpoint(node: ast.AST, module: Module, view: str, action: str, verb: str, base: str, route: Optional[Route], description: str = "") -> Endpoint:
|
|
153
|
+
def endpoint(node: ast.AST, module: Module, view: str, action: str, verb: str, base: str, route: Optional[Route], description: str = "", suffix: str = "", verb_source: str = "") -> Endpoint:
|
|
138
154
|
ident = base if base == action or base.endswith("_" + action) else "%s_%s" % (base, action)
|
|
155
|
+
if suffix:
|
|
156
|
+
ident = "%s_%s" % (ident, suffix)
|
|
139
157
|
return Endpoint(
|
|
140
158
|
id=ident,
|
|
141
159
|
action=action,
|
|
@@ -147,11 +165,30 @@ def endpoint(node: ast.AST, module: Module, view: str, action: str, verb: str, b
|
|
|
147
165
|
route_source=route.source if route else "",
|
|
148
166
|
doc=description,
|
|
149
167
|
path_parameters=dict(route.parameters) if route else {},
|
|
168
|
+
verb_source=verb_source,
|
|
150
169
|
)
|
|
151
170
|
|
|
152
171
|
|
|
153
|
-
def
|
|
172
|
+
def unknown_verb(b, route: Route) -> None:
|
|
173
|
+
b.warn(
|
|
174
|
+
route.source,
|
|
175
|
+
"%s is mounted as an HTTP view, but no HTTP verb is declared; the route is kept with its verb unknown and no operation is inferred for it. "
|
|
176
|
+
"Declare the verb with require_http_methods, @api_view, http_method_names or a branch on request.method" % route.view,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def expand(handler: ast.AST, module: Module, view: str, action: str, verb_list: Tuple[str, ...], base: str, route: Optional[Route], description: str, verb_source: str) -> List[Endpoint]:
|
|
181
|
+
"""One endpoint per verb. The first keeps the plain id; a second verb on
|
|
182
|
+
the same handler is told apart by the verb, ``planet_fetch_post``."""
|
|
183
|
+
out = []
|
|
184
|
+
for index, verb in enumerate(verb_list):
|
|
185
|
+
out.append(endpoint(handler, module, view, action, verb, base, route, description, suffix=verb.lower() if index else "", verb_source=verb_source))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_endpoints(app: App, b, routes: Optional[Routes] = None, project: Optional[Project] = None) -> List[Endpoint]:
|
|
154
190
|
registered = basenames(app)
|
|
191
|
+
reader = verbs_module.Reader(project)
|
|
155
192
|
out: List[Endpoint] = []
|
|
156
193
|
for module in app.package("views"):
|
|
157
194
|
for node in module.classes():
|
|
@@ -165,62 +202,71 @@ def read_endpoints(app: App, b, routes: Optional[Routes] = None) -> List[Endpoin
|
|
|
165
202
|
base = view_name(node.name)
|
|
166
203
|
b.warn(module.rel, "%s is registered by no router in %s/urls.py; its endpoints are named after the class" % (node.name, app.rel))
|
|
167
204
|
handlers = {handler.name: handler for handler in methods(node)}
|
|
168
|
-
|
|
205
|
+
method_routes: Dict[str, List[Route]] = {}
|
|
206
|
+
for route in mounted:
|
|
207
|
+
if "." in route.view:
|
|
208
|
+
method_routes.setdefault(route.view.split(".", 1)[1], []).append(route)
|
|
209
|
+
# (action, verbs, handler, doc, where the verb was read)
|
|
210
|
+
declared: List[Tuple[str, Tuple[str, ...], ast.AST, str, str]] = []
|
|
169
211
|
for handler in handlers.values():
|
|
170
212
|
if handler.name.startswith("_"):
|
|
171
213
|
continue
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
declared.append((handler.name, verb_of(handler, handler.name), handler, doc(handler)))
|
|
214
|
+
verb_list = verbs_of(handler, handler.name)
|
|
215
|
+
verb_source = ""
|
|
216
|
+
if not verb_list and not is_action(handler) and handler.name not in method_routes:
|
|
217
|
+
continue
|
|
218
|
+
if not verb_list:
|
|
219
|
+
# A plain class may expose an arbitrarily named method
|
|
220
|
+
# directly in URLConf (`Planet.fetch`). The route proves
|
|
221
|
+
# the HTTP entrypoint; the verb is read off what the
|
|
222
|
+
# handler, its class or a project wrapper declares, and
|
|
223
|
+
# is left unknown - never guessed - when none of them does.
|
|
224
|
+
evidence = reader.for_handler(module, handler, node)
|
|
225
|
+
if evidence is not None:
|
|
226
|
+
verb_list, verb_source = evidence.verbs, "%s at %s" % (evidence.rule, evidence.source)
|
|
227
|
+
else:
|
|
228
|
+
verb_list = ("",)
|
|
229
|
+
declared.append((handler.name, verb_list, handler, doc(handler), verb_source))
|
|
189
230
|
inherited = []
|
|
190
231
|
for inherited_base in bases:
|
|
191
232
|
inherited += GENERIC_ACTIONS.get(inherited_base, [])
|
|
192
233
|
for action, verb in inherited:
|
|
193
|
-
same_direct_handler = mounted and all(not route.router for route in mounted) and any(item[1]
|
|
234
|
+
same_direct_handler = mounted and all(not route.router for route in mounted) and any(verb in item[1] for item in declared)
|
|
194
235
|
if action not in handlers and not any(item[0] == action for item in declared) and not same_direct_handler:
|
|
195
|
-
declared.append((action, verb, node, doc(node)))
|
|
236
|
+
declared.append((action, (verb,), node, doc(node), ""))
|
|
196
237
|
class_routes = routes.for_view(module.dotted, node.name) if routes else []
|
|
197
|
-
method_routes = {route.view.split(".", 1)[1]: route for route in mounted if "." in route.view}
|
|
198
238
|
if not class_routes and not method_routes:
|
|
199
|
-
for action,
|
|
200
|
-
out
|
|
239
|
+
for action, verb_list, handler, description, verb_source in declared:
|
|
240
|
+
out += expand(handler, module, node.name, action, verb_list, base or view_name(node.name), None, description, verb_source)
|
|
201
241
|
continue
|
|
202
|
-
for action,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
route.source,
|
|
208
|
-
"%s is mounted as an HTTP view, but no HTTP verb is declared; its flow is extracted and the route is omitted from inferred OpenAPI"
|
|
209
|
-
% route.view,
|
|
210
|
-
)
|
|
211
|
-
out.append(endpoint(handler, module, node.name, action, verb, route_base(route, base or view_name(node.name)), route, description))
|
|
242
|
+
for action, verb_list, handler, description, verb_source in declared:
|
|
243
|
+
for route in class_routes or method_routes.get(action, []):
|
|
244
|
+
if "" in verb_list:
|
|
245
|
+
unknown_verb(b, route)
|
|
246
|
+
out += expand(handler, module, node.name, action, verb_list, route_base(route, base or view_name(node.name)), route, description, verb_source)
|
|
212
247
|
for node in module.functions():
|
|
213
248
|
mounted = routes.for_view(module.dotted, node.name) if routes else []
|
|
214
|
-
if not mounted and not
|
|
249
|
+
if not mounted and not is_api_view(node):
|
|
215
250
|
continue
|
|
216
|
-
|
|
217
|
-
|
|
251
|
+
verb_list = verbs_of(node, node.name)
|
|
252
|
+
verb_source = ""
|
|
253
|
+
if not verb_list:
|
|
254
|
+
evidence = reader.for_handler(module, node)
|
|
255
|
+
if evidence is not None:
|
|
256
|
+
verb_list, verb_source = evidence.verbs, "%s at %s" % (evidence.rule, evidence.source)
|
|
257
|
+
elif mounted:
|
|
258
|
+
verb_list = ("",)
|
|
259
|
+
else:
|
|
260
|
+
continue
|
|
261
|
+
if not mounted:
|
|
262
|
+
for verb in verb_list:
|
|
263
|
+
out.append(Endpoint(id=node.name, action=node.name, view="", verb=verb, node=node, module=module, doc=doc(node), verb_source=verb_source))
|
|
218
264
|
continue
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
265
|
+
for route in mounted:
|
|
266
|
+
if "" in verb_list:
|
|
267
|
+
unknown_verb(b, route)
|
|
268
|
+
for verb in verb_list:
|
|
269
|
+
out.append(endpoint(node, module, "", verb.lower() or node.name, verb, route_base(route, node.name), route, doc(node), verb_source=verb_source))
|
|
224
270
|
unique = {}
|
|
225
271
|
for found in out:
|
|
226
272
|
unique[(found.id, found.verb, found.path, found.module.rel)] = found
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Which HTTP verb a mounted handler answers, read from what the code declares.
|
|
2
|
+
|
|
3
|
+
A URLConf can mount any callable — ``path("planet/fetch", Planet.fetch)`` —
|
|
4
|
+
and then the route table proves the path but says nothing about the verb.
|
|
5
|
+
The verb is still written down somewhere in the handler, in one of the ways
|
|
6
|
+
Django and DRF offer for it, and this module reads them in the order a
|
|
7
|
+
reviewer would trust them:
|
|
8
|
+
|
|
9
|
+
1. a decorator on the handler: DRF's ``@action(methods=…)`` and ``@api_view``,
|
|
10
|
+
Django's ``@require_http_methods([…])``, ``@require_GET``, ``@require_POST``
|
|
11
|
+
and ``@require_safe``, also through ``method_decorator(…)``;
|
|
12
|
+
2. the same decorators on the class, ``@method_decorator(…, name="dispatch")``;
|
|
13
|
+
3. the class's ``http_method_names``;
|
|
14
|
+
4. a branch on ``request.method`` in the handler body;
|
|
15
|
+
5. a project wrapper — a decorator or a function the handler hands ``request``
|
|
16
|
+
to — whose own body does one of the above, followed a bounded number of
|
|
17
|
+
levels deep.
|
|
18
|
+
|
|
19
|
+
The first tier that speaks decides. Nothing here is guessed: a handler none
|
|
20
|
+
of the tiers describes has no verb, and the caller says so rather than
|
|
21
|
+
inventing one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import List, Optional, Set, Tuple
|
|
29
|
+
|
|
30
|
+
from source import Module, Project, const_str, dotted, keyword, methods, node_name
|
|
31
|
+
|
|
32
|
+
HTTP_VERBS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE")
|
|
33
|
+
|
|
34
|
+
# What a framework answers for every route regardless of the handler; listing
|
|
35
|
+
# them never says what the handler is for, so they are dropped when any other
|
|
36
|
+
# verb is named beside them.
|
|
37
|
+
IMPLICIT = {"HEAD", "OPTIONS", "TRACE"}
|
|
38
|
+
|
|
39
|
+
SHORTHAND = {"require_GET": ("GET",), "require_POST": ("POST",), "require_safe": ("GET",)}
|
|
40
|
+
LISTED = ("require_http_methods", "action", "api_view")
|
|
41
|
+
|
|
42
|
+
# How far a project wrapper is followed before the reader stops. Two levels
|
|
43
|
+
# cover a decorator that wraps a decorator; anything deeper is a project that
|
|
44
|
+
# should declare its verb where the handler is.
|
|
45
|
+
DEPTH = 3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Evidence:
|
|
50
|
+
verbs: Tuple[str, ...]
|
|
51
|
+
rule: str # "decorator", "class decorator", "http_method_names", "request.method", "wrapper <name>"
|
|
52
|
+
source: str # file:line of what was read
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Reader:
|
|
56
|
+
def __init__(self, project: Optional[Project]):
|
|
57
|
+
self.project = project
|
|
58
|
+
|
|
59
|
+
# --- the tiers -----------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def for_handler(self, module: Module, handler: ast.AST, owner: Optional[ast.ClassDef] = None) -> Optional[Evidence]:
|
|
62
|
+
found = self.decorators(module, handler, handler_name=getattr(handler, "name", ""))
|
|
63
|
+
if found is None and owner is not None:
|
|
64
|
+
found = self.decorators(module, owner, handler_name=getattr(handler, "name", ""), rule="class decorator")
|
|
65
|
+
if found is None and owner is not None:
|
|
66
|
+
found = self.method_names(module, owner)
|
|
67
|
+
if found is None:
|
|
68
|
+
found = self.branches(module, handler)
|
|
69
|
+
if found is None:
|
|
70
|
+
found = self.wrappers(module, handler, owner, DEPTH, set())
|
|
71
|
+
return found
|
|
72
|
+
|
|
73
|
+
def decorators(self, module: Module, node: ast.AST, handler_name: str = "", rule: str = "decorator") -> Optional[Evidence]:
|
|
74
|
+
for dec in getattr(node, "decorator_list", []) or []:
|
|
75
|
+
inner = dec
|
|
76
|
+
# ``method_decorator(require_POST)`` on a method, or on the class
|
|
77
|
+
# with ``name=`` saying which method; ``dispatch`` means every one.
|
|
78
|
+
if isinstance(inner, ast.Call) and dotted(inner.func).split(".")[-1] == "method_decorator" and inner.args:
|
|
79
|
+
applies_to = const_str(keyword(inner, "name"))
|
|
80
|
+
if rule == "class decorator" and applies_to not in ("", "dispatch", handler_name):
|
|
81
|
+
continue
|
|
82
|
+
inner = inner.args[0]
|
|
83
|
+
verbs = listed_verbs(inner)
|
|
84
|
+
if verbs:
|
|
85
|
+
return Evidence(verbs, rule, module.where(dec))
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def method_names(self, module: Module, owner: ast.ClassDef) -> Optional[Evidence]:
|
|
89
|
+
for stmt in owner.body:
|
|
90
|
+
if not isinstance(stmt, (ast.Assign, ast.AnnAssign)):
|
|
91
|
+
continue
|
|
92
|
+
targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
|
|
93
|
+
if not any(isinstance(target, ast.Name) and target.id == "http_method_names" for target in targets):
|
|
94
|
+
continue
|
|
95
|
+
verbs = verbs_in(stmt.value)
|
|
96
|
+
if verbs:
|
|
97
|
+
return Evidence(verbs, "http_method_names", module.where(stmt))
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def branches(self, module: Module, node: ast.AST) -> Optional[Evidence]:
|
|
101
|
+
found: List[str] = []
|
|
102
|
+
where = ""
|
|
103
|
+
for compare in ast.walk(node):
|
|
104
|
+
if not isinstance(compare, ast.Compare):
|
|
105
|
+
continue
|
|
106
|
+
sides = [compare.left] + list(compare.comparators)
|
|
107
|
+
if not any(is_request_method(side) for side in sides):
|
|
108
|
+
continue
|
|
109
|
+
for side in sides:
|
|
110
|
+
for verb in verbs_in(side, filter_implicit=False):
|
|
111
|
+
if verb not in found:
|
|
112
|
+
found.append(verb)
|
|
113
|
+
where = where or module.where(compare)
|
|
114
|
+
verbs = normalise(found)
|
|
115
|
+
return Evidence(verbs, "request.method", where) if verbs else None
|
|
116
|
+
|
|
117
|
+
def wrappers(self, module: Module, handler: ast.AST, owner: Optional[ast.ClassDef], depth: int, seen: Set[Tuple[str, str]]) -> Optional[Evidence]:
|
|
118
|
+
if depth <= 0 or self.project is None:
|
|
119
|
+
return None
|
|
120
|
+
candidates: List[Tuple[Module, ast.AST, Optional[ast.ClassDef], str]] = []
|
|
121
|
+
for dec in getattr(handler, "decorator_list", []) or []:
|
|
122
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
123
|
+
resolved = self.callable_target(module, owner, target)
|
|
124
|
+
if resolved is not None:
|
|
125
|
+
candidates.append(resolved + (dotted(target),))
|
|
126
|
+
for call in ast.walk(handler):
|
|
127
|
+
if not isinstance(call, ast.Call) or not passes_request(call):
|
|
128
|
+
continue
|
|
129
|
+
resolved = self.callable_target(module, owner, call.func)
|
|
130
|
+
if resolved is not None:
|
|
131
|
+
candidates.append(resolved + (dotted(call.func),))
|
|
132
|
+
for target_module, target, target_owner, name in candidates:
|
|
133
|
+
key = (target_module.dotted, node_name(target) if not target_owner else target_owner.name + "." + node_name(target))
|
|
134
|
+
if key in seen:
|
|
135
|
+
continue
|
|
136
|
+
seen.add(key)
|
|
137
|
+
found = self.decorators(target_module, target) or self.calls(target_module, target) or self.branches(target_module, target)
|
|
138
|
+
if found is None:
|
|
139
|
+
found = self.wrappers(target_module, target, target_owner, depth - 1, seen)
|
|
140
|
+
if found is not None:
|
|
141
|
+
return Evidence(found.verbs, "wrapper %s" % name, found.source)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def calls(self, module: Module, node: ast.AST) -> Optional[Evidence]:
|
|
145
|
+
"""``return require_POST(view)`` inside a project decorator: the
|
|
146
|
+
framework decorator applied by hand rather than with ``@``."""
|
|
147
|
+
for call in ast.walk(node):
|
|
148
|
+
if not isinstance(call, ast.Call):
|
|
149
|
+
continue
|
|
150
|
+
for candidate in (call, call.func):
|
|
151
|
+
verbs = listed_verbs(candidate)
|
|
152
|
+
if verbs:
|
|
153
|
+
return Evidence(verbs, "decorator", module.where(call))
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# --- names ---------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def callable_target(self, module: Module, owner: Optional[ast.ClassDef], node: ast.AST) -> Optional[Tuple[Module, ast.AST, Optional[ast.ClassDef]]]:
|
|
159
|
+
"""A project function or method this name refers to; nothing for a
|
|
160
|
+
framework name, a builtin or anything the imports do not explain."""
|
|
161
|
+
name = dotted(node)
|
|
162
|
+
if not name or self.project is None:
|
|
163
|
+
return None
|
|
164
|
+
parts = name.split(".")
|
|
165
|
+
if len(parts) == 2 and parts[0] in ("self", "cls") and owner is not None:
|
|
166
|
+
for item in methods(owner):
|
|
167
|
+
if item.name == parts[1]:
|
|
168
|
+
return module, item, owner
|
|
169
|
+
return None
|
|
170
|
+
if len(parts) == 1:
|
|
171
|
+
hit = self.project.resolve(module, name)
|
|
172
|
+
if hit is None:
|
|
173
|
+
return None
|
|
174
|
+
target = function_in(hit[0], hit[1])
|
|
175
|
+
return (hit[0], target, None) if target is not None else None
|
|
176
|
+
imported = module.imports.get(parts[0])
|
|
177
|
+
if imported is None:
|
|
178
|
+
return None
|
|
179
|
+
target_module = self.project.module(".".join([imported.module] + ([] if imported.name == "*" else [imported.name]) + parts[1:-1]))
|
|
180
|
+
if target_module is None:
|
|
181
|
+
return None
|
|
182
|
+
target = function_in(target_module, parts[-1])
|
|
183
|
+
return (target_module, target, None) if target is not None else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def function_in(module: Module, name: str) -> Optional[ast.AST]:
|
|
187
|
+
for node in module.tree.body:
|
|
188
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
|
|
189
|
+
return node
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def listed_verbs(node: ast.AST) -> Tuple[str, ...]:
|
|
194
|
+
"""The verbs a framework decorator names, however it was imported:
|
|
195
|
+
``@require_http_methods(["GET", "POST"])``, ``@action(methods=["post"])``,
|
|
196
|
+
``@api_view(["GET"])``, or the ``require_POST`` shorthand."""
|
|
197
|
+
name = dotted(node.func if isinstance(node, ast.Call) else node).split(".")[-1]
|
|
198
|
+
if name in SHORTHAND:
|
|
199
|
+
return SHORTHAND[name]
|
|
200
|
+
if not isinstance(node, ast.Call) or name not in LISTED:
|
|
201
|
+
return ()
|
|
202
|
+
listed = keyword(node, "methods") or keyword(node, "request_method_list") or keyword(node, "http_method_names")
|
|
203
|
+
if listed is None and node.args:
|
|
204
|
+
listed = node.args[0]
|
|
205
|
+
if listed is None and name in ("action", "api_view"):
|
|
206
|
+
# DRF's documented default when the decorator names no methods.
|
|
207
|
+
return ("GET",)
|
|
208
|
+
return verbs_in(listed)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def verbs_in(node: Optional[ast.AST], filter_implicit: bool = True) -> Tuple[str, ...]:
|
|
212
|
+
"""The HTTP verbs a literal spells, upper-cased; anything that is not one
|
|
213
|
+
is left out, so ``["get", "frobnicate"]`` reads as GET alone."""
|
|
214
|
+
if node is None:
|
|
215
|
+
return ()
|
|
216
|
+
items = node.elts if isinstance(node, (ast.List, ast.Tuple, ast.Set)) else [node]
|
|
217
|
+
found = [const_str(item).upper() for item in items if const_str(item)]
|
|
218
|
+
return normalise(found) if filter_implicit else tuple(verb for verb in found if verb in HTTP_VERBS)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def normalise(found: List[str]) -> Tuple[str, ...]:
|
|
222
|
+
verbs = [verb for verb in found if verb in HTTP_VERBS]
|
|
223
|
+
explicit = [verb for verb in verbs if verb not in IMPLICIT]
|
|
224
|
+
out: List[str] = []
|
|
225
|
+
for verb in explicit or verbs:
|
|
226
|
+
if verb not in out:
|
|
227
|
+
out.append(verb)
|
|
228
|
+
return tuple(out)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_request_method(node: ast.AST) -> bool:
|
|
232
|
+
"""``request.method``, ``self.request.method``, or either through
|
|
233
|
+
``.upper()``/``.lower()``."""
|
|
234
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("upper", "lower") and not node.args:
|
|
235
|
+
node = node.func.value
|
|
236
|
+
return dotted(node).endswith("request.method")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def passes_request(call: ast.Call) -> bool:
|
|
240
|
+
values = list(call.args) + [kw.value for kw in call.keywords]
|
|
241
|
+
return any(dotted(value) in ("request", "self.request") for value in values)
|