periplus-map 0.2.1__py3-none-any.whl

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 (272) hide show
  1. periplus/__init__.py +70 -0
  2. periplus/checkmap.py +80 -0
  3. periplus/cli.py +658 -0
  4. periplus/contract/schema/map.schema.json +138 -0
  5. periplus/contract/schema/navigation.schema.json +200 -0
  6. periplus/contract/schema/pack-file.schema.json +1405 -0
  7. periplus/contract/schema/pack-manifest.schema.json +245 -0
  8. periplus/contract/schema/project-settings.schema.json +85 -0
  9. periplus/contract/spec/engine.md +151 -0
  10. periplus/contract/spec/rules.md +352 -0
  11. periplus/detect.py +228 -0
  12. periplus/engine/__init__.py +5 -0
  13. periplus/engine/check.py +481 -0
  14. periplus/engine/emit.py +368 -0
  15. periplus/engine/packload.py +2321 -0
  16. periplus/engine/pattern.py +27 -0
  17. periplus/engine/rules.py +1946 -0
  18. periplus/engine/select.py +491 -0
  19. periplus/engine/source.py +172 -0
  20. periplus/engine/tree.py +424 -0
  21. periplus/errors.py +332 -0
  22. periplus/init.py +358 -0
  23. periplus/layout.py +28 -0
  24. periplus/manifest.py +498 -0
  25. periplus/map.py +364 -0
  26. periplus/navigate/__init__.py +1 -0
  27. periplus/navigate/find.py +143 -0
  28. periplus/navigate/ids.py +30 -0
  29. periplus/navigate/listing.py +42 -0
  30. periplus/navigate/mapfile.py +189 -0
  31. periplus/navigate/path.py +175 -0
  32. periplus/navigate/show.py +175 -0
  33. periplus/navigate/types.py +83 -0
  34. periplus/navigate/walk.py +138 -0
  35. periplus/packs/advancedqueue_basic@0.0.1/pack.yaml +14 -0
  36. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type.yaml +9 -0
  37. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_annotation.yaml +43 -0
  38. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_attribute.yaml +43 -0
  39. periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent.yaml +8 -0
  40. periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent_attribute.yaml +43 -0
  41. periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call.yaml +8 -0
  42. periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call_attribute.yaml +43 -0
  43. periplus/packs/ai_basic@0.0.1/pack.yaml +15 -0
  44. periplus/packs/config_pages_basic@0.0.1/bundle/config_pages_bundle_config.yaml +54 -0
  45. periplus/packs/config_pages_basic@0.0.1/pack.yaml +11 -0
  46. periplus/packs/crop_basic@0.0.1/bundle/crop_bundle_config.yaml +54 -0
  47. periplus/packs/crop_basic@0.0.1/pack.yaml +11 -0
  48. periplus/packs/drupal@0.1.0/block/block_placement.yaml +25 -0
  49. periplus/packs/drupal@0.1.0/block/block_placement_code.yaml +58 -0
  50. periplus/packs/drupal@0.1.0/block/block_placement_config.yaml +53 -0
  51. periplus/packs/drupal@0.1.0/block/block_plugin.yaml +52 -0
  52. periplus/packs/drupal@0.1.0/block/block_plugin_altered.yaml +61 -0
  53. periplus/packs/drupal@0.1.0/block/block_plugin_annotation.yaml +69 -0
  54. periplus/packs/drupal@0.1.0/block/block_plugin_attribute.yaml +56 -0
  55. periplus/packs/drupal@0.1.0/block/block_plugin_content.yaml +46 -0
  56. periplus/packs/drupal@0.1.0/block/block_plugin_derived.yaml +110 -0
  57. periplus/packs/drupal@0.1.0/block/block_plugin_deriver.yaml +30 -0
  58. periplus/packs/drupal@0.1.0/block/block_type.yaml +14 -0
  59. periplus/packs/drupal@0.1.0/block/block_type_code.yaml +43 -0
  60. periplus/packs/drupal@0.1.0/block/block_type_config.yaml +45 -0
  61. periplus/packs/drupal@0.1.0/cache/cache.yaml +18 -0
  62. periplus/packs/drupal@0.1.0/cache/cache_backend.yaml +31 -0
  63. periplus/packs/drupal@0.1.0/component/sdc.yaml +19 -0
  64. periplus/packs/drupal@0.1.0/component/sdc_definition.yaml +25 -0
  65. periplus/packs/drupal@0.1.0/component/sdc_use.yaml +25 -0
  66. periplus/packs/drupal@0.1.0/config/config_object.yaml +65 -0
  67. periplus/packs/drupal@0.1.0/display/display.yaml +32 -0
  68. periplus/packs/drupal@0.1.0/display/display_code.yaml +53 -0
  69. periplus/packs/drupal@0.1.0/display/display_config.yaml +95 -0
  70. periplus/packs/drupal@0.1.0/entity/entity.yaml +33 -0
  71. periplus/packs/drupal@0.1.0/entity/entity_class.yaml +60 -0
  72. periplus/packs/drupal@0.1.0/entity/entity_save.yaml +27 -0
  73. periplus/packs/drupal@0.1.0/entity/entity_type_annotation.yaml +85 -0
  74. periplus/packs/drupal@0.1.0/entity/entity_type_attribute.yaml +76 -0
  75. periplus/packs/drupal@0.1.0/field/field_instance_code.yaml +40 -0
  76. periplus/packs/drupal@0.1.0/field/field_instance_config.yaml +58 -0
  77. periplus/packs/drupal@0.1.0/field/field_storage.yaml +37 -0
  78. periplus/packs/drupal@0.1.0/field/field_storage_code.yaml +39 -0
  79. periplus/packs/drupal@0.1.0/field/field_storage_config.yaml +24 -0
  80. periplus/packs/drupal@0.1.0/form/form.yaml +48 -0
  81. periplus/packs/drupal@0.1.0/form/form_class.yaml +50 -0
  82. periplus/packs/drupal@0.1.0/form/form_id.yaml +48 -0
  83. periplus/packs/drupal@0.1.0/form/form_methods.yaml +39 -0
  84. periplus/packs/drupal@0.1.0/hook/hook.yaml +8 -0
  85. periplus/packs/drupal@0.1.0/hook/hook_attribute.yaml +28 -0
  86. periplus/packs/drupal@0.1.0/module/module.yaml +8 -0
  87. periplus/packs/drupal@0.1.0/module/module_info.yaml +50 -0
  88. periplus/packs/drupal@0.1.0/pack.yaml +184 -0
  89. periplus/packs/drupal@0.1.0/plugin/plugin.yaml +17 -0
  90. periplus/packs/drupal@0.1.0/plugin/plugin_annotation.yaml +120 -0
  91. periplus/packs/drupal@0.1.0/recommendations/recommendations.yaml +14 -0
  92. periplus/packs/drupal@0.1.0/service/service.yaml +19 -0
  93. periplus/packs/drupal@0.1.0/service/service_autowire.yaml +28 -0
  94. periplus/packs/drupal@0.1.0/service/service_definition.yaml +24 -0
  95. periplus/packs/drupal@0.1.0/service/service_use.yaml +43 -0
  96. periplus/packs/drupal_basic@0.3.0/action/action.yaml +9 -0
  97. periplus/packs/drupal_basic@0.3.0/action/action_annotation.yaml +43 -0
  98. periplus/packs/drupal_basic@0.3.0/action/action_attribute.yaml +43 -0
  99. periplus/packs/drupal_basic@0.3.0/block/block_placement.yaml +16 -0
  100. periplus/packs/drupal_basic@0.3.0/block/block_placement_config.yaml +51 -0
  101. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin.yaml +14 -0
  102. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_annotation.yaml +47 -0
  103. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_attribute.yaml +43 -0
  104. periplus/packs/drupal_basic@0.3.0/block_type/block_type.yaml +8 -0
  105. periplus/packs/drupal_basic@0.3.0/block_type/block_type_config.yaml +54 -0
  106. periplus/packs/drupal_basic@0.3.0/boundary/boundary_types.yaml +19 -0
  107. periplus/packs/drupal_basic@0.3.0/bundle/bundle.yaml +20 -0
  108. periplus/packs/drupal_basic@0.3.0/bundle/bundle_config.yaml +393 -0
  109. periplus/packs/drupal_basic@0.3.0/call/call.yaml +349 -0
  110. periplus/packs/drupal_basic@0.3.0/component/component.yaml +30 -0
  111. periplus/packs/drupal_basic@0.3.0/config/config_object.yaml +119 -0
  112. periplus/packs/drupal_basic@0.3.0/constraint/constraint.yaml +8 -0
  113. periplus/packs/drupal_basic@0.3.0/constraint/constraint_annotation.yaml +43 -0
  114. periplus/packs/drupal_basic@0.3.0/constraint/constraint_attribute.yaml +43 -0
  115. periplus/packs/drupal_basic@0.3.0/display/display.yaml +30 -0
  116. periplus/packs/drupal_basic@0.3.0/display/display_config.yaml +203 -0
  117. periplus/packs/drupal_basic@0.3.0/display/display_mode_config.yaml +60 -0
  118. periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_annotation.yaml +46 -0
  119. periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_attribute.yaml +43 -0
  120. periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_annotation.yaml +46 -0
  121. periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_attribute.yaml +43 -0
  122. periplus/packs/drupal_basic@0.3.0/entity_type/entity_type.yaml +16 -0
  123. periplus/packs/drupal_basic@0.3.0/field/field_instance_config.yaml +116 -0
  124. periplus/packs/drupal_basic@0.3.0/field/field_storage.yaml +21 -0
  125. periplus/packs/drupal_basic@0.3.0/field/field_storage_config.yaml +45 -0
  126. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter.yaml +8 -0
  127. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_annotation.yaml +46 -0
  128. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_attribute.yaml +43 -0
  129. periplus/packs/drupal_basic@0.3.0/field_type/field_type_annotation.yaml +46 -0
  130. periplus/packs/drupal_basic@0.3.0/field_type/field_type_attribute.yaml +50 -0
  131. periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_annotation.yaml +46 -0
  132. periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_attribute.yaml +50 -0
  133. periplus/packs/drupal_basic@0.3.0/form/form_id.yaml +80 -0
  134. periplus/packs/drupal_basic@0.3.0/hook/hook.yaml +100 -0
  135. periplus/packs/drupal_basic@0.3.0/hook/hook_attribute.yaml +64 -0
  136. periplus/packs/drupal_basic@0.3.0/hook/hook_theme_include.yaml +34 -0
  137. periplus/packs/drupal_basic@0.3.0/library/library.yaml +12 -0
  138. periplus/packs/drupal_basic@0.3.0/library/library_definition.yaml +58 -0
  139. periplus/packs/drupal_basic@0.3.0/local_action/local_action.yaml +12 -0
  140. periplus/packs/drupal_basic@0.3.0/local_action/local_action_definition.yaml +49 -0
  141. periplus/packs/drupal_basic@0.3.0/mail/mail.yaml +8 -0
  142. periplus/packs/drupal_basic@0.3.0/mail/mail_attribute.yaml +43 -0
  143. periplus/packs/drupal_basic@0.3.0/media_source/media_source.yaml +8 -0
  144. periplus/packs/drupal_basic@0.3.0/media_source/media_source_annotation.yaml +43 -0
  145. periplus/packs/drupal_basic@0.3.0/media_source/media_source_attribute.yaml +79 -0
  146. periplus/packs/drupal_basic@0.3.0/menu_link/menu_link.yaml +18 -0
  147. periplus/packs/drupal_basic@0.3.0/menu_link/menu_link_definition.yaml +48 -0
  148. periplus/packs/drupal_basic@0.3.0/module/module.yaml +7 -0
  149. periplus/packs/drupal_basic@0.3.0/module/module_info.yaml +58 -0
  150. periplus/packs/drupal_basic@0.3.0/pack.yaml +528 -0
  151. periplus/packs/drupal_basic@0.3.0/permission/permission.yaml +7 -0
  152. periplus/packs/drupal_basic@0.3.0/permission/permission_definition.yaml +27 -0
  153. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker.yaml +9 -0
  154. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_annotation.yaml +43 -0
  155. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_attribute.yaml +43 -0
  156. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource.yaml +8 -0
  157. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_annotation.yaml +43 -0
  158. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_attribute.yaml +43 -0
  159. periplus/packs/drupal_basic@0.3.0/role/role.yaml +14 -0
  160. periplus/packs/drupal_basic@0.3.0/role/role_config.yaml +44 -0
  161. periplus/packs/drupal_basic@0.3.0/route/route.yaml +42 -0
  162. periplus/packs/drupal_basic@0.3.0/route/route_definition.yaml +113 -0
  163. periplus/packs/drupal_basic@0.3.0/route/route_options.yaml +55 -0
  164. periplus/packs/drupal_basic@0.3.0/route/route_requirements.yaml +108 -0
  165. periplus/packs/drupal_basic@0.3.0/service/service.yaml +56 -0
  166. periplus/packs/drupal_basic@0.3.0/service/service_definition.yaml +181 -0
  167. periplus/packs/drupal_basic@0.3.0/template/template.yaml +86 -0
  168. periplus/packs/drupal_basic@0.3.0/template/template_route_link.yaml +30 -0
  169. periplus/packs/drupal_basic@0.3.0/theme/theme.yaml +15 -0
  170. periplus/packs/drupal_basic@0.3.0/theme/theme_info.yaml +61 -0
  171. periplus/packs/drupal_basic@0.3.0/theme_hook/template_suggestion.yaml +29 -0
  172. periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook.yaml +87 -0
  173. periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook_render.yaml +20 -0
  174. periplus/packs/drupal_basic@0.3.0/url/url.yaml +88 -0
  175. periplus/packs/drupal_basic@0.3.0/views_plugin/views_field_attribute.yaml +80 -0
  176. periplus/packs/drupal_basic@0.3.0/views_plugin/views_filter_attribute.yaml +80 -0
  177. periplus/packs/drupal_basic@0.3.0/views_plugin/views_plugin.yaml +15 -0
  178. periplus/packs/drupal_basic@0.3.0/views_plugin/views_style_attribute.yaml +80 -0
  179. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment.yaml +12 -0
  180. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php.yaml +15 -0
  181. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php_array.yaml +52 -0
  182. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_twig.yaml +26 -0
  183. periplus/packs/drupal_js_basic@0.0.1/attachment/php_function_holder.yaml +13 -0
  184. periplus/packs/drupal_js_basic@0.0.1/behavior/behavior.yaml +198 -0
  185. periplus/packs/drupal_js_basic@0.0.1/pack.yaml +153 -0
  186. periplus/packs/drupal_js_basic@0.0.1/setting/setting.yaml +13 -0
  187. periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_js.yaml +15 -0
  188. periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_php.yaml +92 -0
  189. periplus/packs/drush_basic@0.0.1/command/command.yaml +15 -0
  190. periplus/packs/drush_basic@0.0.1/command/command_annotation.yaml +60 -0
  191. periplus/packs/drush_basic@0.0.1/command/command_attribute.yaml +88 -0
  192. periplus/packs/drush_basic@0.0.1/pack.yaml +29 -0
  193. periplus/packs/eck_basic@0.0.1/bundle/eck_bundle_config.yaml +56 -0
  194. periplus/packs/eck_basic@0.0.1/entity_type/eck_entity_type_config.yaml +26 -0
  195. periplus/packs/eck_basic@0.0.1/pack.yaml +28 -0
  196. periplus/packs/go@0.0.2/interfaces/satisfies.yaml +47 -0
  197. periplus/packs/go@0.0.2/pack.yaml +51 -0
  198. periplus/packs/go@0.0.2/types/find_declarations.yaml +137 -0
  199. periplus/packs/go@0.0.2/types/node_types.yaml +36 -0
  200. periplus/packs/go_basic@0.0.3/pack.yaml +104 -0
  201. periplus/packs/go_basic@0.0.3/rules/calls.yaml +25 -0
  202. periplus/packs/go_basic@0.0.3/rules/declarations.yaml +40 -0
  203. periplus/packs/go_basic@0.0.3/rules/imports.yaml +16 -0
  204. periplus/packs/go_basic@0.0.3/types/node_types.yaml +15 -0
  205. periplus/packs/js_basic@0.0.1/function/function.yaml +68 -0
  206. periplus/packs/js_basic@0.0.1/pack.yaml +50 -0
  207. periplus/packs/laravel_basic@0.0.5/pack.yaml +313 -0
  208. periplus/packs/laravel_basic@0.0.5/rules/classes.yaml +42 -0
  209. periplus/packs/laravel_basic@0.0.5/rules/config.yaml +229 -0
  210. periplus/packs/laravel_basic@0.0.5/rules/routes.yaml +210 -0
  211. periplus/packs/laravel_basic@0.0.5/rules/types.yaml +50 -0
  212. periplus/packs/laravel_basic@0.0.5/rules/views.yaml +100 -0
  213. periplus/packs/paragraphs_basic@0.0.1/bundle/paragraph_bundle_config.yaml +54 -0
  214. periplus/packs/paragraphs_basic@0.0.1/pack.yaml +12 -0
  215. periplus/packs/php@0.1.0/edges/edge_kinds.yaml +64 -0
  216. periplus/packs/php@0.1.0/edges/find_calls.yaml +34 -0
  217. periplus/packs/php@0.1.0/edges/find_imports.yaml +18 -0
  218. periplus/packs/php@0.1.0/edges/find_member_access.yaml +22 -0
  219. periplus/packs/php@0.1.0/edges/find_structure.yaml +38 -0
  220. periplus/packs/php@0.1.0/embedded/embedded_regions.yaml +19 -0
  221. periplus/packs/php@0.1.0/imports/imports.yaml +20 -0
  222. periplus/packs/php@0.1.0/pack.yaml +117 -0
  223. periplus/packs/php@0.1.0/resolution/excluded_tokens.yaml +23 -0
  224. periplus/packs/php@0.1.0/resolution/resolution.yaml +35 -0
  225. periplus/packs/php@0.1.0/types/find_declarations.yaml +102 -0
  226. periplus/packs/php@0.1.0/types/node_types.yaml +55 -0
  227. periplus/packs/php_basic@0.2.0/edges/edge_kinds.yaml +48 -0
  228. periplus/packs/php_basic@0.2.0/edges/find_structure.yaml +750 -0
  229. periplus/packs/php_basic@0.2.0/imports/imports.yaml +19 -0
  230. periplus/packs/php_basic@0.2.0/pack.yaml +94 -0
  231. periplus/packs/php_basic@0.2.0/types/find_declarations.yaml +267 -0
  232. periplus/packs/php_basic@0.2.0/types/node_types.yaml +55 -0
  233. periplus/packs/profile_basic@0.0.1/bundle/profile_bundle_config.yaml +54 -0
  234. periplus/packs/profile_basic@0.0.1/pack.yaml +12 -0
  235. periplus/packs/salesforce_basic@0.0.1/event/salesforce_event.yaml +36 -0
  236. periplus/packs/salesforce_basic@0.0.1/pack.yaml +31 -0
  237. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping.yaml +19 -0
  238. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping_config.yaml +83 -0
  239. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field.yaml +8 -0
  240. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field_annotation.yaml +43 -0
  241. periplus/packs/twig@0.0.1/pack.yaml +26 -0
  242. periplus/packs/twig@0.0.1/types/template.yaml +57 -0
  243. periplus/packs/twig_basic@0.1.0/pack.yaml +11 -0
  244. periplus/packs/twig_tweak_basic@0.0.1/pack.yaml +27 -0
  245. periplus/packs/twig_tweak_basic@0.0.1/rules/block.yaml +20 -0
  246. periplus/packs/twig_tweak_basic@0.0.1/rules/entity.yaml +35 -0
  247. periplus/packs/twig_tweak_basic@0.0.1/rules/field.yaml +21 -0
  248. periplus/packs/twig_tweak_basic@0.0.1/rules/menu.yaml +21 -0
  249. periplus/packs/webform_basic@0.0.1/bundle/webform_submission_bundle_config.yaml +54 -0
  250. periplus/packs/webform_basic@0.0.1/pack.yaml +20 -0
  251. periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler.yaml +8 -0
  252. periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler_annotation.yaml +43 -0
  253. periplus/packs/yaml@0.0.1/pack.yaml +72 -0
  254. periplus/packs/yaml@0.0.1/types/find_mapping_entry.yaml +21 -0
  255. periplus/packs/yaml@0.0.1/types/yaml_types.yaml +44 -0
  256. periplus/packs/yaml_basic@0.1.0/pack.yaml +10 -0
  257. periplus/packs.py +447 -0
  258. periplus/preflight.py +102 -0
  259. periplus/py.typed +0 -0
  260. periplus/report.py +596 -0
  261. periplus/resolution.py +355 -0
  262. periplus/settings.py +617 -0
  263. periplus/settings.stub.yml +99 -0
  264. periplus/spec.py +774 -0
  265. periplus/update.py +197 -0
  266. periplus/validate.py +655 -0
  267. periplus_map-0.2.1.dist-info/METADATA +566 -0
  268. periplus_map-0.2.1.dist-info/RECORD +272 -0
  269. periplus_map-0.2.1.dist-info/WHEEL +4 -0
  270. periplus_map-0.2.1.dist-info/entry_points.txt +2 -0
  271. periplus_map-0.2.1.dist-info/licenses/LICENSE +202 -0
  272. periplus_map-0.2.1.dist-info/licenses/NOTICE +2 -0
