prefly 0.1.0__tar.gz

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 (173) hide show
  1. prefly-0.1.0/PKG-INFO +55 -0
  2. prefly-0.1.0/README.md +37 -0
  3. prefly-0.1.0/license.txt +21 -0
  4. prefly-0.1.0/preflight/__init__.py +1 -0
  5. prefly-0.1.0/preflight/__main__.py +3 -0
  6. prefly-0.1.0/preflight/app_scan.py +177 -0
  7. prefly-0.1.0/preflight/commands.py +176 -0
  8. prefly-0.1.0/preflight/compatibility_scanner.py +207 -0
  9. prefly-0.1.0/preflight/formatter.py +343 -0
  10. prefly-0.1.0/preflight/hooks_scanner.py +375 -0
  11. prefly-0.1.0/preflight/js_scanner.py +397 -0
  12. prefly-0.1.0/preflight/models.py +135 -0
  13. prefly-0.1.0/preflight/online_scanner.py +283 -0
  14. prefly-0.1.0/preflight/python_scanner.py +649 -0
  15. prefly-0.1.0/preflight/report.html +656 -0
  16. prefly-0.1.0/preflight/rule_loader.py +317 -0
  17. prefly-0.1.0/preflight/rules/README.md +599 -0
  18. prefly-0.1.0/preflight/rules/v16/target.yaml +3 -0
  19. prefly-0.1.0/preflight/rules/v16/v16-app-dependency-pins.yaml +102 -0
  20. prefly-0.1.0/preflight/rules/v16/v16-app-dependency-wheels.yaml +23 -0
  21. prefly-0.1.0/preflight/rules/v16/v16-app-frappe-bound.yaml +16 -0
  22. prefly-0.1.0/preflight/rules/v16/v16-app-required-apps.yaml +14 -0
  23. prefly-0.1.0/preflight/rules/v16/v16-app-requires-python.yaml +15 -0
  24. prefly-0.1.0/preflight/rules/v16/v16-app-target-branch.yaml +23 -0
  25. prefly-0.1.0/preflight/rules/v16/v16-app-workspaces-apps-screen.yaml +28 -0
  26. prefly-0.1.0/preflight/rules/v16/v16-bench-node.yaml +22 -0
  27. prefly-0.1.0/preflight/rules/v16/v16-bench-python.yaml +15 -0
  28. prefly-0.1.0/preflight/rules/v16/v16-build-xlsx-data-applied-filters.yaml +26 -0
  29. prefly-0.1.0/preflight/rules/v16/v16-build-xlsx-data-returns-three.yaml +25 -0
  30. prefly-0.1.0/preflight/rules/v16/v16-custom-module-workspace-dock.yaml +29 -0
  31. prefly-0.1.0/preflight/rules/v16/v16-custom-workspace-navigation.yaml +33 -0
  32. prefly-0.1.0/preflight/rules/v16/v16-deprecated-client-get-js.yaml +15 -0
  33. prefly-0.1.0/preflight/rules/v16/v16-deprecated-country-info-get-translated-dict.yaml +15 -0
  34. prefly-0.1.0/preflight/rules/v16/v16-deprecated-email-queue-send-mail.yaml +15 -0
  35. prefly-0.1.0/preflight/rules/v16/v16-deprecated-frappe-test-case.yaml +22 -0
  36. prefly-0.1.0/preflight/rules/v16/v16-deprecated-get-number-format-info.yaml +16 -0
  37. prefly-0.1.0/preflight/rules/v16/v16-deprecated-get-test-records.yaml +15 -0
  38. prefly-0.1.0/preflight/rules/v16/v16-deprecated-gravatar-helpers.yaml +23 -0
  39. prefly-0.1.0/preflight/rules/v16/v16-deprecated-gzip-helpers.yaml +16 -0
  40. prefly-0.1.0/preflight/rules/v16/v16-deprecated-make-esc.yaml +14 -0
  41. prefly-0.1.0/preflight/rules/v16/v16-deprecated-read-multi-pdf.yaml +15 -0
  42. prefly-0.1.0/preflight/rules/v16/v16-deprecated-show-progress.yaml +17 -0
  43. prefly-0.1.0/preflight/rules/v16/v16-deprecated-test-runner.yaml +34 -0
  44. prefly-0.1.0/preflight/rules/v16/v16-deprecated-tests-system-settings.yaml +25 -0
  45. prefly-0.1.0/preflight/rules/v16/v16-deprecated-tests-utils-helpers.yaml +28 -0
  46. prefly-0.1.0/preflight/rules/v16/v16-erpnext-advance-payment-doctypes-hook.yaml +26 -0
  47. prefly-0.1.0/preflight/rules/v16/v16-erpnext-asset-fields.yaml +55 -0
  48. prefly-0.1.0/preflight/rules/v16/v16-erpnext-asset-repair-cost.yaml +23 -0
  49. prefly-0.1.0/preflight/rules/v16/v16-erpnext-budget-fields.yaml +27 -0
  50. prefly-0.1.0/preflight/rules/v16/v16-erpnext-closing-stock-balance-doctype.yaml +22 -0
  51. prefly-0.1.0/preflight/rules/v16/v16-erpnext-closing-stock-balance.yaml +18 -0
  52. prefly-0.1.0/preflight/rules/v16/v16-erpnext-default-supplier-purchase-orders.yaml +28 -0
  53. prefly-0.1.0/preflight/rules/v16/v16-erpnext-depreciation-entry-accounts.yaml +25 -0
  54. prefly-0.1.0/preflight/rules/v16/v16-erpnext-depreciation-entry-cost-center.yaml +26 -0
  55. prefly-0.1.0/preflight/rules/v16/v16-erpnext-depreciation-entry-schedule-name.yaml +23 -0
  56. prefly-0.1.0/preflight/rules/v16/v16-erpnext-depreciation-helpers.yaml +39 -0
  57. prefly-0.1.0/preflight/rules/v16/v16-erpnext-homepage-doctypes.yaml +21 -0
  58. prefly-0.1.0/preflight/rules/v16/v16-erpnext-homepage.yaml +20 -0
  59. prefly-0.1.0/preflight/rules/v16/v16-erpnext-item-wise-tax-detail.yaml +30 -0
  60. prefly-0.1.0/preflight/rules/v16/v16-erpnext-lead-source-doctype.yaml +21 -0
  61. prefly-0.1.0/preflight/rules/v16/v16-erpnext-lead-source.yaml +21 -0
  62. prefly-0.1.0/preflight/rules/v16/v16-erpnext-make-pricing-rule-js.yaml +23 -0
  63. prefly-0.1.0/preflight/rules/v16/v16-erpnext-moved-settings.yaml +40 -0
  64. prefly-0.1.0/preflight/rules/v16/v16-erpnext-pos-closing-invoices.yaml +22 -0
  65. prefly-0.1.0/preflight/rules/v16/v16-erpnext-pos-fields.yaml +30 -0
  66. prefly-0.1.0/preflight/rules/v16/v16-erpnext-removed-fields.yaml +46 -0
  67. prefly-0.1.0/preflight/rules/v16/v16-erpnext-removed-methods.yaml +30 -0
  68. prefly-0.1.0/preflight/rules/v16/v16-erpnext-replaced-reports.yaml +32 -0
  69. prefly-0.1.0/preflight/rules/v16/v16-erpnext-repost-allowed-types.yaml +32 -0
  70. prefly-0.1.0/preflight/rules/v16/v16-erpnext-repost-settings-doctype.yaml +24 -0
  71. prefly-0.1.0/preflight/rules/v16/v16-erpnext-repost-settings.yaml +19 -0
  72. prefly-0.1.0/preflight/rules/v16/v16-erpnext-scrap-item-doctypes.yaml +26 -0
  73. prefly-0.1.0/preflight/rules/v16/v16-erpnext-scrap-items.yaml +23 -0
  74. prefly-0.1.0/preflight/rules/v16/v16-erpnext-secondary-items-fields.yaml +34 -0
  75. prefly-0.1.0/preflight/rules/v16/v16-erpnext-serial-no-creation-document.yaml +23 -0
  76. prefly-0.1.0/preflight/rules/v16/v16-erpnext-subcontracted-quantity.yaml +27 -0
  77. prefly-0.1.0/preflight/rules/v16/v16-erpnext-tax-withholding-doctypes.yaml +24 -0
  78. prefly-0.1.0/preflight/rules/v16/v16-erpnext-tax-withholding-fields.yaml +42 -0
  79. prefly-0.1.0/preflight/rules/v16/v16-erpnext-tax-withholding.yaml +47 -0
  80. prefly-0.1.0/preflight/rules/v16/v16-erpnext-utm-fields.yaml +42 -0
  81. prefly-0.1.0/preflight/rules/v16/v16-erpnext-workstation-cost-fields.yaml +32 -0
  82. prefly-0.1.0/preflight/rules/v16/v16-has-permission-hook-returns-none.yaml +22 -0
  83. prefly-0.1.0/preflight/rules/v16/v16-has-permission-hook-unreadable.yaml +22 -0
  84. prefly-0.1.0/preflight/rules/v16/v16-has-permission-raise-exception.yaml +18 -0
  85. prefly-0.1.0/preflight/rules/v16/v16-make-xlsx-workbook.yaml +22 -0
  86. prefly-0.1.0/preflight/rules/v16/v16-moved-desk-permission-helpers.yaml +29 -0
  87. prefly-0.1.0/preflight/rules/v16/v16-moved-frappe-config-helpers.yaml +27 -0
  88. prefly-0.1.0/preflight/rules/v16/v16-override-doctype-class-subclass.yaml +19 -0
  89. prefly-0.1.0/preflight/rules/v16/v16-private-workspace-my-space.yaml +24 -0
  90. prefly-0.1.0/preflight/rules/v16/v16-removed-backup-doctypes.yaml +22 -0
  91. prefly-0.1.0/preflight/rules/v16/v16-removed-backup-integrations.yaml +18 -0
  92. prefly-0.1.0/preflight/rules/v16/v16-removed-bleach.yaml +23 -0
  93. prefly-0.1.0/preflight/rules/v16/v16-removed-blog-doctypes.yaml +23 -0
  94. prefly-0.1.0/preflight/rules/v16/v16-removed-blog.yaml +18 -0
  95. prefly-0.1.0/preflight/rules/v16/v16-removed-boto3.yaml +23 -0
  96. prefly-0.1.0/preflight/rules/v16/v16-removed-botocore.yaml +27 -0
  97. prefly-0.1.0/preflight/rules/v16/v16-removed-cairocffi.yaml +23 -0
  98. prefly-0.1.0/preflight/rules/v16/v16-removed-changelog-feed-doctypes.yaml +20 -0
  99. prefly-0.1.0/preflight/rules/v16/v16-removed-changelog-feed-hook.yaml +18 -0
  100. prefly-0.1.0/preflight/rules/v16/v16-removed-changelog-feed-js.yaml +20 -0
  101. prefly-0.1.0/preflight/rules/v16/v16-removed-changelog-feed.yaml +17 -0
  102. prefly-0.1.0/preflight/rules/v16/v16-removed-dropbox.yaml +24 -0
  103. prefly-0.1.0/preflight/rules/v16/v16-removed-energy-point-doctypes.yaml +23 -0
  104. prefly-0.1.0/preflight/rules/v16/v16-removed-energy-points-js.yaml +16 -0
  105. prefly-0.1.0/preflight/rules/v16/v16-removed-energy-points.yaml +17 -0
  106. prefly-0.1.0/preflight/rules/v16/v16-removed-geolite2.yaml +24 -0
  107. prefly-0.1.0/preflight/rules/v16/v16-removed-get-excel-date-format.yaml +19 -0
  108. prefly-0.1.0/preflight/rules/v16/v16-removed-leaderboards-hook.yaml +17 -0
  109. prefly-0.1.0/preflight/rules/v16/v16-removed-list-sidebar.yaml +15 -0
  110. prefly-0.1.0/preflight/rules/v16/v16-removed-maxminddb.yaml +27 -0
  111. prefly-0.1.0/preflight/rules/v16/v16-removed-newsletter-doctypes.yaml +23 -0
  112. prefly-0.1.0/preflight/rules/v16/v16-removed-newsletter.yaml +16 -0
  113. prefly-0.1.0/preflight/rules/v16/v16-removed-octicons-import.yaml +21 -0
  114. prefly-0.1.0/preflight/rules/v16/v16-removed-octicons.yaml +17 -0
  115. prefly-0.1.0/preflight/rules/v16/v16-removed-pkg-resources.yaml +25 -0
  116. prefly-0.1.0/preflight/rules/v16/v16-removed-posthog.yaml +24 -0
  117. prefly-0.1.0/preflight/rules/v16/v16-removed-python-stdlib.yaml +127 -0
  118. prefly-0.1.0/preflight/rules/v16/v16-removed-rq-connection-helpers.yaml +29 -0
  119. prefly-0.1.0/preflight/rules/v16/v16-removed-s3transfer.yaml +27 -0
  120. prefly-0.1.0/preflight/rules/v16/v16-removed-test-runner-helpers.yaml +24 -0
  121. prefly-0.1.0/preflight/rules/v16/v16-removed-transaction-log-doctypes.yaml +20 -0
  122. prefly-0.1.0/preflight/rules/v16/v16-removed-transaction-log.yaml +16 -0
  123. prefly-0.1.0/preflight/rules/v16/v16-removed-user-get-roles-import.yaml +17 -0
  124. prefly-0.1.0/preflight/rules/v16/v16-removed-user-get-roles.yaml +18 -0
  125. prefly-0.1.0/preflight/rules/v16/v16-removed-workspace-sidebar-items-import.yaml +23 -0
  126. prefly-0.1.0/preflight/rules/v16/v16-removed-workspace-sidebar-items.yaml +21 -0
  127. prefly-0.1.0/preflight/rules/v16/v16-renamed-is-column-missing.yaml +15 -0
  128. prefly-0.1.0/preflight/rules/v16/v16-sanitize-html-strips-tags.yaml +17 -0
  129. prefly-0.1.0/preflight/rules/v16/v16-server-script-private-request.yaml +30 -0
  130. prefly-0.1.0/preflight/rules/v16/v16-sql-function-string-fields.yaml +25 -0
  131. prefly-0.1.0/preflight/rules/v16/v16-value-cache-nested.yaml +15 -0
  132. prefly-0.1.0/preflight/rules/v17/target.yaml +3 -0
  133. prefly-0.1.0/preflight/rules/v17/v17-app-dependency-pins.yaml +108 -0
  134. prefly-0.1.0/preflight/rules/v17/v17-app-frappe-bound.yaml +16 -0
  135. prefly-0.1.0/preflight/rules/v17/v17-app-required-apps.yaml +12 -0
  136. prefly-0.1.0/preflight/rules/v17/v17-app-requires-python.yaml +14 -0
  137. prefly-0.1.0/preflight/rules/v17/v17-bench-python.yaml +13 -0
  138. prefly-0.1.0/preflight/rules/v17/v17-deprecated-client-get-js.yaml +16 -0
  139. prefly-0.1.0/preflight/rules/v17/v17-deprecated-country-info-get-translated-dict.yaml +15 -0
  140. prefly-0.1.0/preflight/rules/v17/v17-deprecated-email-queue-send-mail.yaml +15 -0
  141. prefly-0.1.0/preflight/rules/v17/v17-deprecated-enqueue-job-name.yaml +17 -0
  142. prefly-0.1.0/preflight/rules/v17/v17-deprecated-frappe-test-case.yaml +17 -0
  143. prefly-0.1.0/preflight/rules/v17/v17-deprecated-get-number-format-info.yaml +17 -0
  144. prefly-0.1.0/preflight/rules/v17/v17-deprecated-get-test-records.yaml +17 -0
  145. prefly-0.1.0/preflight/rules/v17/v17-deprecated-gravatar-helpers.yaml +20 -0
  146. prefly-0.1.0/preflight/rules/v17/v17-deprecated-gzip-helpers.yaml +16 -0
  147. prefly-0.1.0/preflight/rules/v17/v17-deprecated-install-fixtures-underscore.yaml +16 -0
  148. prefly-0.1.0/preflight/rules/v17/v17-deprecated-is-column-missing.yaml +16 -0
  149. prefly-0.1.0/preflight/rules/v17/v17-deprecated-make-esc.yaml +15 -0
  150. prefly-0.1.0/preflight/rules/v17/v17-deprecated-query-limit-page-length.yaml +21 -0
  151. prefly-0.1.0/preflight/rules/v17/v17-deprecated-query-limit-start.yaml +21 -0
  152. prefly-0.1.0/preflight/rules/v17/v17-deprecated-query-page-length.yaml +21 -0
  153. prefly-0.1.0/preflight/rules/v17/v17-deprecated-query-start.yaml +21 -0
  154. prefly-0.1.0/preflight/rules/v17/v17-deprecated-read-multi-pdf.yaml +15 -0
  155. prefly-0.1.0/preflight/rules/v17/v17-deprecated-sendmail-retry.yaml +19 -0
  156. prefly-0.1.0/preflight/rules/v17/v17-deprecated-show-progress.yaml +16 -0
  157. prefly-0.1.0/preflight/rules/v17/v17-deprecated-tests-system-settings.yaml +19 -0
  158. prefly-0.1.0/preflight/rules/v17/v17-deprecated-tests-utils-helpers.yaml +23 -0
  159. prefly-0.1.0/preflight/rules/v17/v17-deprecated-verify-password.yaml +18 -0
  160. prefly-0.1.0/preflight/rules/v17/v17-moved-chromium-pdf-generator.yaml +19 -0
  161. prefly-0.1.0/preflight/rules/v17/v17-moved-doctype-layout.yaml +17 -0
  162. prefly-0.1.0/preflight/rules/v17/v17-moved-max-password-size.yaml +18 -0
  163. prefly-0.1.0/preflight/rules/v17/v17-removed-get-pdf-smart-shrinking.yaml +21 -0
  164. prefly-0.1.0/preflight/rules/v17/v17-removed-sidebar-item-group-doctypes.yaml +17 -0
  165. prefly-0.1.0/preflight/rules/v17/v17-removed-slack-webhook-url.yaml +19 -0
  166. prefly-0.1.0/preflight/rules/v17/v17-removed-weasyprint-download-pdf.yaml +18 -0
  167. prefly-0.1.0/preflight/rules/v17/v17-removed-weasyprint.yaml +22 -0
  168. prefly-0.1.0/preflight/rules/v17/v17-removed-whoosh-search.yaml +15 -0
  169. prefly-0.1.0/preflight/rules/v17/v17-removed-wkhtmltopdf-helpers.yaml +28 -0
  170. prefly-0.1.0/preflight/scan.py +349 -0
  171. prefly-0.1.0/preflight/schema_scanner.py +58 -0
  172. prefly-0.1.0/preflight/sources.py +46 -0
  173. prefly-0.1.0/pyproject.toml +45 -0
