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.
- periplus/__init__.py +70 -0
- periplus/checkmap.py +80 -0
- periplus/cli.py +658 -0
- periplus/contract/schema/map.schema.json +138 -0
- periplus/contract/schema/navigation.schema.json +200 -0
- periplus/contract/schema/pack-file.schema.json +1405 -0
- periplus/contract/schema/pack-manifest.schema.json +245 -0
- periplus/contract/schema/project-settings.schema.json +85 -0
- periplus/contract/spec/engine.md +151 -0
- periplus/contract/spec/rules.md +352 -0
- periplus/detect.py +228 -0
- periplus/engine/__init__.py +5 -0
- periplus/engine/check.py +481 -0
- periplus/engine/emit.py +368 -0
- periplus/engine/packload.py +2321 -0
- periplus/engine/pattern.py +27 -0
- periplus/engine/rules.py +1946 -0
- periplus/engine/select.py +491 -0
- periplus/engine/source.py +172 -0
- periplus/engine/tree.py +424 -0
- periplus/errors.py +332 -0
- periplus/init.py +358 -0
- periplus/layout.py +28 -0
- periplus/manifest.py +498 -0
- periplus/map.py +364 -0
- periplus/navigate/__init__.py +1 -0
- periplus/navigate/find.py +143 -0
- periplus/navigate/ids.py +30 -0
- periplus/navigate/listing.py +42 -0
- periplus/navigate/mapfile.py +189 -0
- periplus/navigate/path.py +175 -0
- periplus/navigate/show.py +175 -0
- periplus/navigate/types.py +83 -0
- periplus/navigate/walk.py +138 -0
- periplus/packs/advancedqueue_basic@0.0.1/pack.yaml +14 -0
- periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type.yaml +9 -0
- periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_annotation.yaml +43 -0
- periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_attribute.yaml +43 -0
- periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent.yaml +8 -0
- periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent_attribute.yaml +43 -0
- periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call.yaml +8 -0
- periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call_attribute.yaml +43 -0
- periplus/packs/ai_basic@0.0.1/pack.yaml +15 -0
- periplus/packs/config_pages_basic@0.0.1/bundle/config_pages_bundle_config.yaml +54 -0
- periplus/packs/config_pages_basic@0.0.1/pack.yaml +11 -0
- periplus/packs/crop_basic@0.0.1/bundle/crop_bundle_config.yaml +54 -0
- periplus/packs/crop_basic@0.0.1/pack.yaml +11 -0
- periplus/packs/drupal@0.1.0/block/block_placement.yaml +25 -0
- periplus/packs/drupal@0.1.0/block/block_placement_code.yaml +58 -0
- periplus/packs/drupal@0.1.0/block/block_placement_config.yaml +53 -0
- periplus/packs/drupal@0.1.0/block/block_plugin.yaml +52 -0
- periplus/packs/drupal@0.1.0/block/block_plugin_altered.yaml +61 -0
- periplus/packs/drupal@0.1.0/block/block_plugin_annotation.yaml +69 -0
- periplus/packs/drupal@0.1.0/block/block_plugin_attribute.yaml +56 -0
- periplus/packs/drupal@0.1.0/block/block_plugin_content.yaml +46 -0
- periplus/packs/drupal@0.1.0/block/block_plugin_derived.yaml +110 -0
- periplus/packs/drupal@0.1.0/block/block_plugin_deriver.yaml +30 -0
- periplus/packs/drupal@0.1.0/block/block_type.yaml +14 -0
- periplus/packs/drupal@0.1.0/block/block_type_code.yaml +43 -0
- periplus/packs/drupal@0.1.0/block/block_type_config.yaml +45 -0
- periplus/packs/drupal@0.1.0/cache/cache.yaml +18 -0
- periplus/packs/drupal@0.1.0/cache/cache_backend.yaml +31 -0
- periplus/packs/drupal@0.1.0/component/sdc.yaml +19 -0
- periplus/packs/drupal@0.1.0/component/sdc_definition.yaml +25 -0
- periplus/packs/drupal@0.1.0/component/sdc_use.yaml +25 -0
- periplus/packs/drupal@0.1.0/config/config_object.yaml +65 -0
- periplus/packs/drupal@0.1.0/display/display.yaml +32 -0
- periplus/packs/drupal@0.1.0/display/display_code.yaml +53 -0
- periplus/packs/drupal@0.1.0/display/display_config.yaml +95 -0
- periplus/packs/drupal@0.1.0/entity/entity.yaml +33 -0
- periplus/packs/drupal@0.1.0/entity/entity_class.yaml +60 -0
- periplus/packs/drupal@0.1.0/entity/entity_save.yaml +27 -0
- periplus/packs/drupal@0.1.0/entity/entity_type_annotation.yaml +85 -0
- periplus/packs/drupal@0.1.0/entity/entity_type_attribute.yaml +76 -0
- periplus/packs/drupal@0.1.0/field/field_instance_code.yaml +40 -0
- periplus/packs/drupal@0.1.0/field/field_instance_config.yaml +58 -0
- periplus/packs/drupal@0.1.0/field/field_storage.yaml +37 -0
- periplus/packs/drupal@0.1.0/field/field_storage_code.yaml +39 -0
- periplus/packs/drupal@0.1.0/field/field_storage_config.yaml +24 -0
- periplus/packs/drupal@0.1.0/form/form.yaml +48 -0
- periplus/packs/drupal@0.1.0/form/form_class.yaml +50 -0
- periplus/packs/drupal@0.1.0/form/form_id.yaml +48 -0
- periplus/packs/drupal@0.1.0/form/form_methods.yaml +39 -0
- periplus/packs/drupal@0.1.0/hook/hook.yaml +8 -0
- periplus/packs/drupal@0.1.0/hook/hook_attribute.yaml +28 -0
- periplus/packs/drupal@0.1.0/module/module.yaml +8 -0
- periplus/packs/drupal@0.1.0/module/module_info.yaml +50 -0
- periplus/packs/drupal@0.1.0/pack.yaml +184 -0
- periplus/packs/drupal@0.1.0/plugin/plugin.yaml +17 -0
- periplus/packs/drupal@0.1.0/plugin/plugin_annotation.yaml +120 -0
- periplus/packs/drupal@0.1.0/recommendations/recommendations.yaml +14 -0
- periplus/packs/drupal@0.1.0/service/service.yaml +19 -0
- periplus/packs/drupal@0.1.0/service/service_autowire.yaml +28 -0
- periplus/packs/drupal@0.1.0/service/service_definition.yaml +24 -0
- periplus/packs/drupal@0.1.0/service/service_use.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/action/action.yaml +9 -0
- periplus/packs/drupal_basic@0.3.0/action/action_annotation.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/action/action_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/block/block_placement.yaml +16 -0
- periplus/packs/drupal_basic@0.3.0/block/block_placement_config.yaml +51 -0
- periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin.yaml +14 -0
- periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_annotation.yaml +47 -0
- periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/block_type/block_type.yaml +8 -0
- periplus/packs/drupal_basic@0.3.0/block_type/block_type_config.yaml +54 -0
- periplus/packs/drupal_basic@0.3.0/boundary/boundary_types.yaml +19 -0
- periplus/packs/drupal_basic@0.3.0/bundle/bundle.yaml +20 -0
- periplus/packs/drupal_basic@0.3.0/bundle/bundle_config.yaml +393 -0
- periplus/packs/drupal_basic@0.3.0/call/call.yaml +349 -0
- periplus/packs/drupal_basic@0.3.0/component/component.yaml +30 -0
- periplus/packs/drupal_basic@0.3.0/config/config_object.yaml +119 -0
- periplus/packs/drupal_basic@0.3.0/constraint/constraint.yaml +8 -0
- periplus/packs/drupal_basic@0.3.0/constraint/constraint_annotation.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/constraint/constraint_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/display/display.yaml +30 -0
- periplus/packs/drupal_basic@0.3.0/display/display_config.yaml +203 -0
- periplus/packs/drupal_basic@0.3.0/display/display_mode_config.yaml +60 -0
- periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_annotation.yaml +46 -0
- periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_annotation.yaml +46 -0
- periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/entity_type/entity_type.yaml +16 -0
- periplus/packs/drupal_basic@0.3.0/field/field_instance_config.yaml +116 -0
- periplus/packs/drupal_basic@0.3.0/field/field_storage.yaml +21 -0
- periplus/packs/drupal_basic@0.3.0/field/field_storage_config.yaml +45 -0
- periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter.yaml +8 -0
- periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_annotation.yaml +46 -0
- periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/field_type/field_type_annotation.yaml +46 -0
- periplus/packs/drupal_basic@0.3.0/field_type/field_type_attribute.yaml +50 -0
- periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_annotation.yaml +46 -0
- periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_attribute.yaml +50 -0
- periplus/packs/drupal_basic@0.3.0/form/form_id.yaml +80 -0
- periplus/packs/drupal_basic@0.3.0/hook/hook.yaml +100 -0
- periplus/packs/drupal_basic@0.3.0/hook/hook_attribute.yaml +64 -0
- periplus/packs/drupal_basic@0.3.0/hook/hook_theme_include.yaml +34 -0
- periplus/packs/drupal_basic@0.3.0/library/library.yaml +12 -0
- periplus/packs/drupal_basic@0.3.0/library/library_definition.yaml +58 -0
- periplus/packs/drupal_basic@0.3.0/local_action/local_action.yaml +12 -0
- periplus/packs/drupal_basic@0.3.0/local_action/local_action_definition.yaml +49 -0
- periplus/packs/drupal_basic@0.3.0/mail/mail.yaml +8 -0
- periplus/packs/drupal_basic@0.3.0/mail/mail_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/media_source/media_source.yaml +8 -0
- periplus/packs/drupal_basic@0.3.0/media_source/media_source_annotation.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/media_source/media_source_attribute.yaml +79 -0
- periplus/packs/drupal_basic@0.3.0/menu_link/menu_link.yaml +18 -0
- periplus/packs/drupal_basic@0.3.0/menu_link/menu_link_definition.yaml +48 -0
- periplus/packs/drupal_basic@0.3.0/module/module.yaml +7 -0
- periplus/packs/drupal_basic@0.3.0/module/module_info.yaml +58 -0
- periplus/packs/drupal_basic@0.3.0/pack.yaml +528 -0
- periplus/packs/drupal_basic@0.3.0/permission/permission.yaml +7 -0
- periplus/packs/drupal_basic@0.3.0/permission/permission_definition.yaml +27 -0
- periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker.yaml +9 -0
- periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_annotation.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource.yaml +8 -0
- periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_annotation.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_attribute.yaml +43 -0
- periplus/packs/drupal_basic@0.3.0/role/role.yaml +14 -0
- periplus/packs/drupal_basic@0.3.0/role/role_config.yaml +44 -0
- periplus/packs/drupal_basic@0.3.0/route/route.yaml +42 -0
- periplus/packs/drupal_basic@0.3.0/route/route_definition.yaml +113 -0
- periplus/packs/drupal_basic@0.3.0/route/route_options.yaml +55 -0
- periplus/packs/drupal_basic@0.3.0/route/route_requirements.yaml +108 -0
- periplus/packs/drupal_basic@0.3.0/service/service.yaml +56 -0
- periplus/packs/drupal_basic@0.3.0/service/service_definition.yaml +181 -0
- periplus/packs/drupal_basic@0.3.0/template/template.yaml +86 -0
- periplus/packs/drupal_basic@0.3.0/template/template_route_link.yaml +30 -0
- periplus/packs/drupal_basic@0.3.0/theme/theme.yaml +15 -0
- periplus/packs/drupal_basic@0.3.0/theme/theme_info.yaml +61 -0
- periplus/packs/drupal_basic@0.3.0/theme_hook/template_suggestion.yaml +29 -0
- periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook.yaml +87 -0
- periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook_render.yaml +20 -0
- periplus/packs/drupal_basic@0.3.0/url/url.yaml +88 -0
- periplus/packs/drupal_basic@0.3.0/views_plugin/views_field_attribute.yaml +80 -0
- periplus/packs/drupal_basic@0.3.0/views_plugin/views_filter_attribute.yaml +80 -0
- periplus/packs/drupal_basic@0.3.0/views_plugin/views_plugin.yaml +15 -0
- periplus/packs/drupal_basic@0.3.0/views_plugin/views_style_attribute.yaml +80 -0
- periplus/packs/drupal_js_basic@0.0.1/attachment/attachment.yaml +12 -0
- periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php.yaml +15 -0
- periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php_array.yaml +52 -0
- periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_twig.yaml +26 -0
- periplus/packs/drupal_js_basic@0.0.1/attachment/php_function_holder.yaml +13 -0
- periplus/packs/drupal_js_basic@0.0.1/behavior/behavior.yaml +198 -0
- periplus/packs/drupal_js_basic@0.0.1/pack.yaml +153 -0
- periplus/packs/drupal_js_basic@0.0.1/setting/setting.yaml +13 -0
- periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_js.yaml +15 -0
- periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_php.yaml +92 -0
- periplus/packs/drush_basic@0.0.1/command/command.yaml +15 -0
- periplus/packs/drush_basic@0.0.1/command/command_annotation.yaml +60 -0
- periplus/packs/drush_basic@0.0.1/command/command_attribute.yaml +88 -0
- periplus/packs/drush_basic@0.0.1/pack.yaml +29 -0
- periplus/packs/eck_basic@0.0.1/bundle/eck_bundle_config.yaml +56 -0
- periplus/packs/eck_basic@0.0.1/entity_type/eck_entity_type_config.yaml +26 -0
- periplus/packs/eck_basic@0.0.1/pack.yaml +28 -0
- periplus/packs/go@0.0.2/interfaces/satisfies.yaml +47 -0
- periplus/packs/go@0.0.2/pack.yaml +51 -0
- periplus/packs/go@0.0.2/types/find_declarations.yaml +137 -0
- periplus/packs/go@0.0.2/types/node_types.yaml +36 -0
- periplus/packs/go_basic@0.0.3/pack.yaml +104 -0
- periplus/packs/go_basic@0.0.3/rules/calls.yaml +25 -0
- periplus/packs/go_basic@0.0.3/rules/declarations.yaml +40 -0
- periplus/packs/go_basic@0.0.3/rules/imports.yaml +16 -0
- periplus/packs/go_basic@0.0.3/types/node_types.yaml +15 -0
- periplus/packs/js_basic@0.0.1/function/function.yaml +68 -0
- periplus/packs/js_basic@0.0.1/pack.yaml +50 -0
- periplus/packs/laravel_basic@0.0.5/pack.yaml +313 -0
- periplus/packs/laravel_basic@0.0.5/rules/classes.yaml +42 -0
- periplus/packs/laravel_basic@0.0.5/rules/config.yaml +229 -0
- periplus/packs/laravel_basic@0.0.5/rules/routes.yaml +210 -0
- periplus/packs/laravel_basic@0.0.5/rules/types.yaml +50 -0
- periplus/packs/laravel_basic@0.0.5/rules/views.yaml +100 -0
- periplus/packs/paragraphs_basic@0.0.1/bundle/paragraph_bundle_config.yaml +54 -0
- periplus/packs/paragraphs_basic@0.0.1/pack.yaml +12 -0
- periplus/packs/php@0.1.0/edges/edge_kinds.yaml +64 -0
- periplus/packs/php@0.1.0/edges/find_calls.yaml +34 -0
- periplus/packs/php@0.1.0/edges/find_imports.yaml +18 -0
- periplus/packs/php@0.1.0/edges/find_member_access.yaml +22 -0
- periplus/packs/php@0.1.0/edges/find_structure.yaml +38 -0
- periplus/packs/php@0.1.0/embedded/embedded_regions.yaml +19 -0
- periplus/packs/php@0.1.0/imports/imports.yaml +20 -0
- periplus/packs/php@0.1.0/pack.yaml +117 -0
- periplus/packs/php@0.1.0/resolution/excluded_tokens.yaml +23 -0
- periplus/packs/php@0.1.0/resolution/resolution.yaml +35 -0
- periplus/packs/php@0.1.0/types/find_declarations.yaml +102 -0
- periplus/packs/php@0.1.0/types/node_types.yaml +55 -0
- periplus/packs/php_basic@0.2.0/edges/edge_kinds.yaml +48 -0
- periplus/packs/php_basic@0.2.0/edges/find_structure.yaml +750 -0
- periplus/packs/php_basic@0.2.0/imports/imports.yaml +19 -0
- periplus/packs/php_basic@0.2.0/pack.yaml +94 -0
- periplus/packs/php_basic@0.2.0/types/find_declarations.yaml +267 -0
- periplus/packs/php_basic@0.2.0/types/node_types.yaml +55 -0
- periplus/packs/profile_basic@0.0.1/bundle/profile_bundle_config.yaml +54 -0
- periplus/packs/profile_basic@0.0.1/pack.yaml +12 -0
- periplus/packs/salesforce_basic@0.0.1/event/salesforce_event.yaml +36 -0
- periplus/packs/salesforce_basic@0.0.1/pack.yaml +31 -0
- periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping.yaml +19 -0
- periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping_config.yaml +83 -0
- periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field.yaml +8 -0
- periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field_annotation.yaml +43 -0
- periplus/packs/twig@0.0.1/pack.yaml +26 -0
- periplus/packs/twig@0.0.1/types/template.yaml +57 -0
- periplus/packs/twig_basic@0.1.0/pack.yaml +11 -0
- periplus/packs/twig_tweak_basic@0.0.1/pack.yaml +27 -0
- periplus/packs/twig_tweak_basic@0.0.1/rules/block.yaml +20 -0
- periplus/packs/twig_tweak_basic@0.0.1/rules/entity.yaml +35 -0
- periplus/packs/twig_tweak_basic@0.0.1/rules/field.yaml +21 -0
- periplus/packs/twig_tweak_basic@0.0.1/rules/menu.yaml +21 -0
- periplus/packs/webform_basic@0.0.1/bundle/webform_submission_bundle_config.yaml +54 -0
- periplus/packs/webform_basic@0.0.1/pack.yaml +20 -0
- periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler.yaml +8 -0
- periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler_annotation.yaml +43 -0
- periplus/packs/yaml@0.0.1/pack.yaml +72 -0
- periplus/packs/yaml@0.0.1/types/find_mapping_entry.yaml +21 -0
- periplus/packs/yaml@0.0.1/types/yaml_types.yaml +44 -0
- periplus/packs/yaml_basic@0.1.0/pack.yaml +10 -0
- periplus/packs.py +447 -0
- periplus/preflight.py +102 -0
- periplus/py.typed +0 -0
- periplus/report.py +596 -0
- periplus/resolution.py +355 -0
- periplus/settings.py +617 -0
- periplus/settings.stub.yml +99 -0
- periplus/spec.py +774 -0
- periplus/update.py +197 -0
- periplus/validate.py +655 -0
- periplus_map-0.2.1.dist-info/METADATA +566 -0
- periplus_map-0.2.1.dist-info/RECORD +272 -0
- periplus_map-0.2.1.dist-info/WHEEL +4 -0
- periplus_map-0.2.1.dist-info/entry_points.txt +2 -0
- periplus_map-0.2.1.dist-info/licenses/LICENSE +202 -0
- 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
|
+
}
|