@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.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. 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
- def verb_of(node: ast.AST, action: str) -> str:
99
- decorator = None
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
- decorator = dec
103
- if decorator is not None:
104
- methods_arg = keyword(decorator, "methods")
105
- if methods_arg is None and decorator.args:
106
- methods_arg = decorator.args[0]
107
- if isinstance(methods_arg, (ast.List, ast.Tuple, ast.Set)) and methods_arg.elts:
108
- return const_str(methods_arg.elts[0]).upper()
109
- return ACTIONS.get(action, "" if action not in ("get", "post", "put", "patch", "delete") else action.upper())
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 read_endpoints(app: App, b, routes: Optional[Routes] = None) -> List[Endpoint]:
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
- declared = []
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
- is_action = any(
173
- isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "action" for dec in getattr(handler, "decorator_list", [])
174
- )
175
- if handler.name not in ACTIONS and not is_action and handler.name not in ("get", "post", "put", "patch", "delete"):
176
- # A plain Django class may expose an arbitrarily named
177
- # method directly in URLConf (`Planet.fetch`). The route
178
- # proves the HTTP entrypoint but, without a method
179
- # decorator or request-method branch, not one particular
180
- # verb. Keep it as a flow root and leave it out of the
181
- # inferred OpenAPI contract.
182
- if handler.name not in {
183
- route.view.split(".", 1)[1]
184
- for route in mounted
185
- if "." in route.view
186
- }:
187
- continue
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] == verb for item in declared)
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, verb, handler, description in declared:
200
- out.append(endpoint(handler, module, node.name, action, verb, base or view_name(node.name), None, description))
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, verb, handler, description in declared:
203
- targets = class_routes or ([method_routes[action]] if action in method_routes else [])
204
- for route in targets:
205
- if not verb:
206
- b.warn(
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 any(isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "api_view" for dec in getattr(node, "decorator_list", [])):
249
+ if not mounted and not is_api_view(node):
215
250
  continue
216
- verb = verb_of(node, node.name)
217
- if not verb:
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
- if mounted:
220
- for route in mounted:
221
- out.append(endpoint(node, module, "", verb.lower(), verb, route_base(route, node.name), route, doc(node)))
222
- else:
223
- out.append(Endpoint(id=node.name, action=node.name, view="", verb=verb, node=node, module=module, doc=doc(node)))
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)