prefly-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: prefly
3
+ Version: 0.1.0
4
+ Summary: Scan a Frappe site for APIs removed or renamed in the next major version, before upgrading
5
+ Author-email: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: license.txt
9
+ Requires-Dist: click
10
+ Requires-Dist: packaging
11
+ Requires-Dist: pyyaml
12
+ Requires-Dist: semantic-version
13
+ Requires-Dist: tomli; python_version < "3.11"
14
+ Project-URL: Documentation, https://github.com/kaulith/preflight/blob/main/docs/overview.md
15
+ Project-URL: Issues, https://github.com/kaulith/preflight/issues
16
+ Project-URL: Source, https://github.com/kaulith/preflight
17
+
18
+ # Preflight
19
+
20
+ Scans a Frappe site before a major version upgrade and lists what will break: removed and renamed
21
+ APIs in its apps, site scripts and customisations, and what the bench needs for the new version. It
22
+ changes nothing on the site.
23
+
24
+ ## Install and run
25
+
26
+ From the bench directory, into the bench's own environment:
27
+
28
+ ```bash
29
+ ./env/bin/pip install prefly
30
+ ./env/bin/preflight --site mysite.local --target v16
31
+ ```
32
+
33
+ For CI, scan an app's checkout with no site or bench:
34
+
35
+ ```bash
36
+ pip install prefly
37
+ preflight --path . --target v16
38
+ ```
39
+
40
+ A clean report means no known problems were found, not that the upgrade is safe.
41
+
42
+ ## Docs
43
+
44
+ - [Overview](https://github.com/kaulith/preflight/blob/main/docs/overview.md): what it checks, options, exit codes, limits
45
+ - [Getting started](https://github.com/kaulith/preflight/blob/main/docs/getting-started.md): install, the first scan, CI, suppressing a finding
46
+ - [Reading a report](https://github.com/kaulith/preflight/blob/main/docs/reading-a-report.md)
47
+ - [Rules](https://github.com/kaulith/preflight/blob/main/docs/rules.md)
48
+ - [Upgrade checklist](https://github.com/kaulith/preflight/blob/main/docs/upgrade-checklist.md)
49
+ - [Writing a rule](https://github.com/kaulith/preflight/blob/main/docs/writing-a-rule.md)
50
+ - [How it was tested](https://github.com/kaulith/preflight/blob/main/docs/how-it-was-tested.md)
51
+
52
+ ## License
53
+
54
+ MIT
55
+
prefly-0.1.0/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # Preflight
2
+
3
+ Scans a Frappe site before a major version upgrade and lists what will break: removed and renamed
4
+ APIs in its apps, site scripts and customisations, and what the bench needs for the new version. It
5
+ changes nothing on the site.
6
+
7
+ ## Install and run
8
+
9
+ From the bench directory, into the bench's own environment:
10
+
11
+ ```bash
12
+ ./env/bin/pip install prefly
13
+ ./env/bin/preflight --site mysite.local --target v16
14
+ ```
15
+
16
+ For CI, scan an app's checkout with no site or bench:
17
+
18
+ ```bash
19
+ pip install prefly
20
+ preflight --path . --target v16
21
+ ```
22
+
23
+ A clean report means no known problems were found, not that the upgrade is safe.
24
+
25
+ ## Docs
26
+
27
+ - [Overview](https://github.com/kaulith/preflight/blob/main/docs/overview.md): what it checks, options, exit codes, limits
28
+ - [Getting started](https://github.com/kaulith/preflight/blob/main/docs/getting-started.md): install, the first scan, CI, suppressing a finding
29
+ - [Reading a report](https://github.com/kaulith/preflight/blob/main/docs/reading-a-report.md)
30
+ - [Rules](https://github.com/kaulith/preflight/blob/main/docs/rules.md)
31
+ - [Upgrade checklist](https://github.com/kaulith/preflight/blob/main/docs/upgrade-checklist.md)
32
+ - [Writing a rule](https://github.com/kaulith/preflight/blob/main/docs/writing-a-rule.md)
33
+ - [How it was tested](https://github.com/kaulith/preflight/blob/main/docs/how-it-was-tested.md)
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaushal Shriwas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from preflight.commands import preflight_command
2
+
3
+ preflight_command()
@@ -0,0 +1,177 @@
1
+ """The scan of an app's own files, which needs only the files: no site, no bench, and no frappe."""
2
+
3
+ import re
4
+ from collections.abc import Collection, Mapping
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ from packaging.requirements import Requirement
9
+ from packaging.utils import canonicalize_name
10
+
11
+ from preflight.compatibility_scanner import CompatibilityScanner, declared_dependencies
12
+ from preflight.hooks_scanner import HooksScanner, hooked_doctypes
13
+ from preflight.js_scanner import JSScanner
14
+ from preflight.models import Finding, Report, Rule
15
+ from preflight.python_scanner import PythonScanner
16
+ from preflight.schema_scanner import SchemaScanner, app_doctype_fields
17
+ from preflight.sources import frontend_files, is_bundle, read_source, source_files
18
+
19
+ # Inside a comment: `#` in Python, TOML and patches.txt, `//` or `/*` in JS and SCSS, `<!--` in a Vue
20
+ # template.
21
+ IGNORE_COMMENT = re.compile(r"(?:#|//|/\*|<!--).*?\bpreflight:\s*ignore\[([^\]]*)\]")
22
+ # What --path cannot check, having no bench and no site to read.
23
+ PATH_MODE_NOT_CHECKED = (
24
+ "required_apps: --path reads no bench to find them on, not checked",
25
+ "bench Python: --path reads no bench, not checked",
26
+ (
27
+ "site: --path reads no site, its scripts, Custom Fields, Property Setters, records and Patch Log"
28
+ " not checked"
29
+ ),
30
+ )
31
+
32
+
33
+ class Scanners:
34
+ """The five scanners, compiled once from one rule set."""
35
+
36
+ def __init__(self, rules: list[Rule]):
37
+ self.python = PythonScanner(rules)
38
+ self.js = JSScanner(rules)
39
+ self.hooks = HooksScanner(rules)
40
+ self.schema = SchemaScanner(rules)
41
+ self.compatibility = CompatibilityScanner(rules)
42
+
43
+
44
+ def ignored_rules(line: str) -> set[str]:
45
+ """The rule ids a `preflight: ignore[rule-a, rule-b]` comment on the line names."""
46
+ return {
47
+ rule_id.strip()
48
+ for comment in IGNORE_COMMENT.finditer(line)
49
+ for rule_id in comment.group(1).split(",")
50
+ }
51
+
52
+
53
+ def add_findings(
54
+ report: Report,
55
+ findings: list[Finding],
56
+ text: str,
57
+ app: str | None = None,
58
+ source: str = "app",
59
+ declared: set[str] = frozenset(),
60
+ ):
61
+ """Count the findings read from `text` into the report, as found in `app` and read from `source`.
62
+
63
+ An import of a package the app declares is no finding, whatever frappe stops installing. One that an
64
+ ignore comment on its own line names is counted as suppressed instead."""
65
+ lines = text.split("\n")
66
+ for finding in findings:
67
+ if finding.rule.match.type == "undeclared_import" and not declared.isdisjoint(
68
+ canonicalize_name(Requirement(dependency).name) for dependency in finding.rule.match.dependencies
69
+ ):
70
+ continue
71
+ if finding.line and finding.rule.id in ignored_rules(lines[finding.line - 1]):
72
+ report.suppressed += 1
73
+ continue
74
+ report.findings.append(replace(finding, app=app, source=source))
75
+
76
+
77
+ def build_path_report(
78
+ rules: list[Rule], target: str, app_paths: list[Path], skip_tests: bool = False, online: bool = False
79
+ ) -> Report:
80
+ """Scan each app's files, `app_paths` naming the directory that holds its hooks.py. There is no frappe
81
+ version to compare branches with, so each pyproject.toml is read whatever branch the app is on."""
82
+ scanners = Scanners(rules)
83
+ report = Report(site=None, target=target, apps=[app_path.name for app_path in app_paths])
84
+ for app_path in app_paths:
85
+ scan_app(report, scanners, app_path.name, app_path, skip_tests)
86
+ scan_pyproject(report, scanners.compatibility, app_path.name, app_path.parent)
87
+ report.not_checked += PATH_MODE_NOT_CHECKED
88
+ if online:
89
+ from preflight.online_scanner import OnlineScanner, scan_online
90
+
91
+ app_roots = {app_path.name: app_path.parent for app_path in app_paths}
92
+ scan_online(report, OnlineScanner(rules), app_roots, None)
93
+ return report
94
+
95
+
96
+ def scan_app(report: Report, scanners: Scanners, app: str, app_path: Path, skip_tests: bool = False):
97
+ """The app's Python with its hooks.py, its front-end files anywhere in the repository, the `execute:`
98
+ lines of its patches.txt, and the link fields of its doctypes. Each file is named from the repository
99
+ root, the parent of `app_path`; the report's lists name it under the app as well."""
100
+ app_root = app_path.parent
101
+ declared = declared_dependencies(app_root)
102
+ hooked = hooked_doctypes(app_path)
103
+ for path in source_files(app_path, ".py", skip_tests):
104
+ file = str(path.relative_to(app_root))
105
+ source = read_source(path, f"{app}/{file}", report.unreadable_files)
106
+ if source is None:
107
+ continue
108
+ module = ".".join(path.relative_to(app_root).with_suffix("").parts).removesuffix(".__init__")
109
+ findings = scan_python(report, scanners.python, file, source, app, hooked.get(module, {}))
110
+ if findings is None:
111
+ continue
112
+ if path == app_path / "hooks.py":
113
+ findings += scanners.hooks.scan(source, file, app_path) + scanners.hooks.missing_hooks(
114
+ source, file, app_path
115
+ )
116
+ add_findings(report, findings, source, app, declared=declared)
117
+ for path in frontend_files(app_root, skip_tests):
118
+ file = str(path.relative_to(app_root))
119
+ source = read_source(path, f"{app}/{file}", report.unreadable_files)
120
+ if source is not None and not is_bundle(source):
121
+ add_findings(report, scanners.js.scan(source, file), source, app)
122
+ scan_patches(report, scanners.python, app, app_path, declared)
123
+ report.findings += [
124
+ replace(finding, app=app) for finding in scanners.schema.scan(app_doctype_fields(app_path))
125
+ ]
126
+
127
+
128
+ def scan_patches(report: Report, scanner: PythonScanner, app: str, app_path: Path, declared: set[str]):
129
+ """The `execute:` lines of the app's patches.txt, which are Python that `bench migrate` runs. They are
130
+ scanned as one file with every other line blanked, so each finding keeps its patches.txt line."""
131
+ path = app_path / "patches.txt"
132
+ if not path.is_file():
133
+ return
134
+ file = str(path.relative_to(app_path.parent))
135
+ source = read_source(path, f"{app}/{file}", report.unreadable_files)
136
+ if source is None:
137
+ return
138
+ code = "\n".join(
139
+ line.removeprefix("execute:") if line.startswith("execute:") else "" for line in source.splitlines()
140
+ )
141
+ add_findings(report, scan_python(report, scanner, file, code, app) or [], code, app, declared=declared)
142
+
143
+
144
+ def scan_pyproject(report: Report, scanner: CompatibilityScanner, app: str, app_root: Path):
145
+ path = app_root / "pyproject.toml"
146
+ if not path.is_file():
147
+ return
148
+ source = read_source(path, f"{app}/pyproject.toml", report.unreadable_files)
149
+ if source is None:
150
+ return
151
+ try:
152
+ findings = scanner.scan_pyproject(source, "pyproject.toml")
153
+ except ValueError:
154
+ report.unparsable_files.append(f"{app}/pyproject.toml")
155
+ return
156
+ add_findings(report, findings, source, app)
157
+
158
+
159
+ def scan_python(
160
+ report: Report,
161
+ scanner: PythonScanner,
162
+ file: str,
163
+ source: str,
164
+ app: str | None = None,
165
+ hooked: Mapping[str, Collection[str]] = {},
166
+ docs: Mapping[str, Collection[str]] = {},
167
+ ) -> list[Finding] | None:
168
+ """The file's findings, or None when it does not parse, with the file named in the report's lists,
169
+ under `app` when it is one of an app's. `hooked` and `docs` are PythonScanner.scan's."""
170
+ listed = f"{app}/{file}" if app else file
171
+ try:
172
+ return scanner.scan(source, file, hooked, docs)
173
+ except SyntaxError:
174
+ report.unparsable_files.append(listed)
175
+ except (ValueError, RecursionError) as error:
176
+ report.unreadable_files.append(f"{listed}: {error}")
177
+ return None
@@ -0,0 +1,176 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ import traceback
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from preflight import __version__
12
+ from preflight.app_scan import build_path_report
13
+ from preflight.formatter import format_html, format_json, format_report, format_sarif
14
+ from preflight.models import SEVERITIES, Report, Rule
15
+ from preflight.rule_loader import RuleError, load_rules, load_ruleset, released_targets
16
+
17
+ FORMATTERS = {"text": format_report, "json": format_json, "html": format_html, "sarif": format_sarif}
18
+ # What a baseline finding is matched on: everything but its line, which moves as the file changes.
19
+ BASELINE_KEYS = ("rule", "app", "file", "snippet")
20
+
21
+
22
+ def app_package_dir(path: Path) -> Path:
23
+ """The app's package directory, the one holding hooks.py: `path` itself, or the one directory in it
24
+ that holds one, so an app is named by its repository root or by its package."""
25
+ path = path.resolve()
26
+ if (path / "hooks.py").is_file():
27
+ return path
28
+ packages = sorted(child for child in path.iterdir() if (child / "hooks.py").is_file())
29
+ if len(packages) != 1:
30
+ raise click.UsageError(f"--path {path}: no app, neither it nor one directory in it holds a hooks.py")
31
+ return packages[0]
32
+
33
+
34
+ def read_baseline(path: Path) -> Counter:
35
+ """The findings of a report written earlier with --format json, each as often as it appears."""
36
+ try:
37
+ data = json.loads(path.read_text(encoding="utf-8"))
38
+ except (OSError, ValueError) as e:
39
+ raise click.UsageError(f"--baseline {path}: {e}") from e
40
+ findings = data.get("findings") if isinstance(data, dict) else None
41
+ if not isinstance(findings, list) or not all(
42
+ isinstance(entry, dict) and all(key in entry for key in BASELINE_KEYS) for entry in findings
43
+ ):
44
+ raise click.UsageError(f"--baseline {path}: not a report written with --format json")
45
+ return Counter(tuple(entry[key] for key in BASELINE_KEYS) for entry in findings)
46
+
47
+
48
+ def scan_site(
49
+ rules: list[Rule], site: str, target: str, apps: tuple[str, ...], skip_tests: bool, online: bool
50
+ ) -> Report:
51
+ """Connect to the site from the bench this python's env belongs to (`<bench>/env`), working in its
52
+ sites directory the way bench does. frappe is imported here only, so --path runs without it."""
53
+ sites_path = Path(sys.prefix).parent / "sites"
54
+ if not sites_path.is_dir():
55
+ raise click.UsageError(
56
+ f"--site needs the python of a bench's env; {sites_path} is not a bench's sites"
57
+ )
58
+ import frappe
59
+
60
+ from preflight.scan import build_report
61
+
62
+ if int(frappe.__version__.split(".")[0]) >= int(target.removeprefix("v")):
63
+ raise click.UsageError(
64
+ f"this bench already runs frappe {frappe.__version__}; --target is the version to upgrade to"
65
+ )
66
+ os.chdir(sites_path)
67
+ try:
68
+ frappe.init(site=site, sites_path=str(sites_path))
69
+ frappe.connect()
70
+ installed = frappe.get_installed_apps()
71
+ if unknown := [app for app in apps if app not in installed]:
72
+ raise click.UsageError(f"not installed on {site}: {', '.join(unknown)}")
73
+ return build_report(rules, site, target, apps, skip_tests, online)
74
+ except frappe.exceptions.IncorrectSitePath as e:
75
+ raise click.UsageError(str(e)) from e
76
+ finally:
77
+ frappe.destroy()
78
+
79
+
80
+ @click.command("preflight")
81
+ @click.version_option(version=__version__, prog_name="preflight")
82
+ @click.option("--site", help="Site to scan, on the bench whose env runs preflight")
83
+ @click.option(
84
+ "--path",
85
+ "paths",
86
+ multiple=True,
87
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
88
+ help="Scan the app in this directory, its repository or its package, with no site or bench; repeatable",
89
+ )
90
+ @click.option("--target", required=True, help="Version to check against, selects the rule set (e.g. v16)")
91
+ @click.option("--unreleased", is_flag=True, hidden=True, help="Allow a target that is not released yet")
92
+ @click.option("--app", "apps", multiple=True, help="Scan only this app of the site; repeatable")
93
+ @click.option(
94
+ "--severity",
95
+ type=click.Choice(SEVERITIES),
96
+ default="info",
97
+ help="Lowest severity to report",
98
+ )
99
+ @click.option(
100
+ "--fail-on",
101
+ type=click.Choice((*SEVERITIES, "never")),
102
+ default="error",
103
+ help="Lowest severity that exits 1",
104
+ )
105
+ @click.option(
106
+ "--format", "output_format", type=click.Choice(tuple(FORMATTERS)), default="text", help="Report format"
107
+ )
108
+ @click.option("--open", "open_report", is_flag=True, help="Also open the HTML report in a browser")
109
+ @click.option(
110
+ "--baseline",
111
+ type=click.Path(dir_okay=False, path_type=Path),
112
+ help="Leave out the findings of this earlier --format json report",
113
+ )
114
+ @click.option("--online", is_flag=True, help="Also run the checks that ask git remotes, node and PyPI")
115
+ @click.option("--skip-tests", is_flag=True, help="Skip test files and tests directories")
116
+ def preflight_command(
117
+ site: str | None,
118
+ paths: tuple[Path, ...],
119
+ target: str,
120
+ unreleased: bool,
121
+ apps: tuple[str, ...],
122
+ severity: str,
123
+ fail_on: str,
124
+ output_format: str,
125
+ open_report: bool,
126
+ baseline: Path | None,
127
+ online: bool,
128
+ skip_tests: bool,
129
+ ):
130
+ """Report APIs removed, renamed or deprecated in the target version"""
131
+ if bool(site) == bool(paths):
132
+ raise click.UsageError("Pass --site to scan a site, or --path to scan an app's files: one of the two")
133
+ if paths and apps:
134
+ raise click.UsageError("--app picks apps of a site; with --path, pass each app's directory")
135
+ try:
136
+ rules = load_rules(target)
137
+ ruleset = load_ruleset(target)
138
+ except RuleError as e:
139
+ raise click.UsageError(str(e)) from e
140
+ if not ruleset.released and not unreleased:
141
+ raise click.UsageError(
142
+ f"{target} is not released yet. Available targets: {', '.join(released_targets())}"
143
+ )
144
+ baselined = read_baseline(baseline) if baseline else Counter()
145
+ app_paths = [app_package_dir(path) for path in paths]
146
+
147
+ try:
148
+ if site:
149
+ report = scan_site(rules, site, target, apps, skip_tests, online)
150
+ else:
151
+ report = build_path_report(rules, target, app_paths, skip_tests, online)
152
+ report.ruleset = ruleset
153
+ counted = []
154
+ for finding in report.findings:
155
+ key = (finding.rule.id, finding.app, finding.file, finding.snippet)
156
+ if baselined[key]:
157
+ baselined[key] -= 1
158
+ report.baselined += 1
159
+ else:
160
+ counted.append(finding)
161
+ report.findings = counted
162
+ click.echo(FORMATTERS[output_format](report, severity, fail_on))
163
+ if open_report:
164
+ with tempfile.NamedTemporaryFile(
165
+ "w", encoding="utf-8", prefix="preflight-", suffix=".html", delete=False
166
+ ) as page:
167
+ page.write(format_html(report, severity, fail_on))
168
+ click.echo(f"HTML report: {page.name}", err=True)
169
+ click.launch(page.name)
170
+ except click.ClickException:
171
+ raise
172
+ except Exception: # noqa: BLE001
173
+ traceback.print_exc()
174
+ sys.exit(3)
175
+ if report.fails(fail_on):
176
+ sys.exit(1)
@@ -0,0 +1,207 @@
1
+ import ast
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import semantic_version
6
+ from packaging.requirements import InvalidRequirement, Requirement
7
+ from packaging.specifiers import SpecifierSet
8
+ from packaging.utils import canonicalize_name
9
+ from packaging.version import InvalidVersion, Version
10
+
11
+ try:
12
+ import tomllib
13
+ except ModuleNotFoundError: # python 3.10, which frappe v15 still runs on
14
+ import tomli as tomllib
15
+
16
+ from preflight.hooks_scanner import assigned_names
17
+ from preflight.models import Finding, Rule
18
+
19
+ PYTHON_RELEASES = tuple(Version(f"3.{minor}.{patch}") for minor in range(8, 30) for patch in (0, 99))
20
+
21
+
22
+ def parse_required_app_name(requirement: str) -> str:
23
+ """Mirrors frappe.installer.parse_required_app_name from v16, which v15 does not have."""
24
+ name = requirement.rstrip("/").split("#")[0]
25
+ name = name.rsplit("/", 1)[-1].rsplit(":", 1)[-1]
26
+ return name.split("@")[0].removesuffix(".git")
27
+
28
+
29
+ def checked_out_branch(app_root: Path) -> str | None:
30
+ """The branch the app's checkout is on, or None when HEAD is detached or there is no checkout. A
31
+ worktree's `.git` is a file naming the git dir that holds its HEAD."""
32
+ git_dir = app_root / ".git"
33
+ try:
34
+ if git_dir.is_file():
35
+ git_dir = app_root / git_dir.read_text(encoding="utf-8").removeprefix("gitdir:").strip()
36
+ head = (git_dir / "HEAD").read_text(encoding="utf-8").strip()
37
+ except OSError:
38
+ return None
39
+ return head.removeprefix("ref: refs/heads/") if head.startswith("ref: refs/heads/") else None
40
+
41
+
42
+ def is_frappe_branch(branch: str, frappe_major: int) -> bool:
43
+ """True on the version branch of the frappe the bench runs (version-15-hotfix on v15), or on a branch
44
+ cut from it (mergify/bp/version-15-hotfix/pr-1), which the upgrade switches to the target's. An
45
+ app's own release branch, like Insights' version-3, stays."""
46
+ return bool(re.search(rf"(^|/)version-{frappe_major}(-hotfix|-beta)?(/|$)", branch))
47
+
48
+
49
+ def declared_dependencies(app_root: Path) -> set[str]:
50
+ """The canonical names of the packages the app asks pip for: its pyproject's `[project].dependencies`,
51
+ and a requirements.txt, which older apps still install from. A file that cannot be read declares
52
+ nothing."""
53
+ requirements = []
54
+ try:
55
+ data = tomllib.loads((app_root / "pyproject.toml").read_text(encoding="utf-8"))
56
+ requirements += data.get("project", {}).get("dependencies", [])
57
+ except (OSError, ValueError):
58
+ pass
59
+ try:
60
+ lines = (app_root / "requirements.txt").read_text(encoding="utf-8").splitlines()
61
+ requirements += [line.partition("#")[0].strip() for line in lines]
62
+ except (OSError, ValueError):
63
+ pass
64
+ names = set()
65
+ for requirement in requirements:
66
+ try:
67
+ names.add(canonicalize_name(Requirement(requirement).name))
68
+ except InvalidRequirement:
69
+ continue
70
+ return names
71
+
72
+
73
+ def shares_a_python(first: SpecifierSet, second: SpecifierSet) -> bool:
74
+ """Whether any Python release satisfies both. Each minor is tried at .0 and .99, so a bound on a
75
+ patch release (">=3.14.2") still counts its minor."""
76
+ return any(first.contains(release) and second.contains(release) for release in PYTHON_RELEASES)
77
+
78
+
79
+ def named_versions(specifiers: SpecifierSet) -> set[Version]:
80
+ """Each version `specifiers` names, and one just above it, which `>` and `!=` let through."""
81
+ versions = set()
82
+ for specifier in specifiers:
83
+ try:
84
+ version = Version(specifier.version.removesuffix(".*"))
85
+ except InvalidVersion:
86
+ continue
87
+ versions |= {version, Version(f"{version.base_version}.1")}
88
+ return versions
89
+
90
+
91
+ def can_coexist(first: SpecifierSet, second: SpecifierSet) -> bool:
92
+ """Whether a version satisfies both. Only 0 and the `named_versions` of either are tried, which is
93
+ exact for the pins frappe writes (`==`, `~=`, `>=` with `<`); a pair whose shared versions all lie
94
+ past a `!=` wildcard (`!=1.*`) reads as a clash."""
95
+ both = first & second
96
+ return any(
97
+ both.contains(version, prereleases=True)
98
+ for version in {Version("0"), *named_versions(first), *named_versions(second)}
99
+ )
100
+
101
+
102
+ def quoted_line(lines: list[str], start: int, text: str) -> int | None:
103
+ """The first line from `start` on that quotes `text`, or None when none does."""
104
+ return next(
105
+ (
106
+ number
107
+ for number, line in enumerate(lines, 1)
108
+ if number >= start and (f'"{text}"' in line or f"'{text}'" in line)
109
+ ),
110
+ None,
111
+ )
112
+
113
+
114
+ def toml_line(lines: list[str], table: str, key: str) -> int | None:
115
+ """The line `key` is set on inside `[table]`, or None when it is not found; tomllib keeps no
116
+ positions."""
117
+ current = ""
118
+ for number, line in enumerate(lines, 1):
119
+ stripped = line.strip()
120
+ if stripped.startswith("["):
121
+ current = stripped.strip("[]").strip()
122
+ elif current == table and stripped.partition("=")[0].strip().strip("\"'") == key:
123
+ return number
124
+ return None
125
+
126
+
127
+ def finding_on(rule: Rule, file: str, lines: list[str], line: int | None) -> Finding:
128
+ return Finding(rule=rule, file=file, line=line, snippet=lines[line - 1].strip() if line else "")
129
+
130
+
131
+ class CompatibilityScanner:
132
+ """Rules on whether the bench can install the target: its Python, and each app's pyproject.toml
133
+ and required_apps."""
134
+
135
+ def __init__(self, rules: list[Rule]):
136
+ self.rules_by_type: dict[str, list[Rule]] = {}
137
+ for rule in rules:
138
+ self.rules_by_type.setdefault(rule.match.type, []).append(rule)
139
+
140
+ def scan_environment(self, python_version: str) -> list[Finding]:
141
+ return [
142
+ Finding(rule=rule, file="env/bin/python", line=None, snippet=f"Python {python_version}")
143
+ for rule in self.rules_by_type.get("bench_python", ())
144
+ if not SpecifierSet(rule.match.python).contains(python_version)
145
+ ]
146
+
147
+ def scan_pyproject(self, source: str, file: str) -> list[Finding]:
148
+ """Raises ValueError when the file, or a bound or dependency in it, does not parse."""
149
+ data = tomllib.loads(source)
150
+ lines = source.splitlines()
151
+ findings = []
152
+
153
+ if requires_python := data.get("project", {}).get("requires-python"):
154
+ line = toml_line(lines, "project", "requires-python")
155
+ findings += [
156
+ finding_on(rule, file, lines, line)
157
+ for rule in self.rules_by_type.get("requires_python", ())
158
+ if not shares_a_python(SpecifierSet(requires_python), SpecifierSet(rule.match.python))
159
+ ]
160
+
161
+ bench = data.get("tool", {}).get("bench", {})
162
+ if frappe_bound := bench.get("frappe-dependencies", {}).get("frappe"):
163
+ line = toml_line(lines, "tool.bench.frappe-dependencies", "frappe")
164
+ bound = semantic_version.SimpleSpec(frappe_bound)
165
+ findings += [
166
+ finding_on(rule, file, lines, line)
167
+ for rule in self.rules_by_type.get("frappe_bound", ())
168
+ if semantic_version.Version(rule.match.frappe) not in bound
169
+ ]
170
+
171
+ dependencies_line = toml_line(lines, "project", "dependencies")
172
+ for rule in self.rules_by_type.get("dependency_pins", ()):
173
+ pins = {canonicalize_name(pin.name): pin for pin in map(Requirement, rule.match.dependencies)}
174
+ for dependency in data.get("project", {}).get("dependencies", ()):
175
+ requirement = Requirement(dependency)
176
+ pin = pins.get(canonicalize_name(requirement.name))
177
+ if pin and not can_coexist(requirement.specifier, pin.specifier):
178
+ findings.append(
179
+ Finding(
180
+ rule=rule,
181
+ file=file,
182
+ line=quoted_line(lines, dependencies_line or 1, dependency),
183
+ snippet=f"{dependency} (frappe pins {pin})",
184
+ )
185
+ )
186
+ return findings
187
+
188
+ def scan_required_apps(self, source: str, file: str, apps_on_bench: set[str]) -> list[Finding]:
189
+ """Raises SyntaxError if `source` is not valid Python."""
190
+ lines = source.splitlines()
191
+ findings = []
192
+ for node in ast.parse(source, filename=file).body:
193
+ if not any(name.id == "required_apps" for name in assigned_names(node)):
194
+ continue
195
+ if not isinstance(node.value, ast.List | ast.Tuple):
196
+ continue
197
+ for entry in node.value.elts:
198
+ if (
199
+ isinstance(entry, ast.Constant)
200
+ and isinstance(entry.value, str)
201
+ and parse_required_app_name(entry.value) not in apps_on_bench
202
+ ):
203
+ findings += [
204
+ finding_on(rule, file, lines, entry.lineno)
205
+ for rule in self.rules_by_type.get("required_apps", ())
206
+ ]
207
+ return findings