periplus/packs.py ADDED
@@ -0,0 +1,447 @@
1
+ """Pack resolution: where packs are looked for, what directories are there, and what a pin matches.
2
+
3
+ Three questions and one fact. Where — ``resolve_pack_roots``. What is there —
4
+ ``discover_candidates``. Which of them a ``packs:`` pin names — ``match_pins``. The fact is
5
+ ``bundled_digest``, a hash of the bundled pack files, which tells two installs apart.
6
+
7
+ **No file inside a pack directory is opened, except by ``bundled_digest``.** It is the one
8
+ function here that opens pack files, and it only hashes their bytes. Not a manifest, not
9
+ ``pack.yaml``, not anything else. That
10
+ is a deliberate limit and the reason this module is small: resolving a pin, detecting a
11
+ duplicate and refusing a version mismatch are judgments about pack *validity*, and a harness making
12
+ them would have to know what a ``folders`` block is before anything knows what a pack is. A pack
13
+ directory is named ``<pack>@<version>``, so a pin can be matched against directory names alone. The
14
+ validator later confirms the manifest inside agrees with the name outside — a check this module
15
+ could never make.
16
+
17
+ The only ``Traversable`` operations this module calls are ``is_dir``, ``is_file``, ``iterdir`` and
18
+ ``name``, plus the one ``joinpath`` that reaches the bundled root itself. Apart from
19
+ ``bundled_digest``'s ``read_bytes``, it never calls ``open`` or ``read_text``, and it never
20
+ calls ``builtins.open``.
21
+
22
+ Nothing here is rendered. A directory name is attacker-influenced text and is stored exactly as it
23
+ is on disk; escaping it is ``report.py``'s work, and escaping in the producer would make the escaped
24
+ form the value and let two escapes be applied.
25
+
26
+ This module ships no ``HarnessError`` subclass. ``match_pins`` returns ``Problem`` records rather
27
+ than raising, because one call has to report every unmatched pin and an exception carries one —
28
+ three unmatched pins produce three problems in a single run.
29
+
30
+ A note on the name, because the collision is deliberate and easy to misread. ``periplus.packs`` is
31
+ this module *and* the bundled pack data at ``src/periplus/packs/``. A regular module wins over a
32
+ namespace-package portion in the import system, so ``import periplus.packs`` reaches this file;
33
+ nothing imports the data directory as a package, and ``importlib.resources`` reaches it as a
34
+ resource rather than as a module.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import importlib.resources
41
+ from collections.abc import Iterable, Sequence
42
+ from dataclasses import dataclass
43
+ from importlib.resources.abc import Traversable
44
+ from pathlib import Path
45
+ from typing import Literal
46
+
47
+ from periplus.errors import ExitCode, Problem
48
+ from periplus.layout import PACKS_DIRECTORY as PACKS_DIRECTORY
49
+ from periplus.settings import PROJECT_MARKER, ResolvedSetting, SourceRef
50
+
51
+ __all__ = [
52
+ "PACKS_DIRECTORY",
53
+ "MatchedPack",
54
+ "PackCandidate",
55
+ "PackName",
56
+ "PackRoot",
57
+ "bundled_digest",
58
+ "discover_candidates",
59
+ "match_pins",
60
+ "resolve_pack_roots",
61
+ ]
62
+
63
+ #: The directory segment that holds packs, at the project root, at the user level and inside the
64
+ #: install. One name because it is one convention: a person who knows where their packs go at one
65
+ #: level knows it at all three.
66
+
67
+ # The four kinds of root and the three statuses an entry can have are written out at every
68
+ # annotation rather than named once as a module-level alias. `settings.py` records why: mypy
69
+ # normalises a `Literal` alias into a union of literals while the runtime object is not a union, so
70
+ # a tool that compares the declared types against the imported module reports the module as broken
71
+ # when it is correct.
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class PackRoot:
76
+ """One directory on the pack search path, whether or not it is there.
77
+
78
+ ``display`` is ``str(traversable)`` for every root, with no special case. ``Traversable`` does
79
+ not guarantee a meaningful ``str()``, and that is accepted: the value is only ever rendered,
80
+ every implementation in the standard library produces something a person can read, and a branch
81
+ here would be a second place for the bundled root's name to be decided.
82
+
83
+ ``exists`` is ``traversable.is_dir()``. ``Traversable`` declares no ``exists()`` at all.
84
+
85
+ ``source`` names the settings file that added a configured root, and is ``None`` for the three
86
+ fixed ones — including a fixed root a settings file also named, which the file did not add.
87
+ """
88
+
89
+ kind: Literal["project", "user", "bundled", "configured"]
90
+ display: str
91
+ traversable: Traversable
92
+ exists: bool
93
+ source: SourceRef | None
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class PackName:
98
+ """A directory name split at its single ``@``. Parsed from the name, never read from a file.
99
+
100
+ Neither half is checked further. The version is not required to be a version and the pack name
101
+ is not required to be anything, because checking either is validation and validation is the
102
+ next task's whole subject.
103
+ """
104
+
105
+ pack: str
106
+ version: str
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class PackCandidate:
111
+ """One entry directly under one root, and what its name makes of it.
112
+
113
+ Every entry is a candidate whatever its type. The type decides the ``status``, not whether the
114
+ entry appears — every candidate directory is reported, and the whole
115
+ point of ``unnamed`` is the difference between "there is nothing there" and "there is something
116
+ there that no pin can ever match".
117
+
118
+ ``not_a_directory`` is kept because a ``pack.yaml`` or a
119
+ ``README.md`` sitting loose under a pack root is a thing a person put there: reporting it
120
+ costs one enum member on a branch that already exists, and dropping it silently would hide the
121
+ one case where a name a pin could match is not a directory.
122
+
123
+ ``indeterminate`` is what ``is_dir()`` and ``is_file()`` both denying looks like: a broken
124
+ symlink, a socket or a device node, or an entry under a root that can be listed and not
125
+ traversed — a directory at mode ``0o444`` lists its entries and refuses to stat any of them, so
126
+ a real pack directory under it answers ``False`` to both questions. The status is defined by
127
+ what was observed and not by a cause the harness would have to guess at. It exists because
128
+ ``not_a_directory`` is an assertion, and asserting that a directory is not a directory is a
129
+ report that lies; "I could not tell" is the true answer and is a different thing for a person
130
+ to do about it.
131
+ """
132
+
133
+ root: PackRoot
134
+ entry: str
135
+ name: PackName | None
136
+ status: Literal["named", "unnamed", "not_a_directory", "indeterminate"]
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class MatchedPack:
141
+ """A pin and the directory whose name it matched, under the root that held it.
142
+
143
+ ``path`` is a ``str`` and not a ``Path`` because a pack matched under the bundled root has no
144
+ ``Path`` — it has a ``Traversable``, which is not guaranteed to be one. One type for all four
145
+ roots is worth more than a ``Path`` for three of them, and the value is only ever rendered.
146
+ """
147
+
148
+ pin: str
149
+ name: PackName
150
+ root: PackRoot
151
+ path: str
152
+
153
+
154
+ def resolve_pack_roots(
155
+ project_root: Path | None,
156
+ user_config_dir: Path,
157
+ configured: ResolvedSetting[tuple[Path, ...]] | None,
158
+ ) -> tuple[PackRoot, ...]:
159
+ """The pack search path, in fixed order, de-duplicated, with every root reported present or not.
160
+
161
+ Four sources in one order that never decides an outcome: the project's own
162
+ ``<root>/.periplus/packs``, the person's ``<user config dir>/packs``, the install's own
163
+ ``periplus/packs`` through ``importlib.resources``, then every ``pack_paths`` entry from either
164
+ settings file. A pin matching under two roots is a problem naming both, not a first-wins pick,
165
+ so the order is there to make the output stable and for nothing else.
166
+
167
+ A ``project_root`` of ``None`` produces no project record. ``absent`` is for a directory
168
+ that has a path and is not there; a root with no path has nothing to report, and printing one
169
+ would name a directory nothing ever intended to search. The user root and the bundled root are
170
+ still returned, because both are knowable with no project and no settings at all.
171
+
172
+ A ``configured`` of ``None`` is the ordinary case, not an error: a key no settings file declared
173
+ merges to ``None`` rather than to an empty value, and the three fixed roots come back alone.
174
+
175
+ The four are de-duplicated in order, first occurrence winning, so a ``pack_paths`` entry
176
+ naming a directory already on the fixed list changes nothing — it was already going to be
177
+ searched, in that position. The surviving root keeps its fixed ``kind`` and its ``source``
178
+ stays ``None``. Without this, every directory under that path is discovered twice and every pin
179
+ matching one of them fails the run for a duplicate that does not exist, which is the same
180
+ hazard ``settings.py`` records as the reason ``pack_paths`` merges by union.
181
+
182
+ Comparison is equality between the lexically normalised paths ``settings.py`` produced — no
183
+ ``resolve()``, no symlink following. Two spellings of one directory that differ after
184
+ normalisation are two roots here, and a pin matching under both reports a duplicate. That is
185
+ what the harness can see without resolving a path, and resolving one would print a directory the
186
+ person never named.
187
+
188
+ A root that is not a ``Path`` at all cannot be compared with one and is never de-duplicated. On
189
+ every install this project has seen, the bundled root *is* a ``Path`` and de-duplicates like the
190
+ others; the branch exists for the installs where it is not.
191
+ """
192
+ found: list[PackRoot] = []
193
+ if project_root is not None:
194
+ found.append(_root("project", project_root / PROJECT_MARKER / PACKS_DIRECTORY, None))
195
+ found.append(_root("user", user_config_dir / PACKS_DIRECTORY, None))
196
+ found.append(_root("bundled", _bundled_root(), None))
197
+ if configured is not None:
198
+ # `entry_sources` is one reference per entry of `value`, in the same order, because
199
+ # `settings._merge_sequence` appends to both in one loop body — so indexing it positionally
200
+ # is safe by construction. Never zip against `sources`, which is
201
+ # one reference per contributing *file*: the two can have the same length and different
202
+ # content, and zipping produces a plausible, silent misattribution.
203
+ entry_sources = configured.entry_sources
204
+ for index, path in enumerate(configured.value):
205
+ source = entry_sources[index] if entry_sources is not None else None
206
+ found.append(_root("configured", path, source))
207
+
208
+ roots: list[PackRoot] = []
209
+ seen: set[Path] = set()
210
+ for root in found:
211
+ key = root.traversable
212
+ if isinstance(key, Path):
213
+ if key in seen:
214
+ continue
215
+ seen.add(key)
216
+ roots.append(root)
217
+ return tuple(roots)
218
+
219
+
220
+ def _bundled_root() -> Traversable:
221
+ # The import package's own name, not the distribution's. `importlib.resources` reaches the
222
+ # bundled data directory as a resource; the module you are reading shadows it as a module.
223
+ return importlib.resources.files("periplus") / PACKS_DIRECTORY
224
+
225
+
226
+ def bundled_digest() -> str:
227
+ """The sha256 of every file under the bundled packs, in sorted path order.
228
+
229
+ Each file contributes its path relative to the bundled root, then its bytes, so a renamed file
230
+ changes the digest as a changed one does. Two installs carry the same rutters exactly when this
231
+ string is equal.
232
+ """
233
+ files: dict[str, Traversable] = {}
234
+ pending: list[tuple[str, Traversable]] = [("", _bundled_root())]
235
+ while pending:
236
+ prefix, folder = pending.pop()
237
+ try:
238
+ entries = tuple(folder.iterdir())
239
+ except OSError:
240
+ # An absent or unreadable folder contributes nothing, as in `discover_candidates`.
241
+ continue
242
+ for entry in entries:
243
+ name = f"{prefix}{entry.name}"
244
+ if entry.is_dir():
245
+ pending.append((f"{name}/", entry))
246
+ else:
247
+ files[name] = entry
248
+ digest = hashlib.sha256()
249
+ for name in sorted(files):
250
+ digest.update(name.encode("utf-8") + b"\0" + files[name].read_bytes() + b"\0")
251
+ return digest.hexdigest()
252
+
253
+
254
+ def _root(
255
+ kind: Literal["project", "user", "bundled", "configured"],
256
+ traversable: Traversable,
257
+ source: SourceRef | None,
258
+ ) -> PackRoot:
259
+ """One root record. ``is_dir()`` is the only filesystem call, and nothing is opened."""
260
+ return PackRoot(
261
+ kind=kind,
262
+ display=str(traversable),
263
+ traversable=traversable,
264
+ exists=traversable.is_dir(),
265
+ source=source,
266
+ )
267
+
268
+
269
+ def discover_candidates(roots: Sequence[PackRoot]) -> tuple[PackCandidate, ...]:
270
+ """Every entry directly under every root that is there, in root order then name order.
271
+
272
+ One ``iterdir()`` per existing root. One level: no recursion, no second ``iterdir``, no
273
+ ``joinpath`` past the root — so a symlinked directory under a root cannot walk anywhere, and a
274
+ pack's contents are never reached.
275
+
276
+ Sorted by root order, then by entry name, by code point. Nothing iterates a directory in
277
+ filesystem order: on ext4 with hashed directories that order is arbitrary, and output that
278
+ agreed with it would differ between two machines holding the same tree.
279
+
280
+ A root that exists and cannot be listed contributes no candidates. ``iterdir`` raises
281
+ ``OSError`` on a directory with no read permission and it is caught; the root stays in the
282
+ report with ``exists`` true. It is not a failure on its own — a pin that needed one of its
283
+ directories fails when it is matched, and that message names the roots that were searched.
284
+ """
285
+ candidates: list[PackCandidate] = []
286
+ for root in roots:
287
+ if not root.exists:
288
+ continue
289
+ try:
290
+ # The sort is inside the `try` because that is where `Path.iterdir` raises on the
291
+ # declared floor and it is not the same place on every version. On 3.11 it is a
292
+ # generator function and a `PermissionError` surfaces when the result is walked; on
293
+ # 3.14 it raises at the call. Verified on both, by running the closed-directory case
294
+ # with the sort moved out: 3.14 still catches it and 3.11 lets it escape. So this is a
295
+ # 3.11 requirement that the machine this was written on cannot demonstrate.
296
+ entries = sorted(root.traversable.iterdir(), key=lambda entry: entry.name)
297
+ except OSError:
298
+ continue
299
+ candidates.extend(_candidate(root, entry) for entry in entries)
300
+ return tuple(candidates)
301
+
302
+
303
+ def _candidate(root: PackRoot, entry: Traversable) -> PackCandidate:
304
+ """One entry classified by its type and then by its name.
305
+
306
+ ``is_file()`` is asked only when ``is_dir()`` has already said no, so a pack directory — the
307
+ ordinary case — pays for one probe and not two. The answer separates an entry that is a file
308
+ from an entry neither question would claim, which is the whole of ``indeterminate``.
309
+ """
310
+ if not entry.is_dir():
311
+ status: Literal["not_a_directory", "indeterminate"] = (
312
+ "not_a_directory" if entry.is_file() else "indeterminate"
313
+ )
314
+ return PackCandidate(root=root, entry=entry.name, name=None, status=status)
315
+ name = parse_pack_name(entry.name)
316
+ if name is None:
317
+ return PackCandidate(root=root, entry=entry.name, name=None, status="unnamed")
318
+ return PackCandidate(root=root, entry=entry.name, name=name, status="named")
319
+
320
+
321
+ def parse_pack_name(entry: str) -> PackName | None:
322
+ """``<pack>@<version>`` split at its single ``@``, or ``None`` when the name is not that shape.
323
+
324
+ Exactly one ``@`` and both sides non-empty. Nothing further is checked, so ``drupal``,
325
+ ``a@b@c``, ``@1.0.0`` and ``drupal@`` are all unnamed. Whether the version is a version and
326
+ whether the pack name is a pack name are questions about validity, and this module answers none.
327
+ """
328
+ if entry.count("@") != 1:
329
+ return None
330
+ pack, _, version = entry.partition("@")
331
+ if not pack or not version:
332
+ return None
333
+ return PackName(pack=pack, version=version)
334
+
335
+
336
+ def match_pins(
337
+ pins: Sequence[str],
338
+ candidates: Sequence[PackCandidate],
339
+ ) -> tuple[tuple[MatchedPack, ...], tuple[Problem, ...]]:
340
+ """Match each pin against directory names alone. Returns matches and problems, and never raises.
341
+
342
+ A name matches a pin when the candidate's status is ``named`` and its entry string equals the
343
+ pin, byte for byte — no case folding, no Unicode normalisation, no whitespace stripping, no
344
+ version comparison. ``drupal@0.1.0`` matches ``drupal@0.1.0`` and nothing else.
345
+
346
+ Three outcomes and no fourth:
347
+
348
+ * Matched once — one ``MatchedPack``, no problem.
349
+ * Matched nowhere — one problem at ``PIN_UNMATCHED``, naming the roots that were searched and
350
+ every near miss. A pin that does not itself parse falls here with an empty near-miss list; it
351
+ is not a new failure class, because "matched nowhere" is already true of it.
352
+ * Matched under two roots — one problem at ``PIN_DUPLICATED`` naming both roots, **and both
353
+ matches are still returned**. The name is reported twice rather than
354
+ resolved, so dropping either match would report it once and resolve it by omission.
355
+
356
+ A near miss is a ``named`` candidate whose *parsed pack name* equals the pin's, and whose entry
357
+ is not the pin. Parsed rather than a prefix: under a prefix comparison a pin of ``php@0.1.0``
358
+ would near-miss ``phpstan@1.0.0``, which is a different pack.
359
+
360
+ A directory on the path that no pin names is reported present and unmatched, and is not a
361
+ problem. This function never decides whether it is called at all — matching is skipped when
362
+ settings gave no pins, and that is ``resolution.py``'s call.
363
+
364
+ The exit status is not decided here. ``PIN_UNMATCHED`` and ``PIN_DUPLICATED`` are set on the
365
+ ``Problem``, and ``ResolutionReport.exit_code`` takes the lowest code among every problem found.
366
+ """
367
+ matches: list[MatchedPack] = []
368
+ problems: list[Problem] = []
369
+ for pin in pins:
370
+ # The walrus binds the parsed name the candidate already carries, so a match never
371
+ # re-parses a directory name that discovery has parsed once. A candidate carries a name
372
+ # exactly when its status is `named`, which is how `_candidate` builds it.
373
+ found = [
374
+ (candidate, name)
375
+ for candidate in candidates
376
+ if (name := candidate.name) is not None and candidate.entry == pin
377
+ ]
378
+ matches.extend(_matched(pin, candidate, name) for candidate, name in found)
379
+ if not found:
380
+ problems.append(_unmatched(pin, candidates))
381
+ elif len(found) > 1:
382
+ problems.append(_duplicated(pin, [candidate for candidate, _ in found]))
383
+ return tuple(matches), tuple(problems)
384
+
385
+
386
+ def _matched(pin: str, candidate: PackCandidate, name: PackName) -> MatchedPack:
387
+ """One matched pin, under the root that held it."""
388
+ return MatchedPack(
389
+ pin=pin,
390
+ name=name,
391
+ root=candidate.root,
392
+ # Joined lexically from the root's printable name. `Traversable.joinpath` is deliberately
393
+ # not used: this module joins nothing past a root, and the value is only ever rendered.
394
+ path=str(Path(candidate.root.display) / candidate.entry),
395
+ )
396
+
397
+
398
+ def _unmatched(pin: str, candidates: Sequence[PackCandidate]) -> Problem:
399
+ """A pin no directory name matched, with the near misses and the roots that were listed.
400
+
401
+ ``searched`` names the roots the candidates came from rather than every root on the path,
402
+ because this function is given candidates and not roots. A root that exists and holds nothing —
403
+ or one that could not be listed — contributes no candidate and so is not named here. The report
404
+ as a whole still carries every root with its own ``exists``, which is where a person reads the
405
+ full list; this detail is the near-at-hand half of it.
406
+ """
407
+ pack = parse_pack_name(pin)
408
+ near = [
409
+ candidate.entry
410
+ for candidate in candidates
411
+ if candidate.status == "named"
412
+ and pack is not None
413
+ and candidate.name is not None
414
+ and candidate.name.pack == pack.pack
415
+ and candidate.entry != pin
416
+ ]
417
+ return Problem(
418
+ code=ExitCode.PIN_UNMATCHED,
419
+ message=f"no pack directory is named {pin!r} on the pack search path",
420
+ detail={
421
+ "pin": pin,
422
+ "searched": _joined(dict.fromkeys(c.root.display for c in candidates)),
423
+ "near_misses": _joined(dict.fromkeys(near)),
424
+ },
425
+ )
426
+
427
+
428
+ def _duplicated(pin: str, found: Sequence[PackCandidate]) -> Problem:
429
+ """One name under two roots. Both roots are named; both matches are returned by the caller."""
430
+ return Problem(
431
+ code=ExitCode.PIN_DUPLICATED,
432
+ message=f"the pack directory {pin!r} was found under more than one root",
433
+ detail={
434
+ "pin": pin,
435
+ "roots": _joined(candidate.root.display for candidate in found),
436
+ },
437
+ )
438
+
439
+
440
+ def _joined(values: Iterable[str]) -> str:
441
+ """Directory names and root names as one string, because ``Problem.detail`` maps names to text.
442
+
443
+ ``, `` rather than a newline: a name holding a separator is ambiguous either way, and a newline
444
+ would let a directory name forge a line of the text report before ``report.py`` ever sees it.
445
+ An empty string means none, which is a fact rather than a missing key.
446
+ """
447
+ return ", ".join(values)
periplus/preflight.py ADDED
@@ -0,0 +1,102 @@
1
+ """The runtime dependency check: what the package declares it needs, and what is actually there.
2
+
3
+ This module exists because of one requirement — "the command exits non-zero naming
4
+ which one, **rather than failing later inside a subcommand**". Answering the first half needs
5
+ nothing; a missing import raises on its own and the status is non-zero. The second half is the
6
+ whole design: the failure has to be found before anything that would trip over it is imported. So
7
+ ``cli`` imports this module and ``errors`` at module level and imports ``periplus.resolution``
8
+ inside ``main()``, and this file is what runs in the gap.
9
+
10
+ That is also why the check reads installed metadata instead of importing the packages. Importing
11
+ ``ruamel.yaml`` to find out whether ``ruamel.yaml`` is importable would raise the exact
12
+ ``ImportError`` that must not be what the user sees.
13
+
14
+ Standard library only, and nothing here or on ``cli``'s module-level import path may import
15
+ ``ruamel.yaml``, ``platformdirs`` or ``jsonschema``. See ``errors`` for the two-clause form of that
16
+ property and the test that holds it.
17
+
18
+ This module reports and does not decide. It returns statuses; ``main`` turns an absence into a
19
+ process status. ``resolution.resolve()`` will later be handed the same tuple as a parameter rather
20
+ than calling this function again, because a second call from inside ``resolve`` would run the check
21
+ after the failure it exists to prevent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+
28
+ __all__ = ["REQUIRED", "DependencyStatus", "check_runtime_dependencies"]
29
+
30
+ #: The distributions the package cannot run without, hardcoded here and compared against
31
+ #: ``pyproject.toml`` by a test rather than read back at runtime. Reading them at runtime would
32
+ #: mean parsing PEP 508 requirement strings with their markers and extras evaluated, which is a
33
+ #: fourth dependency for three packages that would then need it.
34
+ #:
35
+ #: ``jsonschema`` is the third, and it is the name this list most needed. ``periplus validate``
36
+ #: checks a pack against the shipped schemas with the reference implementation, so an install
37
+ #: without it dies of an ``ImportError`` inside a subcommand — the one failure this module exists
38
+ #: to prevent — unless the name is here.
39
+ #:
40
+ #: A language grammar is not here: only a loaded rutter that pins one needs it, and the pack
41
+ #: check reports a pinned grammar that is absent or cannot be imported.
42
+ #:
43
+ #: Written in the manifest's own order rather than sorted, on purpose.
44
+ #: ``check_runtime_dependencies`` sorts, and a sorted constant would make that sort a no-op that
45
+ #: could be deleted with every test
46
+ #: still green — the determinism the report needs would then rest on the order somebody happened to
47
+ #: type here.
48
+ REQUIRED: tuple[str, ...] = (
49
+ "ruamel.yaml",
50
+ "platformdirs",
51
+ "jsonschema",
52
+ "tree-sitter",
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class DependencyStatus:
58
+ """What was found for one distribution.
59
+
60
+ ``installed`` and ``present`` are both carried because they answer different questions. The
61
+ report names the version when there is one, and a consumer branching on availability should
62
+ read a boolean rather than infer one from ``None``.
63
+ """
64
+
65
+ distribution: str
66
+ installed: str | None
67
+ present: bool
68
+
69
+
70
+ def check_runtime_dependencies() -> tuple[DependencyStatus, ...]:
71
+ """One status per required distribution, sorted by distribution name.
72
+
73
+ Sorted by code point through Python's own string ordering, which is not locale-aware — the
74
+ report has to be byte-identical across two runs on the same input, and these statuses reach it.
75
+
76
+ ``importlib.metadata`` is imported here rather than at module level, and that is a decision
77
+ with a measurement behind it rather than a style preference. It costs about 29 ms of import
78
+ subtree — roughly 36 ms of wall clock on a whole interpreter run, measured 2026-08-30 on
79
+ Python 3.14.7 — which is over three times what ``dataclasses`` costs and most of the 44 ms the
80
+ packaging component fought to remove by resolving ``__version__`` lazily. At module level it
81
+ would sit on ``cli``'s module-level import path and be paid by ``periplus --help``, by every
82
+ consumer that imports ``periplus.cli`` as a library, and by the blocked-import test. Here it is
83
+ paid by the caller that asks, which is the same arrangement and the same reason as
84
+ ``__init__.__getattr__``. Re-measure with
85
+ ``python -X importtime`` rather than trusting the number.
86
+ """
87
+ from importlib.metadata import PackageNotFoundError, version
88
+
89
+ statuses = []
90
+ for distribution in sorted(REQUIRED):
91
+ try:
92
+ installed: str | None = version(distribution)
93
+ except PackageNotFoundError:
94
+ installed = None
95
+ statuses.append(
96
+ DependencyStatus(
97
+ distribution=distribution,
98
+ installed=installed,
99
+ present=installed is not None,
100
+ )
101
+ )
102
+ return tuple(statuses)
periplus/py.typed ADDED
File without changes