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/validate.py ADDED
@@ -0,0 +1,655 @@
1
+ """``periplus validate <pack>``: check a pack against the shipped schemas, and stop.
2
+
3
+ The names come from ``spec.py``, which is the command this one sits beside: a frozen record per
4
+ failure, one report record, two renderers in a ``RENDERERS`` table keyed by ``--format``'s two
5
+ choices, and an ``exit_code`` property on the report. A person who has read ``spec`` should not
6
+ have to learn a second shape here.
7
+
8
+ Three properties this module has to hold, all of them asserted by tests already written:
9
+
10
+ **It is imported lazily, inside ``main()``.** ``errors.py`` states the package's central
11
+ constraint — nothing on ``cli``'s module-level import path imports a runtime distribution — and
12
+ this module reaches ``jsonschema``, so a module-level ``import periplus.validate`` in ``cli.py``
13
+ would put that distribution on the path the dependency check exists to keep clear.
14
+ ``cli.py`` already imports ``init`` and ``spec`` this way.
15
+
16
+ **Its output is deterministic.** Determinism binds every command.
17
+ ``Draft202012Validator.iter_errors`` declares no order, ``Path.rglob`` returns filesystem order,
18
+ and neither is stable across machines. So every error found across every file in the pack is
19
+ collected and then sorted once, by ``(file, pointer, message, keyword)``, before anything is
20
+ rendered. The keyword is the fourth component because path, pointer and message together do not
21
+ order two errors that differ only in which keyword failed.
22
+
23
+ **A branch failure names the keys under it.** ``pack-file.schema.json`` holds seven ``oneOf`` and
24
+ four ``anyOf``. A failure inside one produces a parent error reading "is not valid under any of
25
+ the given schemas", which names no key at all — so the sub-errors ``jsonschema`` carries in
26
+ ``ValidationError.context`` are rendered beneath their parent, sorted by the same key.
27
+ Validate has to name what failed, and a line naming only the branch does not.
28
+
29
+ Two things this module does not do, recorded so a reader does not go looking for them.
30
+
31
+ It does **not** check a pack's vocabulary against its dependencies — that a rule may only name a
32
+ type its own pack or a pack it depends on declares. That is the graph validator, it needs the
33
+ reader ``spec.InheritedVocabulary`` is waiting on, and it is the next unit. What is checked here is
34
+ one file against one document, which is every check the two shipped schemas can express.
35
+
36
+ It does **not** validate the packs a pack depends on. ``periplus validate drupal`` reads
37
+ ``drupal``'s files and no others, although resolving the name walks the ``depends`` graph and
38
+ inherits whatever that walk reported. A pack is validated by naming it, which is also the command
39
+ a CI step runs once per pack.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ from collections.abc import Callable, Iterable, Mapping, Sequence
46
+ from dataclasses import dataclass, field
47
+ from importlib.resources.abc import Traversable
48
+ from pathlib import Path
49
+
50
+ from jsonschema import Draft202012Validator
51
+ from jsonschema.exceptions import ValidationError
52
+ from ruamel.yaml import YAML
53
+ from ruamel.yaml.error import YAMLError
54
+
55
+ from periplus.errors import ExitCode, Problem
56
+ from periplus.manifest import MANIFEST_FILENAME, resolve_pack_order
57
+ from periplus.packs import PackCandidate, match_pins
58
+ from periplus.preflight import DependencyStatus
59
+ from periplus.report import _escape
60
+ from periplus.resolution import resolve
61
+ from periplus.spec import _unknown_pack, contract_root
62
+
63
+ __all__ = [
64
+ "MANIFEST_SCHEMA",
65
+ "PACK_FILE_SCHEMA",
66
+ "RENDERERS",
67
+ "SchemaError",
68
+ "ValidationReport",
69
+ "render_json",
70
+ "render_text",
71
+ "validate",
72
+ ]
73
+
74
+ #: The problem name for a bare pack name that two or more copies on the search path carry.
75
+ PACK_AMBIGUOUS = "PACK_AMBIGUOUS"
76
+
77
+ #: The shipped schema a ``pack.yaml`` is checked against, named by its path under ``contract/``.
78
+ #: Read through ``spec.contract_root()`` rather than through a second reader.
79
+ MANIFEST_SCHEMA = "schema/pack-manifest.schema.json"
80
+
81
+ #: The shipped schema every other ``*.yaml`` / ``*.yml`` in a pack directory is checked against.
82
+ #:
83
+ #: The rule that decides which of the two applies — ``pack.yaml`` is the manifest, everything else
84
+ #: is a pack file — is engine knowledge a pack cannot override, and it is a recorded limit of this
85
+ #: task rather than a solved problem. A pack shipping a YAML file that is not rules has no way to
86
+ #: say so, and no bundled pack does this today.
87
+ PACK_FILE_SCHEMA = "schema/pack-file.schema.json"
88
+
89
+ #: The two suffixes a file in a pack directory is read for. Written here rather than at the one
90
+ #: call site, because it is half of the classification rule the constant above records the other
91
+ #: half of, and a reader looking for "which files does this open" should find one answer.
92
+ _YAML_SUFFIXES = (".yaml", ".yml")
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class SchemaError:
97
+ """One disagreement between a file in a pack and the schema it is written against.
98
+
99
+ ``file`` is the file's path relative to the pack directory, joined with ``/`` on every
100
+ platform, because it names a place inside a pack and not a location on a disk.
101
+
102
+ ``pointer`` addresses the failing key inside that file — ``$.rules[0]`` for the first rule,
103
+ ``$`` for the document itself. ``keyword`` is the validator keyword that failed, carried
104
+ because it is the fourth component of the sort key and because ``required`` and ``oneOf``
105
+ failing at one pointer are two different faults.
106
+
107
+ ``context`` holds the sub-errors of a branch failure, already sorted. It is empty for every
108
+ other kind, which is a value and not a placeholder.
109
+ """
110
+
111
+ file: str
112
+ pointer: str
113
+ keyword: str
114
+ message: str
115
+ context: tuple[SchemaError, ...] = field(default_factory=tuple)
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class ValidationReport:
120
+ """One ``validate`` run: what was checked, what disagreed, and what else went wrong.
121
+
122
+ ``checked`` names every file the run opened, relative to the pack directory and sorted. It is
123
+ carried rather than counted, because "no errors" and "no files" are different answers and a
124
+ report that gave only the first could not tell them apart.
125
+
126
+ ``version`` is the version half of the directory name that was checked, ``None`` when no
127
+ directory was found; with ``directory`` it says which copy of the pack the run opened.
128
+
129
+ ``problems`` are the resolution faults the run inherited — an unreadable settings file, a pin
130
+ matching nothing — kept apart from ``errors`` because they are faults in a different file with
131
+ a different owner.
132
+ """
133
+
134
+ pack: str
135
+ directory: str | None
136
+ checked: tuple[str, ...]
137
+ errors: tuple[SchemaError, ...]
138
+ problems: tuple[Problem, ...]
139
+ version: str | None = None
140
+
141
+ @property
142
+ def exit_code(self) -> ExitCode:
143
+ """``SCHEMA_INVALID`` whenever anything disagreed, else the lowest problem code, else OK.
144
+
145
+ **This departs from the ``min()`` aggregation every other report uses, deliberately.**
146
+ ``SCHEMA_INVALID`` is the highest number in the table, so under ``min()`` it would be
147
+ masked by every existing code and a ``validate`` run in a project with an unresolvable pin
148
+ would exit 5 — reporting a fault in the settings file as the answer to a question about a
149
+ pack. A schema error is this command's entire subject, so it takes the status.
150
+ """
151
+ if self.errors:
152
+ return ExitCode.SCHEMA_INVALID
153
+ return min((problem.code for problem in self.problems), default=ExitCode.OK)
154
+
155
+
156
+ def validate(
157
+ start: Path,
158
+ env: Mapping[str, str],
159
+ dependencies: tuple[DependencyStatus, ...],
160
+ requested: str,
161
+ ) -> ValidationReport:
162
+ """Resolve one pack name, check every YAML file it carries, and report.
163
+
164
+ The pack is resolved through the pack search path exactly as ``spec`` resolves it, by calling
165
+ ``resolve()`` — one definition of where packs live rather than a second one here. A name no
166
+ directory on the path carries is ``PACK_UNKNOWN``, which already means "an argument names a
167
+ pack nothing carries".
168
+
169
+ Pack YAML is parsed at ``typ='safe'``, as ``settings.py`` and ``manifest.py`` parse theirs.
170
+ ``_load`` records what that buys and what it does not; the short form is that the round-trip
171
+ loader constructs Python objects out of a pack's own tags and the safe one refuses.
172
+
173
+ Every problem ``resolve()`` found is carried, including ones about pins this run did not need,
174
+ for the reason ``spec()`` records: a settings file that could not be read is a pack search path
175
+ the tool cannot vouch for, and a report describing a pack found on a path it could not compute
176
+ would be a report that lies about where it looked.
177
+ """
178
+ resolution = resolve(start=start, env=env, dependencies=dependencies)
179
+ problems: list[Problem] = list(resolution.problems)
180
+ pins = (
181
+ resolution.settings.packs.value if resolution.settings and resolution.settings.packs else ()
182
+ )
183
+ directory, path, version = _locate(requested, resolution.candidates, pins, problems)
184
+ if directory is None:
185
+ return _report(requested, None, (), (), problems)
186
+ checked, errors = _check_directory(directory, problems)
187
+ return _report(requested, path, checked, errors, problems, version)
188
+
189
+
190
+ def _report(
191
+ requested: str,
192
+ path: str | None,
193
+ checked: tuple[str, ...],
194
+ errors: tuple[SchemaError, ...],
195
+ problems: Sequence[Problem],
196
+ version: str | None = None,
197
+ ) -> ValidationReport:
198
+ """One report, with its problems sorted by code then message.
199
+
200
+ The same key ``resolve()`` and ``describe_contract()`` sort their own problems by, so a problem
201
+ reads the same wherever a person meets it.
202
+ """
203
+ return ValidationReport(
204
+ pack=requested,
205
+ directory=path,
206
+ checked=checked,
207
+ errors=errors,
208
+ problems=tuple(sorted(problems, key=lambda problem: (int(problem.code), problem.message))),
209
+ version=version,
210
+ )
211
+
212
+
213
+ def _locate(
214
+ requested: str,
215
+ candidates: Sequence[PackCandidate],
216
+ pins: Sequence[str],
217
+ problems: list[Problem],
218
+ ) -> tuple[Traversable | None, str | None, str | None]:
219
+ """One pack name, or ``name@version``, resolved to the directory that carries it, or to nothing.
220
+
221
+ ``name@version`` names the copy. A bare name with one copy on the path names that copy. A bare
222
+ name with several copies takes the copy the project pins, and with no pin among them stops with
223
+ ``PACK_AMBIGUOUS`` listing every copy, rather than checking one and saying nothing about which.
224
+ A version no copy carries is ``PACK_UNKNOWN`` naming the versions that are on the path.
225
+
226
+ The same three calls ``spec._describe_pack`` makes, in the same order and for the same reasons:
227
+ every ``named`` candidate whose pack half equals the name becomes a pin, ``match_pins`` turns
228
+ those into matches, and ``resolve_pack_order`` turns a match into a ``LoadedPack`` — which is
229
+ the one record carrying the pack directory as a ``Traversable``. That handle is what makes this
230
+ work under an install where the bundled root is not a ``Path``.
231
+
232
+ ``_unknown_pack`` is imported from ``spec`` rather than written again. It is the same fault
233
+ with the same remedy and the same ``detail`` keys, and two copies of one message is how the two
234
+ commands come to answer one mistake differently.
235
+
236
+ A ``None`` directory adds no problem of its own beyond the one already appended. Every reason
237
+ the walk could not produce the pack — a duplicated name, an unreadable manifest — has reported
238
+ itself, and inventing a second would be describing one fault twice.
239
+ """
240
+ name, _, version = requested.partition("@")
241
+ copies = [
242
+ candidate
243
+ for candidate in candidates
244
+ if candidate.status == "named"
245
+ and candidate.name is not None
246
+ and candidate.name.pack == name
247
+ ]
248
+ entries = sorted({candidate.entry for candidate in copies})
249
+ if not entries:
250
+ problems.append(_unknown_pack(name, candidates))
251
+ return None, None, None
252
+ if version:
253
+ entries = [entry for entry in entries if entry == requested]
254
+ if not entries:
255
+ versions = ", ".join(sorted({c.name.version for c in copies if c.name}))
256
+ problems.append(
257
+ Problem(
258
+ code=ExitCode.PACK_UNKNOWN,
259
+ message=f"no copy of {name!r} on the pack search path is version {version!r}",
260
+ detail={"pack": name, "version": version, "available": versions},
261
+ )
262
+ )
263
+ return None, None, None
264
+ elif len(entries) > 1:
265
+ pinned = [entry for entry in entries if entry in pins]
266
+ if not pinned:
267
+ problems.append(_ambiguous(name, copies))
268
+ return None, None, None
269
+ entries = pinned
270
+
271
+ matches, pin_problems = match_pins(entries, candidates)
272
+ order, walk_problems = resolve_pack_order(matches, candidates)
273
+ problems.extend(pin_problems)
274
+ problems.extend(walk_problems)
275
+
276
+ loaded = next((pack for pack in order if pack.name.pack == name), None)
277
+ if loaded is None:
278
+ return None, None, None
279
+ return loaded.directory, loaded.path, loaded.name.version
280
+
281
+
282
+ def _ambiguous(name: str, copies: Sequence[PackCandidate]) -> Problem:
283
+ """Two or more copies of a name and nothing to choose between them, each copy listed.
284
+
285
+ ``PACK_AMBIGUOUS`` is the problem's own name, in ``detail``; its exit code is
286
+ ``PACK_UNKNOWN``'s, 15, because ``errors.ExitCode`` has no member of its own for it.
287
+ """
288
+ listed = sorted(
289
+ f"{c.entry} at {Path(c.root.display) / c.entry}" for c in copies if c.name is not None
290
+ )
291
+ return Problem(
292
+ code=ExitCode.PACK_UNKNOWN,
293
+ message=(
294
+ f"{PACK_AMBIGUOUS}: {len(listed)} copies of {name!r} are on the pack search path "
295
+ f"({'; '.join(listed)}); name one as {name}@<version>"
296
+ ),
297
+ detail={"problem": PACK_AMBIGUOUS, "pack": name, "copies": "; ".join(listed)},
298
+ )
299
+
300
+
301
+ def _check_directory(
302
+ directory: Traversable,
303
+ problems: list[Problem],
304
+ ) -> tuple[tuple[str, ...], tuple[SchemaError, ...]]:
305
+ """Every YAML file under one pack directory, checked, with one sort over the whole result.
306
+
307
+ **One sort, across every file at once, and that is the load-bearing statement in this module.**
308
+ The walk below returns files in the order the filesystem lists them, which on ext4 with hashed
309
+ directories is arbitrary and differs between two machines holding one tree; ``iter_errors``
310
+ declares no order of its own. Sorting per file would leave the order the *files* were walked in
311
+ deciding the output. Sorting once at the end absorbs both.
312
+
313
+ Each validator is built once and used for every file it applies to. That is not a saving: a
314
+ validator built per file would re-resolve the same ``$ref`` graph forty-eight times for
315
+ ``drupal``, and the reason to say so here is that a reader should not move it back.
316
+ """
317
+ manifest = Draft202012Validator(_schema(MANIFEST_SCHEMA))
318
+ pack_file = Draft202012Validator(_schema(PACK_FILE_SCHEMA))
319
+
320
+ checked: list[str] = []
321
+ errors: list[SchemaError] = []
322
+ for relative, node in _yaml_files(directory):
323
+ # `readable`, and never `document is None`. A file that would not parse and a file that
324
+ # parsed and holds `null` both give a document of `None`, and reading the second as the
325
+ # first is how an empty file, a comment-only file and one holding `--- null` were dropped
326
+ # from a run with no record: unchecked, unreported, and the command exiting 0. A null
327
+ # document is a schema error the shipped schema already expresses, so it goes to the
328
+ # validator like any other and comes back as `None is not of type 'object'`.
329
+ document, readable = _load(node, problems)
330
+ if not readable:
331
+ continue
332
+ checked.append(relative)
333
+ validator = manifest if relative.rsplit("/", 1)[-1] == MANIFEST_FILENAME else pack_file
334
+ errors.extend(_error(relative, found) for found in validator.iter_errors(document))
335
+ return tuple(sorted(checked)), tuple(sorted(errors, key=_sort_key))
336
+
337
+
338
+ def _schema(name: str) -> Mapping[str, object]:
339
+ """One shipped schema, read out of the installed package through ``spec``'s own reader.
340
+
341
+ ``contract_root()`` and not a second ``importlib.resources`` call: ``spec`` already publishes
342
+ these four documents from that directory, and two readers of one directory is where a wheel
343
+ install and a source checkout come to disagree about what shipped.
344
+
345
+ The name is split on ``/`` rather than passed whole. ``Traversable.joinpath`` is not required
346
+ to accept a path with a separator in it, and the bundled contract is reached through whatever
347
+ ``importlib.resources`` hands back on the install this is running from.
348
+ """
349
+ node = contract_root()
350
+ for segment in name.split("/"):
351
+ node = node / segment
352
+ # Annotated rather than returned straight out of `json.loads`, whose declared return is `Any`.
353
+ # Under mypy strict an `Any` returned from a typed function is an error, and the annotation is
354
+ # the claim being made: these four documents are objects, and `check_schema` in
355
+ # `tests/test_contract.py` is what holds that claim rather than a runtime branch here.
356
+ document: Mapping[str, object] = json.loads(node.read_text(encoding="utf-8"))
357
+ return document
358
+
359
+
360
+ def _yaml_files(directory: Traversable) -> list[tuple[str, Traversable]]:
361
+ """Every ``*.yaml`` and ``*.yml`` at or under a pack directory, each named relative to it.
362
+
363
+ Relative and joined with ``/`` on every platform, because the name says where a file sits
364
+ inside a pack and a pack is the same pack wherever it was unpacked.
365
+
366
+ **This is where filesystem order enters, so it is where filesystem order has to stop.**
367
+ ``Traversable.iterdir`` declares no order and on ext4 with hashed directories it is arbitrary,
368
+ so without this sort two machines holding one pack walk its files in two orders.
369
+
370
+ **No test defends it.** In particular,
371
+ ``test_an_unmodified_bundled_pack_validates_clean_and_says_what_it_opened`` does not: with
372
+ ``return found`` in place of the sort, the whole suite passes. That test compares
373
+ ``set(payload["checked"])`` against a set, and ``checked`` is sorted again downstream, so the
374
+ walk order cannot reach it. A green suite after deleting the sort is not permission to delete
375
+ it.
376
+
377
+ What a mutation does reach is ``errors`` order, and only in combination: with this sort gone
378
+ *and* the ``file`` component dropped from ``_sort_key``, two errors print in
379
+ ``iterdir`` order. Neither mutation alone is observed by anything. That the sort key's
380
+ components are undefended is recorded as a known gap and is not closed here.
381
+
382
+ ``_check_directory``'s ``sorted(checked)`` is the redundant one — deleting it fails nothing,
383
+ because this function has already sorted. It is kept for the reason ``spec._describe_pack``
384
+ keeps its own unobserved sort: a determinism property that holds only because an upstream
385
+ function happens to sort is held by a coincidence.
386
+ """
387
+ found: list[tuple[str, Traversable]] = []
388
+ _walk(directory, "", found)
389
+ return sorted(found, key=lambda entry: entry[0])
390
+
391
+
392
+ def _walk(node: Traversable, path: str, found: list[tuple[str, Traversable]]) -> None:
393
+ """Append every YAML file at or under ``node``. Nothing else in the tree is opened."""
394
+ if node.is_dir():
395
+ for child in node.iterdir():
396
+ _walk(child, f"{path}/{child.name}" if path else child.name, found)
397
+ return
398
+ if any(node.name.endswith(suffix) for suffix in _YAML_SUFFIXES):
399
+ found.append((path, node))
400
+
401
+
402
+ def _load(node: Traversable, problems: list[Problem]) -> tuple[object, bool]:
403
+ """One pack file as a parsed document, and whether it could be read at all.
404
+
405
+ **The second half of that pair is the whole point of the signature.** A
406
+ file that will not parse and a file that parses to ``null`` both give a document of ``None``,
407
+ so a caller branching on ``document is None`` treats the second as the first. Then an empty
408
+ file, a comment-only file and one holding ``--- null`` in a pack would be
409
+ dropped from the run with no record — not in ``checked``, not in ``errors``, not in
410
+ ``problems``, and the command would exit 0. Returning the flag separately is what makes a null
411
+ document reach the validator, where ``pack-file.schema.json``'s top-level ``"type": "object"``
412
+ already has the answer: ``None is not of type 'object'``.
413
+
414
+ ``YAML(typ='safe', pure=True)``, the configuration ``settings.py`` and ``manifest.py`` both
415
+ use, for the three reasons ``load_manifest`` records. The first of the three is the one that
416
+ matters here, and stated exactly: **a pack is third-party data, and the safe loader refuses a
417
+ tag it does not know where the round-trip loader accepts it as data.** Measured on ruamel
418
+ 0.19.1, all three loaders, against ``!!python/object/apply:os.system [...]``:
419
+
420
+ * ``safe`` raises ``ConstructorError``.
421
+ * ``rt`` returns a ``CommentedSeq`` carrying the tag. Nothing is constructed and nothing runs.
422
+ * ``unsafe`` executes it. The marker file appeared.
423
+
424
+ **What the tag then costs is a silent pass, not a confusing error.** ``jsonschema`` sees the
425
+ node underneath and not the tag, so a tagged value whose underlying node matches the declared
426
+ type validates clean with the tag still on it. Measured, same version::
427
+
428
+ rt-load a: !!python/object/apply:os.system ['echo hi']
429
+ validate {"type": "array", "items": {"type": "string"}}
430
+ result no errors; value is CommentedSeq(['echo hi']) tagged
431
+ tag:yaml.org,2002:python/object/apply:os.system
432
+
433
+ Under ``safe`` those bytes never become a document at all. So the tag is refused where an
434
+ author sees it, rather than carried into a map by a validator that had no way to object.
435
+
436
+ **Three plausible claims about these loaders are false, and only measurement shows it.**
437
+ ``rt`` does not construct the Python object — ``unsafe`` is the loader that does. A
438
+ ``TaggedScalar`` is not an object that no keyword describes — every keyword tried describes it,
439
+ including the shipped manifest schema, which reports ``$.pack type`` for a tagged scalar. And
440
+ ``CommentedMap.__repr__`` does not differ from ``dict``'s: measured on ruamel 0.19.1, the two
441
+ reprs are byte-identical — ``CommentedMap`` renders as ``{'a': 1}`` exactly as ``dict`` does —
442
+ so no error text that ``jsonschema`` interpolates through ``{instance!r}`` into ``enum``,
443
+ ``type`` and every branch message depends on which of the two loaders ran. Swapping them leaves
444
+ the whole suite green, which is the same fact from the other side. A reader tempted to restate
445
+ any of this from what the names suggest should run it first. ``settings.py`` and ``manifest.py``
446
+ claim only "safe refuses arbitrary object construction", which is accurate.
447
+
448
+ **A file that will not parse is a ``Problem`` at ``UNREADABLE_MANIFEST`` and not a
449
+ ``SchemaError``,** and the code is shared with ``manifest.py`` deliberately. A ``SchemaError``
450
+ names a key and a pointer inside a document, and there is no document here to point into. The
451
+ fault is the same one that member already covers — a file a pack author shipped could not be
452
+ turned into a mapping — with the same one remedy, open the named path. What it costs is that
453
+ the member's name says "manifest" about a rule file; a code of its own would have been a new
454
+ number for a remedy already written down.
455
+ """
456
+ display = str(node)
457
+ yaml = YAML(typ="safe", pure=True)
458
+ try:
459
+ with node.open("r", encoding="utf-8") as stream:
460
+ # Annotated for the reason `_schema` records: `YAML.load` is declared `Any`, and what
461
+ # comes back is genuinely unknown -- a mapping, a list, a string or `None`, whatever
462
+ # the file holds. `object` is the honest type and the validator takes it from here.
463
+ document: object = yaml.load(stream)
464
+ return document, True
465
+ except YAMLError as error:
466
+ problems.append(_unreadable(display, "parser", "could not be parsed", error))
467
+ except UnicodeDecodeError as error:
468
+ problems.append(_unreadable(display, "encoding", "is not valid UTF-8", error))
469
+ except OSError as error:
470
+ problems.append(_unreadable(display, "read", "could not be read", error))
471
+ return None, False
472
+
473
+
474
+ def _unreadable(display: str, cause: str, said: str, error: Exception) -> Problem:
475
+ """One file in a pack that could not be turned into a document, named by its cause.
476
+
477
+ The same four ``detail`` keys ``manifest.ManifestUnreadable`` populates, so a consumer of
478
+ ``--format json`` reads one shape for the two commands that report this code.
479
+ """
480
+ return Problem(
481
+ code=ExitCode.UNREADABLE_MANIFEST,
482
+ message=f"a file in the pack {said}: {display}",
483
+ detail={"path": display, "cause": cause, "detail": str(error)},
484
+ )
485
+
486
+
487
+ def _error(file: str, error: ValidationError) -> SchemaError:
488
+ """One ``jsonschema`` error as a record, with its branch sub-errors beneath it.
489
+
490
+ ``json_path`` rather than a pointer assembled here: ``$.rules[0]`` is the address the
491
+ reference implementation itself prints, so a person reading this output and a person reading
492
+ ``jsonschema``'s are reading one notation.
493
+
494
+ ``context`` is walked recursively because a branch can hold a branch. Each level is sorted by
495
+ the same four-part key as the top level, so the whole tree has one order and it is not the
496
+ order ``iter_errors`` happened to yield.
497
+ """
498
+ return SchemaError(
499
+ file=file,
500
+ pointer=error.json_path,
501
+ # `validator` is the keyword's name for every error a schema produces, and a sentinel on
502
+ # the errors `jsonschema` raises about a schema rather than about an instance. `str` is
503
+ # what makes the sort key a string in both cases rather than raising on the second.
504
+ keyword=str(error.validator),
505
+ message=error.message,
506
+ context=tuple(sorted((_error(file, sub) for sub in error.context), key=_sort_key)),
507
+ )
508
+
509
+
510
+ def _sort_key(error: SchemaError) -> tuple[str, str, str, str]:
511
+ """File, pointer, message, then keyword. The fourth component is not an afterthought.
512
+
513
+ Path, pointer and message together do not order two errors that differ only in which keyword
514
+ failed — ``required`` and ``oneOf`` can fail at one pointer with one message shape — and
515
+ falling back to ``iter_errors`` order there is the guarantee ``jsonschema`` does not give.
516
+ """
517
+ return (error.file, error.pointer, error.message, error.keyword)
518
+
519
+
520
+ # ---------------------------------------------------------------------------------------------
521
+ # The text form
522
+ # ---------------------------------------------------------------------------------------------
523
+
524
+
525
+ def render_text(report: ValidationReport) -> str:
526
+ """The report as the lines a person reads: the file, the pointer, and what disagreed.
527
+
528
+ Every error names the failing key and its path in the file. A branch failure's sub-errors are
529
+ indented beneath it.
530
+
531
+ Every value here is escaped. A pack name comes off the command line, a file name comes off a
532
+ filesystem where any byte but ``/`` and NUL is legal, and a schema message quotes the contents
533
+ of a pack file — all three are attacker-influenced text and go through ``_escape`` exactly as
534
+ they do in ``report``, ``init`` and ``spec``.
535
+ """
536
+ lines: list[str] = [f"periplus validate {_escape(report.pack)}"]
537
+ lines += _section("Pack", _pack_lines(report))
538
+ lines += _section("Checked", [_escape(name) for name in report.checked])
539
+ lines += _section("Schema errors", _error_lines(report.errors, ""))
540
+ lines += _section("Problems", _problem_lines(report.problems))
541
+ return "\n".join(lines) + "\n"
542
+
543
+
544
+ def _section(heading: str, rows: Sequence[str]) -> list[str]:
545
+ """One block: a blank line, its heading, and its rows indented under it.
546
+
547
+ The same shape as ``spec._section``, and empty prints ``none`` for the same reason: a section
548
+ a reader cannot find is indistinguishable from a section that does not exist.
549
+ """
550
+ return ["", f"{heading}:", *[f" {row}".rstrip() for row in rows or ["none"]]]
551
+
552
+
553
+ def _pack_lines(report: ValidationReport) -> list[str]:
554
+ """What was asked about and where it was found, with each label padded to a fixed width."""
555
+ return [
556
+ f"{'pack:':<12}{_escape(report.pack)}",
557
+ f"{'version:':<12}{_escape(report.version or 'not found')}",
558
+ f"{'directory:':<12}{_escape(report.directory or 'not found on the pack search path')}",
559
+ f"{'files':<12}{len(report.checked)}",
560
+ ]
561
+
562
+
563
+ def _error_lines(errors: Iterable[SchemaError], indent: str) -> list[str]:
564
+ """One line per disagreement, its sub-errors indented two spaces further beneath it.
565
+
566
+ The file is named on every **top-level** line rather than grouped under a heading per file. A
567
+ person reading a failure copies one line into an editor, and a line naming only a pointer
568
+ sends them looking upward for the file it belongs to.
569
+
570
+ A sub-error's line drops the file and keeps the pointer. The file is the parent's, three lines
571
+ of ten repeating one path is noise a reader has to look past, and the pointer stays because a
572
+ sub-error can sit deeper in the document than the branch that carries it.
573
+ """
574
+ lines: list[str] = []
575
+ for error in errors:
576
+ located = f"{_escape(error.file)} " if not indent else ""
577
+ lines.append(
578
+ f"{indent}{located}{_escape(error.pointer)} "
579
+ f"{_escape(error.keyword)} {_escape(error.message)}"
580
+ )
581
+ lines += _error_lines(error.context, f"{indent} ")
582
+ return lines
583
+
584
+
585
+ def _problem_lines(problems: Sequence[Problem]) -> list[str]:
586
+ """One line per problem, its code first, with its named facts under it."""
587
+ lines: list[str] = []
588
+ for problem in problems:
589
+ lines.append(f"{int(problem.code)} {_escape(problem.message)}")
590
+ lines += [
591
+ f" {_escape(key)}: {_escape(problem.detail[key])}" for key in sorted(problem.detail)
592
+ ]
593
+ return lines
594
+
595
+
596
+ # ---------------------------------------------------------------------------------------------
597
+ # The JSON form
598
+ # ---------------------------------------------------------------------------------------------
599
+
600
+
601
+ def render_json(report: ValidationReport) -> str:
602
+ """The report as one JSON document, keys sorted, ``ensure_ascii=True``.
603
+
604
+ An explicit projection rather than a walk over the dataclasses, for the reason
605
+ ``spec.render_json`` gives: adding a field to a record must not silently add a key whose type
606
+ nobody chose.
607
+
608
+ ``ensure_ascii=True``, matching every other form this tool prints: a pack file name comes off a
609
+ filesystem where a name need not be valid UTF-8, and under ``ensure_ascii`` a surrogate escape
610
+ encodes cleanly instead of raising out of the encoder.
611
+
612
+ Nothing is passed through ``_escape``, exactly as in ``spec.render_json``: that helper stops a
613
+ name forging a *line*, and this document has no lines to forge.
614
+ """
615
+ payload = {
616
+ "pack": report.pack,
617
+ "directory": report.directory,
618
+ "version": report.version,
619
+ "checked": list(report.checked),
620
+ "errors": [_json_error(error) for error in report.errors],
621
+ "problems": [
622
+ {
623
+ "code": int(problem.code),
624
+ "message": problem.message,
625
+ "detail": dict(problem.detail),
626
+ }
627
+ for problem in report.problems
628
+ ],
629
+ }
630
+ return json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=True) + "\n"
631
+
632
+
633
+ def _json_error(error: SchemaError) -> dict[str, object]:
634
+ """One disagreement as an object, carrying its sub-errors in the same shape.
635
+
636
+ ``context`` is always present and is an empty list when there is none, rather than being
637
+ omitted. A consumer then reads one shape for every error instead of branching on whether a key
638
+ is there, and an empty list says what the record says: this error has no sub-errors.
639
+ """
640
+ return {
641
+ "file": error.file,
642
+ "pointer": error.pointer,
643
+ "keyword": error.keyword,
644
+ "message": error.message,
645
+ "context": [_json_error(sub) for sub in error.context],
646
+ }
647
+
648
+
649
+ #: The formats ``validate --format`` offers, in the order they are offered. ``cli.build_parser``
650
+ #: writes the same two names as a literal, for the reason ``report.RENDERERS`` records: ``cli`` may
651
+ #: not import this module. A test asserts the two agree.
652
+ RENDERERS: Mapping[str, Callable[[ValidationReport], str]] = {
653
+ "text": render_text,
654
+ "json": render_json,
655
+ }