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/report.py ADDED
@@ -0,0 +1,596 @@
1
+ """The two renderers. No resolution logic, no filesystem, no terminal.
2
+
3
+ This module receives a finished ``ResolutionReport`` and returns a string. It reads nothing, opens
4
+ nothing, and asks nothing about the stream it will be written to: no colour, no TTY detection, no
5
+ terminal-width probing. A pipe and a terminal receive the same bytes, which is half of the
6
+ determinism property and the half that is easiest to lose by accident.
7
+
8
+ The two renderers share one helper, ``_escape``, which is a pure string function that knows nothing
9
+ about the record. They share no "format a section" helper: a change to one renderer must not be
10
+ able to change the other, and a shared section formatter is exactly the edge that would let it.
11
+
12
+ Every mapping is emitted in name order, by ``sorted()`` here rather than by trusting the producer.
13
+ ``settings.py`` already sorts ``folders`` and ``extensions``, and a second consumer may build a
14
+ ``ResolvedSettings`` some other way.
15
+
16
+ Nothing in this module is on ``cli``'s module-level import path. ``cli`` imports it inside
17
+ ``main()``, after the dependency check, so nothing it imports — ``json`` included — is paid by
18
+ ``periplus --version`` or ``--help``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from collections.abc import Callable, Iterable, Mapping, Sequence
25
+
26
+ from periplus.errors import Problem
27
+ from periplus.packs import MatchedPack, PackCandidate, PackName, PackRoot
28
+ from periplus.preflight import DependencyStatus
29
+ from periplus.resolution import ResolutionReport
30
+ from periplus.settings import (
31
+ FirstParty,
32
+ ResolvedSetting,
33
+ ResolvedSettings,
34
+ SettingsSource,
35
+ SourceRef,
36
+ )
37
+
38
+ __all__ = ["RENDERERS", "render_json", "render_text"]
39
+
40
+ #: The settings keys, in the order the text form prints them. Written out rather than read off
41
+ #: `ResolvedSettings.__dataclass_fields__`, because the order a person reads is a choice and the
42
+ #: order the fields happen to be declared in is not one.
43
+ SETTINGS_KEYS = (
44
+ "periplus_version",
45
+ "packs",
46
+ "pack_paths",
47
+ "folders",
48
+ "extensions",
49
+ "first_party",
50
+ "exclude",
51
+ "re_include",
52
+ "accepted_unknowns",
53
+ )
54
+
55
+
56
+ #: The two line boundaries ``str.splitlines()`` honours that are outside the ``Cc`` category.
57
+ #: Escaping every control character closed the newline hole and left these two: a name holding
58
+ #: U+2028 produced escaped output with no ``\n`` in it at all that ``splitlines()`` still read as
59
+ #: three lines, which is the line forging this function exists to prevent, two code points further
60
+ #: out. They are escaped in ``\uXXXX`` form because they do not fit two hex digits.
61
+ _LINE_BOUNDARIES = ("\u2028", "\u2029")
62
+
63
+
64
+ def _escaped(character: str) -> str:
65
+ """One character, kept as itself or replaced by its ordinal.
66
+
67
+ Three cases rather than a nested conditional, because an unreadable predicate is how the next
68
+ range gets missed.
69
+ """
70
+ if character in _LINE_BOUNDARIES:
71
+ return f"\\u{ord(character):04x}"
72
+ if " " <= character < "\x7f" or character > "\x9f":
73
+ return character
74
+ return f"\\x{ord(character):02x}"
75
+
76
+
77
+ def _escape(text: str) -> str:
78
+ """Attacker-influenced text as one line that always encodes. Never raises.
79
+
80
+ A directory name on Linux is any byte sequence except ``/`` and NUL. Three things follow, and
81
+ each is closed here:
82
+
83
+ * A name can hold a **newline** and forge a line of this report.
84
+ * A name can hold any **other control character**, and an ESC is the one that matters: a
85
+ directory called ``evil\x1b[2K@1.0.0`` writes a raw ANSI erase-line into a report a person
86
+ reads. That is the same class as forging a line, and it is why every code point in the
87
+ Unicode ``Cc`` category is escaped by ordinal rather than left to a codec. ESC and BEL encode
88
+ cleanly to both ASCII and UTF-8, so no ``errors="backslashreplace"`` will ever touch them —
89
+ an earlier version of this function relied on exactly that and let them straight through.
90
+
91
+ **``Cc`` is 0x00-0x1F *and* 0x7F-0x9F**, and both ranges are escaped. U+009B is CSI in a UTF-8
92
+ xterm with ``allowC1Printable`` at its default, so a directory name carrying it would write a
93
+ raw control sequence into the report — the same defect the paragraph above describes, one
94
+ range further along.
95
+ * A name that is **not valid UTF-8** arrives with surrogate escapes, which a UTF-8 stdout
96
+ refuses to encode. The ``try`` is what narrows the last escape to the strings that genuinely
97
+ would not encode.
98
+
99
+ A **printable non-ASCII name is left alone**, rather than escaped to ``\\xc9mile@1.0.0``. That
100
+ is the one place the two forms legitimately differ: JSON uses ``ensure_ascii=True`` because its
101
+ output is a document a parser consumes and a lone surrogate breaks the encode, while this form
102
+ is read by a person, who is better served by the directory name their filesystem actually
103
+ holds.
104
+
105
+ The backslash replacement comes first, so an escape sequence a real name contains cannot be
106
+ confused with one this function produced.
107
+
108
+ It happens here and never in a producer. ``packs.py`` records why: escaping where the value is
109
+ read would make the escaped form the value, and a second renderer would escape it again.
110
+ """
111
+ for raw, escaped in (("\\", "\\\\"), ("\n", "\\n"), ("\r", "\\r"), ("\t", "\\t")):
112
+ text = text.replace(raw, escaped)
113
+ text = "".join(_escaped(character) for character in text)
114
+ try:
115
+ text.encode("utf-8")
116
+ except UnicodeEncodeError:
117
+ text = text.encode("utf-8", "backslashreplace").decode("utf-8")
118
+ return text
119
+
120
+
121
+ # ---------------------------------------------------------------------------------------------
122
+ # The text form
123
+ # ---------------------------------------------------------------------------------------------
124
+
125
+
126
+ def render_text(report: ResolutionReport) -> str:
127
+ """The report as the lines a person reads, in the order the resolution happened.
128
+
129
+ Sections in stage order — dependencies, project root, settings sources, effective settings with
130
+ their sources, then pack roots, candidates, matched packs and problems. Reading top to bottom
131
+ is reading what the tool did.
132
+
133
+ Every section is printed even when it is empty, and a section whose stage did not run says so
134
+ instead of being left out. A report that omits a section it could not fill reads as though the
135
+ thing it describes does not exist.
136
+
137
+ Provenance is printed inline against each value, in brackets, because a value whose source has
138
+ to be looked up elsewhere will not get looked up.
139
+
140
+ Everything this function writes encodes to UTF-8. Text taken from a filesystem or a settings
141
+ file goes through ``_escape``, which escapes what would not encode and leaves a printable
142
+ non-ASCII name as the name on disk; the words around it are ASCII literals. The stream is
143
+ pinned to UTF-8 in ``cli``, which is what carries that name to a console whose default encoding
144
+ is something else.
145
+ """
146
+ lines: list[str] = [f"periplus {_escape(report.tool_version)}"]
147
+ lines.append(f"rutters: {report.rutters_digest}")
148
+ lines += _block("Dependencies", _dependency_rows(report.dependencies))
149
+ lines += _block(
150
+ "Project root",
151
+ [["not found - no .periplus directory at or above the starting directory"]]
152
+ if report.project_root is None
153
+ else [[_escape(str(report.project_root))]],
154
+ )
155
+ lines += _block(
156
+ "Settings sources",
157
+ [[f"user configuration directory from {report.user_config_from}"]]
158
+ + _source_rows(report.settings_sources),
159
+ )
160
+ lines += _block("Effective settings", _settings_rows(report))
161
+ lines += _block("Pack roots", _root_rows(report.pack_roots))
162
+ lines += _block("Candidates", _candidate_rows(report.candidates))
163
+ lines += _block("Matched packs", _match_rows(report, report.packs))
164
+ lines += _block("Problems", _problem_rows(report.problems))
165
+ return "\n".join(lines) + "\n"
166
+
167
+
168
+ def _block(heading: str, rows: Sequence[Sequence[str]]) -> list[str]:
169
+ """One section: a blank line, its heading, and its rows aligned into columns.
170
+
171
+ Alignment is computed from the content of this section alone and never from the terminal. The
172
+ last populated cell in a row is not padded, so no line carries trailing whitespace.
173
+ """
174
+ widths: list[int] = []
175
+ for row in rows:
176
+ for index, cell in enumerate(row[:-1]):
177
+ if index >= len(widths):
178
+ widths.append(0)
179
+ widths[index] = max(widths[index], len(cell))
180
+ lines = ["", f"{heading}:"]
181
+ for row in rows:
182
+ cells = [
183
+ cell.ljust(widths[index]) if index < len(row) - 1 else cell
184
+ for index, cell in enumerate(row)
185
+ ]
186
+ lines.append((" " + " ".join(cells)).rstrip())
187
+ return lines
188
+
189
+
190
+ def _dependency_rows(dependencies: Sequence[DependencyStatus]) -> list[list[str]]:
191
+ """One row per required distribution: name, and the version found or that it is absent."""
192
+ if not dependencies:
193
+ return [["none required"]]
194
+ return [
195
+ [_escape(status.distribution), _escape(status.installed or "not installed")]
196
+ for status in dependencies
197
+ ]
198
+
199
+
200
+ def _source_rows(sources: Sequence[SettingsSource]) -> list[list[str]]:
201
+ """One row per located settings file, with what happened to it and why, if there was a why."""
202
+ if not sources:
203
+ return [["no settings file location is knowable"]]
204
+ return [
205
+ [source.kind, source.status, _escape(str(source.path)), _escape(source.reason or "")]
206
+ for source in sources
207
+ ]
208
+
209
+
210
+ def _root_rows(roots: Sequence[PackRoot]) -> list[list[str]]:
211
+ """One row per pack root, in the fixed root order, present or absent, never omitted.
212
+
213
+ Not sorted, and that is the declared key: project, user, bundled, then the ``pack_paths``
214
+ entries in the order they were merged. Sorting this would discard the search order.
215
+ """
216
+ if not roots:
217
+ return [["none"]]
218
+ return [
219
+ [
220
+ root.kind,
221
+ "present" if root.exists else "absent",
222
+ _escape(root.display),
223
+ _ref(root.source) if root.source is not None else "",
224
+ ]
225
+ for root in roots
226
+ ]
227
+
228
+
229
+ def _candidate_rows(candidates: Sequence[PackCandidate]) -> list[list[str]]:
230
+ """One row per entry found under a root — exactly one, whatever the entry is named.
231
+
232
+ ``discover_candidates`` has already sorted these by root order then entry name, by code point.
233
+ The escape is what keeps the count honest: a directory named with an embedded newline would
234
+ otherwise forge a row.
235
+ """
236
+ if not candidates:
237
+ return [["none"]]
238
+ return [
239
+ [
240
+ candidate.root.kind,
241
+ candidate.status,
242
+ _escape(candidate.entry),
243
+ _name(candidate.name),
244
+ ]
245
+ for candidate in candidates
246
+ ]
247
+
248
+
249
+ def _match_rows(report: ResolutionReport, matches: Sequence[MatchedPack]) -> list[list[str]]:
250
+ """One row per matched pin, in the order the settings file wrote its pins.
251
+
252
+ Not sorted: the declared key is the order the person chose, which is input and therefore stable
253
+ across runs. A name matched under two roots appears twice, because reporting it once would
254
+ resolve the duplicate by omission.
255
+ """
256
+ if "packs" in report.skipped:
257
+ return [[f"skipped - {_escape(report.skipped['packs'])}"]]
258
+ if not matches:
259
+ return [["none"]]
260
+ return [[_escape(match.pin), match.root.kind, _escape(match.path)] for match in matches]
261
+
262
+
263
+ def _problem_rows(problems: Sequence[Problem]) -> list[list[str]]:
264
+ """One row per problem, lowest code first, with its named facts beside its one line."""
265
+ if not problems:
266
+ return [["none"]]
267
+ rows: list[list[str]] = []
268
+ for problem in problems:
269
+ rows.append([str(int(problem.code)), _escape(problem.message)])
270
+ for key in sorted(problem.detail):
271
+ rows.append(["", f" {_escape(key)}: {_escape(problem.detail[key])}"])
272
+ return rows
273
+
274
+
275
+ def _settings_rows(report: ResolutionReport) -> list[list[str]]:
276
+ """Every settings key with its effective value and the file that decided it.
277
+
278
+ Two columns: the key, and the value with its provenance appended. Three columns were tried and
279
+ the third had to be padded to the widest value in the whole section, which pushed the source of
280
+ a one-character ``periplus_version`` out past column 120 because one ``first_party`` line is
281
+ long. Ragged provenance reads better than an aligned column nobody can reach.
282
+
283
+ A key no file declared reads "not declared", and a key a file declared empty reads "declared
284
+ empty" and names that file. The two are different facts: presence is what makes a file a
285
+ source, so a file writing ``re_include: []`` is a source of an empty value and not an absence.
286
+ """
287
+ settings = report.settings
288
+ if settings is None:
289
+ return [[f"skipped - {_escape(report.skipped.get('settings', 'not resolved'))}"]]
290
+ rows: list[list[str]] = []
291
+ for key in SETTINGS_KEYS:
292
+ rows.extend(_setting_rows(key, getattr(settings, key)))
293
+ return rows
294
+
295
+
296
+ def _joined_values(value: str | tuple[str, ...]) -> str:
297
+ """One value escaped, or several each escaped and joined by a comma and a space."""
298
+ return _escape(value) if isinstance(value, str) else ", ".join(_escape(v) for v in value)
299
+
300
+
301
+ def _setting_rows(key: str, value: object) -> list[list[str]]:
302
+ """One key's rows. Five value shapes, each named rather than walked generically.
303
+
304
+ The first row carries the key and every continuation row leaves it blank, so a multi-entry key
305
+ reads as one block rather than as one key repeated.
306
+ """
307
+ if isinstance(value, Mapping):
308
+ if not value:
309
+ return [[key, "none declared"]]
310
+ return _rows(
311
+ key,
312
+ [
313
+ _with_refs(f"{_escape(name)} = {_joined_values(entry.value)}", entry.sources)
314
+ for name, entry in sorted(value.items())
315
+ ],
316
+ )
317
+ if value is None:
318
+ return [[key, "not declared"]]
319
+ assert isinstance(value, ResolvedSetting) # noqa: S101 - the five shapes are exhaustive
320
+ if key == "first_party":
321
+ return _rows(
322
+ key,
323
+ [
324
+ _with_refs(f"{half} = {_joined(getattr(value.value, half))}", value.sources)
325
+ for half in ("include", "exclude")
326
+ ],
327
+ )
328
+ if isinstance(value.value, tuple):
329
+ return _rows(key, _entry_lines(value, _unknown if key == "accepted_unknowns" else str))
330
+ return [[key, _with_refs(_escape(str(value.value)), value.sources)]]
331
+
332
+
333
+ def _rows(key: str, details: Sequence[str]) -> list[list[str]]:
334
+ """One block: the key against its first line, and blank against every line after it."""
335
+ return [[key if index == 0 else "", detail] for index, detail in enumerate(details)]
336
+
337
+
338
+ def _entry_lines(setting: ResolvedSetting[object], render: Callable[[object], str]) -> list[str]:
339
+ """A sequence value as one line per entry, each naming the file that contributed *that* entry.
340
+
341
+ ``entry_sources`` and not ``sources``: for a key that accumulates, the file that added one
342
+ entry is a different fact from the files that had a say in the key, and zipping against
343
+ ``sources`` produces a plausible, silent misattribution.
344
+ """
345
+ items = setting.value
346
+ assert isinstance(items, tuple) # noqa: S101 - every sequence rule produces a tuple
347
+ if not items:
348
+ return [_with_refs("declared empty", setting.sources)]
349
+ per_entry = setting.entry_sources
350
+ return [
351
+ _with_refs(
352
+ _escape(render(item)),
353
+ (per_entry[index],) if per_entry is not None else setting.sources,
354
+ )
355
+ for index, item in enumerate(items)
356
+ ]
357
+
358
+
359
+ def _with_refs(text: str, sources: Sequence[SourceRef]) -> str:
360
+ """One value and the files behind it, on one line, with the provenance last."""
361
+ return f"{text} {_refs(sources)}".rstrip()
362
+
363
+
364
+ def _unknown(item: object) -> str:
365
+ """One ``accepted_unknowns`` entry as its keys in name order.
366
+
367
+ Its values are deliberately not all strings — an unquoted ``accepted_on: 2026-08-28`` loads as
368
+ a ``datetime.date`` — so each is rendered through ``str`` here rather than assumed.
369
+ """
370
+ if not isinstance(item, Mapping):
371
+ return str(item)
372
+ return ", ".join(f"{key}={item[key]}" for key in sorted(item))
373
+
374
+
375
+ def _name(name: PackName | None) -> str:
376
+ """A parsed directory name, or nothing when the name is not ``<pack>@<version>``."""
377
+ return "" if name is None else f"{_escape(name.pack)} {_escape(name.version)}"
378
+
379
+
380
+ def _ref(source: SourceRef) -> str:
381
+ """One settings file, as it is printed beside a value it decided."""
382
+ return f"[{source.kind} {_escape(source.name)}]"
383
+
384
+
385
+ def _refs(sources: Sequence[SourceRef]) -> str:
386
+ """Every settings file that had a say in one key, in cascade order."""
387
+ return " ".join(_ref(source) for source in sources)
388
+
389
+
390
+ def _joined(values: Iterable[object]) -> str:
391
+ """A sequence as one line. ``, `` because every element is already escaped to one line."""
392
+ return ", ".join(_escape(str(value)) for value in values)
393
+
394
+
395
+ # ---------------------------------------------------------------------------------------------
396
+ # The JSON form
397
+ # ---------------------------------------------------------------------------------------------
398
+
399
+
400
+ def render_json(report: ResolutionReport) -> str:
401
+ """The report as one JSON document, on one line per value, with sorted keys.
402
+
403
+ An **explicit projection**, not a walk over the dataclasses. Four shipped values are not JSON
404
+ primitives: ``PackRoot.traversable`` is a ``Traversable``, ``SettingsSource.path`` and ``.root``
405
+ and each ``pack_paths`` entry are ``Path`` objects, an ``accepted_unknowns`` value may be a
406
+ ``datetime.date``, and ``PackCandidate.root`` embeds a whole ``PackRoot`` that a walk would
407
+ repeat once per candidate. ``dataclasses.asdict`` raises ``TypeError`` on the first three and
408
+ silently duplicates the fourth, and ``default=str`` on ``json.dumps`` hides all four behind a
409
+ stringification nobody chose and lets a fifth arrive unnoticed.
410
+
411
+ So one private function per record type names the keys it emits and converts each value.
412
+ Adding a field to a dataclass therefore does *not* silently add a key: it adds nothing until
413
+ the projection names it, which is the safer of the two failure modes, because the alternative
414
+ is a key whose type nobody chose.
415
+
416
+ ``ensure_ascii=True`` rather than ``False``. A directory name on Linux is any byte sequence but
417
+ ``/`` and NUL, so ``os.listdir`` returns a name that is not valid UTF-8 with surrogate escapes,
418
+ and encoding one to UTF-8 raises ``UnicodeEncodeError: surrogates not allowed``. Under
419
+ ``ensure_ascii`` the same name encodes cleanly as an escape. The trade is that a legitimate
420
+ non-ASCII pack name renders escaped; for a tool whose job is to say where it looked, not
421
+ crashing on a filesystem a person's machine can genuinely produce wins.
422
+
423
+ Nothing here is passed through ``_escape``. That helper exists so a name cannot forge a *line*,
424
+ and JSON has no lines to forge: the encoder escapes a newline and a surrogate itself, losslessly
425
+ and reversibly, which is what the consumer of this form needs. Escaping first would hand that
426
+ consumer a name that is not the name on disk.
427
+ """
428
+ payload = {
429
+ "tool_version": report.tool_version,
430
+ "rutters_digest": report.rutters_digest,
431
+ "project_root": _text(report.project_root),
432
+ "user_config_from": report.user_config_from,
433
+ "settings_sources": [_json_source(source) for source in report.settings_sources],
434
+ "settings": _json_settings(report.settings),
435
+ "pack_roots": [_json_root(root) for root in report.pack_roots],
436
+ "candidates": [_json_candidate(candidate) for candidate in report.candidates],
437
+ "packs": [_json_match(match) for match in report.packs],
438
+ "dependencies": [_json_dependency(status) for status in report.dependencies],
439
+ "skipped": dict(report.skipped),
440
+ "problems": [_json_problem(problem) for problem in report.problems],
441
+ }
442
+ return json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=True) + "\n"
443
+
444
+
445
+ def _text(value: object) -> str | None:
446
+ """A ``Path`` or anything else printable as a string, and ``None`` as ``null``."""
447
+ return None if value is None else str(value)
448
+
449
+
450
+ def _json_source(source: SettingsSource) -> dict[str, object]:
451
+ """One located settings file. ``path`` and ``root`` are ``Path`` objects and become strings."""
452
+ return {
453
+ "kind": source.kind,
454
+ "path": str(source.path),
455
+ "root": str(source.root),
456
+ "status": source.status,
457
+ "reason": source.reason,
458
+ }
459
+
460
+
461
+ def _json_root(root: PackRoot) -> dict[str, object]:
462
+ """One pack root. ``traversable`` is never emitted — ``display`` is its rendered form, and a
463
+ ``Traversable``'s ``repr`` is implementation-defined for a zip install or a multiplexed path."""
464
+ return {
465
+ "kind": root.kind,
466
+ "display": root.display,
467
+ "exists": root.exists,
468
+ "source": _json_ref(root.source),
469
+ }
470
+
471
+
472
+ def _json_root_ref(root: PackRoot) -> dict[str, object]:
473
+ """A root as it appears *inside* a candidate or a match: which one it is, and nothing more.
474
+
475
+ Embedding the whole record here is what makes a mechanical walk emit every root once per
476
+ candidate — five copies of the bundled root on a run against this project's own repository.
477
+ """
478
+ return {"kind": root.kind, "display": root.display}
479
+
480
+
481
+ def _json_candidate(candidate: PackCandidate) -> dict[str, object]:
482
+ """One entry under one root."""
483
+ return {
484
+ "root": _json_root_ref(candidate.root),
485
+ "entry": candidate.entry,
486
+ "name": _json_name(candidate.name),
487
+ "status": candidate.status,
488
+ }
489
+
490
+
491
+ def _json_match(match: MatchedPack) -> dict[str, object]:
492
+ """One matched pin."""
493
+ return {
494
+ "pin": match.pin,
495
+ "name": _json_name(match.name),
496
+ "root": _json_root_ref(match.root),
497
+ "path": match.path,
498
+ }
499
+
500
+
501
+ def _json_name(name: PackName | None) -> dict[str, object] | None:
502
+ """A parsed directory name, or ``null`` when the name is not ``<pack>@<version>``."""
503
+ return None if name is None else {"pack": name.pack, "version": name.version}
504
+
505
+
506
+ def _json_ref(source: SourceRef | None) -> dict[str, object] | None:
507
+ """One settings file reference."""
508
+ return None if source is None else {"kind": source.kind, "name": source.name}
509
+
510
+
511
+ def _json_dependency(status: DependencyStatus) -> dict[str, object]:
512
+ """One required distribution."""
513
+ return {
514
+ "distribution": status.distribution,
515
+ "installed": status.installed,
516
+ "present": status.present,
517
+ }
518
+
519
+
520
+ def _json_problem(problem: Problem) -> dict[str, object]:
521
+ """One failure. ``code`` is an ``IntEnum`` member, emitted as the integer it is."""
522
+ return {
523
+ "code": int(problem.code),
524
+ "message": problem.message,
525
+ "detail": dict(problem.detail),
526
+ }
527
+
528
+
529
+ def _json_settings(settings: ResolvedSettings | None) -> dict[str, object] | None:
530
+ """Every settings key, or ``null`` when the merge did not run.
531
+
532
+ ``null`` and an object of nulls are different facts and both occur: the first means no file was
533
+ read at all, the second means files were read and declared none of these keys.
534
+ """
535
+ if settings is None:
536
+ return None
537
+ return {key: _json_setting(key, getattr(settings, key)) for key in SETTINGS_KEYS}
538
+
539
+
540
+ def _json_setting(key: str, value: object) -> object:
541
+ """One settings key: a mapping of settings, one setting, or ``null``."""
542
+ if isinstance(value, Mapping):
543
+ return {name: _json_one(entry) for name, entry in value.items()}
544
+ if value is None:
545
+ return None
546
+ assert isinstance(value, ResolvedSetting) # noqa: S101 - the five shapes are exhaustive
547
+ return _json_one(value)
548
+
549
+
550
+ def _json_one(setting: ResolvedSetting[object]) -> dict[str, object]:
551
+ """One effective value with every file behind it, and the per-entry attribution when there is
552
+ one. ``entry_sources`` is ``null`` for a key where there is nothing per-entry to say."""
553
+ return {
554
+ "value": _json_value(setting.value),
555
+ "sources": [_json_ref(source) for source in setting.sources],
556
+ "entry_sources": (
557
+ None
558
+ if setting.entry_sources is None
559
+ else [_json_ref(source) for source in setting.entry_sources]
560
+ ),
561
+ }
562
+
563
+
564
+ def _json_value(value: object) -> object:
565
+ """One effective value as JSON. Five shapes, and a declared floor under the fifth."""
566
+ if isinstance(value, FirstParty):
567
+ return {"include": list(value.include), "exclude": list(value.exclude)}
568
+ if isinstance(value, tuple):
569
+ return [_json_value(item) for item in value]
570
+ if isinstance(value, Mapping):
571
+ return {str(key): _json_scalar(item) for key, item in sorted(value.items(), key=str)}
572
+ return _json_scalar(value)
573
+
574
+
575
+ def _json_scalar(value: object) -> object:
576
+ """A JSON primitive, or its ``str`` when it is not one.
577
+
578
+ The floor is declared rather than delegated to ``default=str``: ``accepted_unknowns`` values are
579
+ deliberately not required to be strings, so a ``datetime.date`` reaches here and would raise
580
+ ``TypeError`` from the encoder on a settings file that is entirely legal. Everything else in
581
+ the record is already a primitive by the time it arrives, so this converts the one case the
582
+ schema allows rather than blanketing the document.
583
+ """
584
+ if isinstance(value, str | int | float | bool) or value is None:
585
+ return value
586
+ return str(value)
587
+
588
+
589
+ #: The formats ``--format`` offers, in the order they are offered. ``cli.build_parser`` writes the
590
+ #: same two names as a literal — it may not import this module, because that would put
591
+ #: ``ruamel.yaml`` on the console script's module-level import path — and a test asserts the two
592
+ #: agree.
593
+ RENDERERS: Mapping[str, Callable[[ResolutionReport], str]] = {
594
+ "text": render_text,
595
+ "json": render_json,
596
+ }