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/spec.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""``periplus spec``: publish the contract a pack is authored against, and stop.
|
|
2
|
+
|
|
3
|
+
The consumer is a model. It types one command and has to come away with everything it needs to
|
|
4
|
+
write a pack: the four schemas that say what a pack file may contain, the two prose documents that
|
|
5
|
+
say what a rule is and what the engine does with it, and an honest list of what the schema language
|
|
6
|
+
cannot yet express. So the whole contract is emitted, not a summary of it and not a pointer to a
|
|
7
|
+
path — a path is a second command a model has to guess at, and a summary is prose nobody reviewed
|
|
8
|
+
standing in for prose somebody did.
|
|
9
|
+
|
|
10
|
+
**Everything published here is read out of the installed package**, through
|
|
11
|
+
``importlib.resources``, never from a path assembled relative to this file. That is what makes the
|
|
12
|
+
command work from a wheel, a zip install and a source checkout alike, and it is the same route
|
|
13
|
+
``init`` reaches its stub through and ``packs`` reaches the bundled pack root through.
|
|
14
|
+
|
|
15
|
+
Two things this module deliberately does not do.
|
|
16
|
+
|
|
17
|
+
It does **not** read a pack's node types and edge kinds. ``periplus spec <pack>`` reports what that
|
|
18
|
+
pack's manifest declares and what it inherits *from*; the vocabulary itself needs a reader that
|
|
19
|
+
walks a pack's rule files, and that reader does not exist. Where the answer is knowable without one
|
|
20
|
+
it is given: a pack whose manifest declares no ``depends`` has an empty dependency closure, so the
|
|
21
|
+
union of what it inherits is empty by arithmetic rather than by a stub. Where it is not knowable,
|
|
22
|
+
``SpecReport.inherited`` is ``None`` and the renderers say the vocabulary has not been read. A
|
|
23
|
+
tuple of nothing and "nothing has been read" are different facts and this module refuses to print
|
|
24
|
+
the first when it means the second.
|
|
25
|
+
|
|
26
|
+
It does **not** validate anything. A manifest that names itself something other than its directory,
|
|
27
|
+
a ``depends`` that is a string rather than a list — all of that loads exactly as ``manifest.py``
|
|
28
|
+
loads it, which is to say permissively, and is the validator's to refuse.
|
|
29
|
+
|
|
30
|
+
One rule decides what goes through ``report._escape`` and what does not, because the two halves of
|
|
31
|
+
this output have different provenance:
|
|
32
|
+
|
|
33
|
+
* **Package data is emitted verbatim.** The contract documents are the artifact being published —
|
|
34
|
+
escaping them would hand a reader a document that is not the document — and a gap's text is a
|
|
35
|
+
slice of the same bytes. They ship inside the wheel; a person's input cannot reach them.
|
|
36
|
+
* **Everything else is escaped.** A pack name comes off the command line, a directory name comes
|
|
37
|
+
off a filesystem where any byte but ``/`` and NUL is legal, and a problem message quotes both.
|
|
38
|
+
Those are attacker-influenced text and go through ``_escape`` exactly as they do in ``report``
|
|
39
|
+
and ``init``.
|
|
40
|
+
|
|
41
|
+
Determinism, which is a stated property of the project rather than a nicety. Three sorts are
|
|
42
|
+
written in this module and they are not worth the same, so which is which is recorded here rather
|
|
43
|
+
than left for a reader to work out from the call sites:
|
|
44
|
+
|
|
45
|
+
* **``read_contract``'s sort over documents is load-bearing.** It is the one place filesystem
|
|
46
|
+
order could reach this output. ``Traversable.iterdir`` declares no order and on ext4 with hashed
|
|
47
|
+
directories it is arbitrary, so without it two machines holding one install print the contract
|
|
48
|
+
in two orders. Sorted after the walk rather than during it, so it is a single statement a test
|
|
49
|
+
can remove; ``test_the_contract_comes_out_in_path_order_whatever_order_the_package_lists_it_in``
|
|
50
|
+
removes it by reading the same tree through a ``Traversable`` that lists in reverse.
|
|
51
|
+
* **``describe_contract``'s sort over problems is load-bearing.** ``resolve_pack_order`` emits
|
|
52
|
+
problems in the order its walk finds them, which is graph order and not code order: a pack whose
|
|
53
|
+
``depends`` names both a cycle and a pack nobody installed hands back ``DEPENDS_CYCLE`` before
|
|
54
|
+
``DEPENDS_UNMATCHED``, and this sort is what turns that into ``12`` then ``14``.
|
|
55
|
+
``test_two_problems_are_printed_in_code_order_and_not_in_the_order_the_walk_found_them``
|
|
56
|
+
builds exactly that pack and fails if the sort goes.
|
|
57
|
+
* **``_describe_pack``'s ``entries = sorted(...)`` is belt and braces, and nothing observes it.**
|
|
58
|
+
Its result feeds ``match_pins``, whose problems are sorted by the bullet above and whose
|
|
59
|
+
directories ``manifest._sorted_directories`` sorts again before either is printed. It is kept
|
|
60
|
+
because a determinism property that holds only because two downstream modules happen to sort is
|
|
61
|
+
a property held by a coincidence, but it is not defended by a test, and a reader should not go
|
|
62
|
+
looking for the one that defends it.
|
|
63
|
+
|
|
64
|
+
Nothing else here iterates a set: the gaps come out in the order their document writes them, which
|
|
65
|
+
is a function of the file's bytes, and the pack names come from ``manifest.resolve_pack_order``,
|
|
66
|
+
which already sorts.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from __future__ import annotations
|
|
70
|
+
|
|
71
|
+
import importlib.resources
|
|
72
|
+
import json
|
|
73
|
+
import re
|
|
74
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
75
|
+
from dataclasses import dataclass
|
|
76
|
+
from importlib.resources.abc import Traversable
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
import periplus
|
|
80
|
+
from periplus.errors import ExitCode, Problem
|
|
81
|
+
from periplus.manifest import LoadedPack, resolve_pack_order
|
|
82
|
+
from periplus.packs import PackCandidate, match_pins
|
|
83
|
+
from periplus.preflight import DependencyStatus
|
|
84
|
+
from periplus.report import _escape
|
|
85
|
+
from periplus.resolution import resolve
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"CONTRACT_DIRECTORY",
|
|
89
|
+
"RENDERERS",
|
|
90
|
+
"SUMMARY_RENDERERS",
|
|
91
|
+
"ContractDocument",
|
|
92
|
+
"Gap",
|
|
93
|
+
"InheritedVocabulary",
|
|
94
|
+
"PackSubject",
|
|
95
|
+
"SpecReport",
|
|
96
|
+
"contract_root",
|
|
97
|
+
"describe_contract",
|
|
98
|
+
"read_contract",
|
|
99
|
+
"render_json",
|
|
100
|
+
"render_json_summary",
|
|
101
|
+
"render_text",
|
|
102
|
+
"render_text_summary",
|
|
103
|
+
"spec",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
#: The directory inside the package that holds the contract. One name, read by this module and by
|
|
107
|
+
#: nothing else today; ``tests/test_packaging.py`` declares the file list the wheel may carry under
|
|
108
|
+
#: it, which is the bound on what this command can publish.
|
|
109
|
+
CONTRACT_DIRECTORY = "contract"
|
|
110
|
+
|
|
111
|
+
#: A heading that opens a list of things the contract cannot yet express. Two documents write it
|
|
112
|
+
#: two ways — ``## Still open`` in ``rules.md`` and ``## Open, and deliberately not decided here``
|
|
113
|
+
#: in ``engine.md`` — so this matches the shape rather than either literal.
|
|
114
|
+
#:
|
|
115
|
+
#: It is an **address**, not content. The four gaps themselves are not written down anywhere in
|
|
116
|
+
#: this package: they are read out of the shipped Markdown every run, so adding a fifth bullet to
|
|
117
|
+
#: either document publishes a fifth gap with no edit here. That is the whole reason this parser
|
|
118
|
+
#: exists instead of a tuple of strings.
|
|
119
|
+
#:
|
|
120
|
+
#: The shape is ``open`` as the **last word of a clause**: the heading ends there, or a comma,
|
|
121
|
+
#: colon, semicolon or full stop does. ``## Still open`` and ``## Open, and deliberately not
|
|
122
|
+
#: decided here`` are the two written today and both are that shape. What the shape excludes is a
|
|
123
|
+
#: heading where ``open`` is an adjective on some other subject — ``## Open questions for
|
|
124
|
+
#: reviewers``, ``## Open source dependencies``, ``## open-ended`` — every one of which a
|
|
125
|
+
#: ``\b`` after the word accepted. That is not a hypothetical over-match: the day somebody adds an
|
|
126
|
+
#: ``## Open source dependencies`` section to ``engine.md``, every bullet under it would have been
|
|
127
|
+
#: published as a limit of the schema language, with nothing in the output saying so.
|
|
128
|
+
_GAP_HEADING = re.compile(r"^\#{1,2} (?:still )?open\s*(?:[,:;.]|$)", re.IGNORECASE)
|
|
129
|
+
|
|
130
|
+
#: A heading at the level that ends a section. A ``###`` subsection stays inside its ``##`` parent.
|
|
131
|
+
_SECTION_HEADING = re.compile(r"^\#{1,2} ")
|
|
132
|
+
|
|
133
|
+
#: A bullet's bolded lead, as its title. ``- **Cardinality.** Nothing says …`` has the title
|
|
134
|
+
#: ``Cardinality``; the trailing full stop belongs to the sentence and not to the name. A bullet
|
|
135
|
+
#: with no bolded lead has no title, which is a fact about the bullet rather than an error.
|
|
136
|
+
_GAP_TITLE = re.compile(r"^\*\*(?P<title>.+?)\.?\*\*\s*")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class ContractDocument:
|
|
141
|
+
"""One file shipped under ``contract/``, and its bytes as text.
|
|
142
|
+
|
|
143
|
+
``path`` is relative to ``contract/`` and joined with ``/`` on every platform, because it names
|
|
144
|
+
a resource inside a package and not a location on a disk. ``schema/map.schema.json`` is the
|
|
145
|
+
same string whatever ran the command.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
path: str
|
|
149
|
+
text: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class Gap:
|
|
154
|
+
"""One thing the schema language cannot yet express, as the document states it.
|
|
155
|
+
|
|
156
|
+
``title`` is the bullet's bolded lead, or an empty string when it has none. ``text`` is the
|
|
157
|
+
rest of the bullet with its continuation lines joined into one, so a listing prints one gap per
|
|
158
|
+
line and a name carrying a line break cannot forge a second.
|
|
159
|
+
|
|
160
|
+
Both are shipped package data and are emitted verbatim. See the module docstring.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
document: str
|
|
164
|
+
title: str
|
|
165
|
+
text: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class InheritedVocabulary:
|
|
170
|
+
"""The node types and edge kinds a pack may name because something it depends on declares them.
|
|
171
|
+
|
|
172
|
+
Empty tuples mean the vocabulary was worked out and is empty. That is not the same as
|
|
173
|
+
``SpecReport.inherited`` being ``None``, which means it was not worked out at all — the record
|
|
174
|
+
exists so a consumer has to branch on the difference instead of reading an absence as a zero.
|
|
175
|
+
|
|
176
|
+
**This is the seam the next unit fills.** Resolving a non-empty ``depends`` chain into names
|
|
177
|
+
needs a reader that opens a pack's rule files, and there is none. When there is, it produces
|
|
178
|
+
one of these and the ``None`` branch in ``_describe_pack`` goes away; nothing else in this
|
|
179
|
+
module, and nothing in either renderer, has to change shape.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
node_types: tuple[str, ...]
|
|
183
|
+
edge_kinds: tuple[str, ...]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class PackSubject:
|
|
188
|
+
"""The pack the command was asked about, as the search path and its own manifest describe it.
|
|
189
|
+
|
|
190
|
+
``requested`` is the name off the command line, kept whatever became of it, so a report about a
|
|
191
|
+
pack that is not installed still says which one was asked for.
|
|
192
|
+
|
|
193
|
+
``directory`` is ``None`` when no directory on the search path carries the name, or when the
|
|
194
|
+
one that does has a manifest that could not be read. Both are reported as a problem beside it.
|
|
195
|
+
|
|
196
|
+
``declares`` and ``version`` are what the manifest says about itself, which is deliberately not
|
|
197
|
+
checked against ``requested``: ``manifest.py`` carries the directory's name and the file's own
|
|
198
|
+
name side by side and lets neither win, because choosing between them is the validator's, and
|
|
199
|
+
this module is a consumer of that record rather than a second place the judgment is made.
|
|
200
|
+
|
|
201
|
+
``depends`` is the parsed list, sorted and de-duplicated. ``inherits_from`` is every other pack
|
|
202
|
+
the dependency walk actually reached, which is a different fact: a ``depends`` entry naming a
|
|
203
|
+
pack that is not installed appears in the first and not in the second.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
requested: str
|
|
207
|
+
directory: str | None
|
|
208
|
+
declares: str | None
|
|
209
|
+
version: str | None
|
|
210
|
+
depends: tuple[str, ...]
|
|
211
|
+
inherits_from: tuple[str, ...]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True, slots=True)
|
|
215
|
+
class SpecReport:
|
|
216
|
+
"""One ``spec`` run: the contract, what is open in it, and the pack it was asked about.
|
|
217
|
+
|
|
218
|
+
``pack`` is ``None`` when no pack was named. ``inherited`` is then the empty vocabulary rather
|
|
219
|
+
than ``None``, and the distinction is the point: nothing was named, so nothing is inherited,
|
|
220
|
+
and that is an answer rather than an absence.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
tool_version: str
|
|
224
|
+
documents: tuple[ContractDocument, ...]
|
|
225
|
+
gaps: tuple[Gap, ...]
|
|
226
|
+
pack: PackSubject | None
|
|
227
|
+
inherited: InheritedVocabulary | None
|
|
228
|
+
problems: tuple[Problem, ...]
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def exit_code(self) -> ExitCode:
|
|
232
|
+
"""The lowest code among the problems, or ``OK``.
|
|
233
|
+
|
|
234
|
+
The same expression as ``ResolutionReport.exit_code`` and ``InitReport.exit_code``,
|
|
235
|
+
repeated rather than factored out, for the reason ``init.py`` records: a shared base class
|
|
236
|
+
between records that have nothing else in common is an edge that lets a change to one
|
|
237
|
+
report change another.
|
|
238
|
+
"""
|
|
239
|
+
return min((problem.code for problem in self.problems), default=ExitCode.OK)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def contract_root() -> Traversable:
|
|
243
|
+
"""The shipped ``contract/`` directory, reached through the package rather than a built path.
|
|
244
|
+
|
|
245
|
+
``importlib.resources`` and never ``Path(__file__).parent``: the second works in a checkout and
|
|
246
|
+
is wrong everywhere else, and it is the exact defect ``tests/test_contract.py`` installs a real
|
|
247
|
+
wheel to rule out.
|
|
248
|
+
"""
|
|
249
|
+
return importlib.resources.files("periplus") / CONTRACT_DIRECTORY
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read_contract(root: Traversable) -> tuple[ContractDocument, ...]:
|
|
253
|
+
"""Every file under ``contract/``, recursively, in path order.
|
|
254
|
+
|
|
255
|
+
The walk is unconditional rather than a lookup of six known names. A seventh contract file
|
|
256
|
+
added to the package is published by this command with no edit here, which is the same posture
|
|
257
|
+
``tests/test_contract.py`` takes when it asserts set equality over what an install exposes
|
|
258
|
+
rather than looking up the names it expects.
|
|
259
|
+
|
|
260
|
+
Sorted **after** the walk and not during it. One sort over the whole result is one statement a
|
|
261
|
+
reader can find and a test can delete, where a sort per directory level is three places the
|
|
262
|
+
property lives; ``Traversable.iterdir`` has no declared order and on ext4 with hashed
|
|
263
|
+
directories it is arbitrary, so without this two machines holding one install would print the
|
|
264
|
+
contract in two orders.
|
|
265
|
+
|
|
266
|
+
Nothing here is caught. A file under ``contract/`` that cannot be read or decoded means the
|
|
267
|
+
install is damaged, which is not a state a person's input can reach and has no remedy this
|
|
268
|
+
command could print; it raises, and the interpreter's own status and traceback say more than a
|
|
269
|
+
``Problem`` would.
|
|
270
|
+
"""
|
|
271
|
+
documents: list[ContractDocument] = []
|
|
272
|
+
_visit(root, "", documents)
|
|
273
|
+
return tuple(sorted(documents, key=lambda document: document.path))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _visit(node: Traversable, path: str, documents: list[ContractDocument]) -> None:
|
|
277
|
+
"""Append every file at or under ``node``, naming each by its path relative to the root."""
|
|
278
|
+
if node.is_dir():
|
|
279
|
+
for child in node.iterdir():
|
|
280
|
+
_visit(child, f"{path}/{child.name}" if path else child.name, documents)
|
|
281
|
+
return
|
|
282
|
+
documents.append(ContractDocument(path=path, text=node.read_text(encoding="utf-8")))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def read_gaps(documents: Sequence[ContractDocument]) -> tuple[Gap, ...]:
|
|
286
|
+
"""Everything the contract says it cannot yet express, read out of the contract.
|
|
287
|
+
|
|
288
|
+
A gap is a bullet under a heading that opens an open-questions section — ``## Still open`` in
|
|
289
|
+
``rules.md``, ``## Open, and deliberately not decided here`` in ``engine.md``. Neither the
|
|
290
|
+
headings' exact wording nor any gap's text is written down in this package: the heading is
|
|
291
|
+
matched by shape and the bullets are read, so **adding a bullet to either document adds a gap
|
|
292
|
+
to this command's output with no change here**. That is the property, and it is why this is a
|
|
293
|
+
parser rather than a tuple of strings that would have to be edited in lockstep with the prose.
|
|
294
|
+
|
|
295
|
+
Every document is offered to the parser, including the schemas. A JSON document has no line
|
|
296
|
+
starting with ``## `` — a JSON string cannot carry a raw newline — so the parser finds nothing
|
|
297
|
+
in one, and that costs a scan of bytes already in memory and saves a rule about which
|
|
298
|
+
extensions are prose. A seventh contract document written in Markdown contributes its own open
|
|
299
|
+
questions the day it ships.
|
|
300
|
+
|
|
301
|
+
Documents come in path order and each document's gaps come out in the order it writes them.
|
|
302
|
+
That order is the document's own and is a function of its bytes, so it is stable across
|
|
303
|
+
machines the way a sort would be, and it keeps a list a person wrote reading the way they wrote
|
|
304
|
+
it — the same call ``report._match_rows`` makes about the order of a ``packs:`` list.
|
|
305
|
+
"""
|
|
306
|
+
return tuple(gap for document in documents for gap in _document_gaps(document))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _document_gaps(document: ContractDocument) -> list[Gap]:
|
|
310
|
+
"""One document's open-questions bullets, in the order the document writes them.
|
|
311
|
+
|
|
312
|
+
A section runs from its heading to the next heading at the same level or above, so a ``###``
|
|
313
|
+
subsection inside it is part of it. A bullet runs from its ``- `` to the next bullet, the next
|
|
314
|
+
heading or a blank line, with continuation lines stripped of their indentation and joined by
|
|
315
|
+
single spaces. An indented sub-bullet therefore folds into its parent's text rather than
|
|
316
|
+
becoming a gap of its own; no document does that today, and folding is the harmless reading.
|
|
317
|
+
"""
|
|
318
|
+
gaps: list[Gap] = []
|
|
319
|
+
bullet: list[str] = []
|
|
320
|
+
inside = False
|
|
321
|
+
|
|
322
|
+
def flush() -> None:
|
|
323
|
+
if bullet:
|
|
324
|
+
gaps.append(_gap(document.path, " ".join(bullet)))
|
|
325
|
+
bullet.clear()
|
|
326
|
+
|
|
327
|
+
for line in document.text.splitlines():
|
|
328
|
+
if _SECTION_HEADING.match(line):
|
|
329
|
+
flush()
|
|
330
|
+
inside = bool(_GAP_HEADING.match(line))
|
|
331
|
+
continue
|
|
332
|
+
if not inside:
|
|
333
|
+
continue
|
|
334
|
+
if line.startswith("- "):
|
|
335
|
+
flush()
|
|
336
|
+
bullet.append(line[2:].strip())
|
|
337
|
+
elif not line.strip():
|
|
338
|
+
flush()
|
|
339
|
+
elif bullet:
|
|
340
|
+
bullet.append(line.strip())
|
|
341
|
+
flush()
|
|
342
|
+
return gaps
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _gap(document: str, text: str) -> Gap:
|
|
346
|
+
"""One bullet split into its bolded lead and the rest of what it says."""
|
|
347
|
+
lead = _GAP_TITLE.match(text)
|
|
348
|
+
if lead is None:
|
|
349
|
+
return Gap(document=document, title="", text=text)
|
|
350
|
+
return Gap(document=document, title=lead.group("title"), text=text[lead.end() :])
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def describe_contract(
|
|
354
|
+
contract: Traversable,
|
|
355
|
+
tool_version: str,
|
|
356
|
+
requested: str | None = None,
|
|
357
|
+
candidates: Sequence[PackCandidate] = (),
|
|
358
|
+
problems: Sequence[Problem] = (),
|
|
359
|
+
) -> SpecReport:
|
|
360
|
+
"""Assemble one report from a contract directory and, optionally, a pack to describe.
|
|
361
|
+
|
|
362
|
+
``contract`` is a ``Traversable`` and not a path this function builds, for the same reason
|
|
363
|
+
``resolve()`` takes ``start``: a test drives it against a temporary tree with no monkeypatching
|
|
364
|
+
anywhere, which is what makes "add a fifth open question and it appears" a test that edits a
|
|
365
|
+
file rather than a claim in a docstring.
|
|
366
|
+
|
|
367
|
+
``candidates`` are ignored when no pack was named, and ``spec()`` does not gather them on that
|
|
368
|
+
path at all. See its docstring for why that is a property and not an optimisation.
|
|
369
|
+
|
|
370
|
+
``problems`` are the ones the caller already found — resolving the search path is the only
|
|
371
|
+
source today — and they are folded in and sorted with this function's own, so the record
|
|
372
|
+
carries every fault of the run and the status is the lowest code among all of them.
|
|
373
|
+
"""
|
|
374
|
+
documents = read_contract(contract)
|
|
375
|
+
gaps = read_gaps(documents)
|
|
376
|
+
found: list[Problem] = list(problems)
|
|
377
|
+
subject: PackSubject | None = None
|
|
378
|
+
inherited: InheritedVocabulary | None = InheritedVocabulary(node_types=(), edge_kinds=())
|
|
379
|
+
if requested is not None:
|
|
380
|
+
subject, inherited = _describe_pack(requested, candidates, found)
|
|
381
|
+
return SpecReport(
|
|
382
|
+
tool_version=tool_version,
|
|
383
|
+
documents=documents,
|
|
384
|
+
gaps=gaps,
|
|
385
|
+
pack=subject,
|
|
386
|
+
inherited=inherited,
|
|
387
|
+
# Sorted by code then message, once, at the end — the same key ``resolve()`` sorts its own
|
|
388
|
+
# problems by, so a problem reads the same wherever a person meets it.
|
|
389
|
+
problems=tuple(sorted(found, key=lambda problem: (int(problem.code), problem.message))),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _describe_pack(
|
|
394
|
+
requested: str,
|
|
395
|
+
candidates: Sequence[PackCandidate],
|
|
396
|
+
problems: list[Problem],
|
|
397
|
+
) -> tuple[PackSubject, InheritedVocabulary | None]:
|
|
398
|
+
"""One pack name resolved to a directory, its manifest, and what it inherits.
|
|
399
|
+
|
|
400
|
+
A name and not a ``<pack>@<version>`` pin: a person authoring a pack knows what their pack is
|
|
401
|
+
called and need not know which version of a dependency is installed. Every ``named`` candidate
|
|
402
|
+
whose pack half equals the name becomes a pin, and ``match_pins`` turns those into matches, so
|
|
403
|
+
the one rule about how a directory name is read lives in ``packs.py`` and is not restated here.
|
|
404
|
+
|
|
405
|
+
Two directories carrying the name are reported and neither is chosen, by
|
|
406
|
+
``resolve_pack_order``, which keys its graph by pack name and cannot hold one name as two
|
|
407
|
+
packs. A single directory found under two roots is reported twice, once by ``match_pins`` as a
|
|
408
|
+
duplicated pin and once by the walk as a duplicated name; they are two true statements about
|
|
409
|
+
one tree — the pin matched in two places, and the name resolves to two directories — and
|
|
410
|
+
suppressing either would need this module to decide which of a person's faults is the real one.
|
|
411
|
+
"""
|
|
412
|
+
entries = sorted(
|
|
413
|
+
{
|
|
414
|
+
candidate.entry
|
|
415
|
+
for candidate in candidates
|
|
416
|
+
if candidate.status == "named"
|
|
417
|
+
and candidate.name is not None
|
|
418
|
+
and candidate.name.pack == requested
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
absent = PackSubject(
|
|
422
|
+
requested=requested,
|
|
423
|
+
directory=None,
|
|
424
|
+
declares=None,
|
|
425
|
+
version=None,
|
|
426
|
+
depends=(),
|
|
427
|
+
inherits_from=(),
|
|
428
|
+
)
|
|
429
|
+
if not entries:
|
|
430
|
+
problems.append(_unknown_pack(requested, candidates))
|
|
431
|
+
return absent, None
|
|
432
|
+
|
|
433
|
+
matches, pin_problems = match_pins(entries, candidates)
|
|
434
|
+
order, walk_problems = resolve_pack_order(matches, candidates)
|
|
435
|
+
problems.extend(pin_problems)
|
|
436
|
+
problems.extend(walk_problems)
|
|
437
|
+
|
|
438
|
+
loaded = next((pack for pack in order if pack.name.pack == requested), None)
|
|
439
|
+
if loaded is None:
|
|
440
|
+
# Every reason the walk could not produce it — a duplicated name, an unreadable manifest —
|
|
441
|
+
# has already appended its own problem, so this branch adds none and would be lying if it
|
|
442
|
+
# invented one.
|
|
443
|
+
return absent, None
|
|
444
|
+
subject = PackSubject(
|
|
445
|
+
requested=requested,
|
|
446
|
+
directory=loaded.path,
|
|
447
|
+
declares=loaded.manifest.pack,
|
|
448
|
+
version=loaded.manifest.version,
|
|
449
|
+
depends=tuple(sorted(set(loaded.manifest.depends))),
|
|
450
|
+
inherits_from=tuple(
|
|
451
|
+
sorted({pack.name.pack for pack in order if pack.name.pack != requested})
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
return subject, _inherited(loaded)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _inherited(loaded: LoadedPack) -> InheritedVocabulary | None:
|
|
458
|
+
"""The empty vocabulary for a pack that depends on nothing, and ``None`` for every other pack.
|
|
459
|
+
|
|
460
|
+
A pack whose manifest declares no ``depends`` has an empty dependency closure, so the union of
|
|
461
|
+
the node types and edge kinds it inherits is empty. That is arithmetic over an empty set and
|
|
462
|
+
needs no reader, which is why this one answer can be given today and the other cannot.
|
|
463
|
+
|
|
464
|
+
The question is asked of the **raw** ``depends`` value and not of ``PackManifest.depends``,
|
|
465
|
+
and the difference is the difference between a true and a false statement.
|
|
466
|
+
``manifest.py`` deliberately parses ``depends: "php"`` to ``()`` — dropping a mistyped value
|
|
467
|
+
rather than reading it as three dependencies named ``p``, ``h`` and ``p`` — so its empty tuple
|
|
468
|
+
covers two files: one that declares no dependencies, and one whose declaration could not be
|
|
469
|
+
read. Only the first inherits nothing. The second is unknown, and unknown is ``None``.
|
|
470
|
+
"""
|
|
471
|
+
declared = loaded.manifest.document.get("depends")
|
|
472
|
+
if declared is None or (isinstance(declared, list | tuple) and not declared):
|
|
473
|
+
return InheritedVocabulary(node_types=(), edge_kinds=())
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _unknown_pack(requested: str, candidates: Sequence[PackCandidate]) -> Problem:
|
|
478
|
+
"""A name no directory on the pack search path carries.
|
|
479
|
+
|
|
480
|
+
``available`` is every pack name discovery found, sorted, rather than a guess at which was
|
|
481
|
+
meant — the same call ``manifest._unmatched`` makes, and for the same reason: a rule that
|
|
482
|
+
decides ``twigg`` meant ``twig`` will one day decide it meant ``php``.
|
|
483
|
+
"""
|
|
484
|
+
available = sorted(
|
|
485
|
+
{
|
|
486
|
+
candidate.name.pack
|
|
487
|
+
for candidate in candidates
|
|
488
|
+
if candidate.status == "named" and candidate.name is not None
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
return Problem(
|
|
492
|
+
code=ExitCode.PACK_UNKNOWN,
|
|
493
|
+
message=f"no pack on the pack search path is named {requested!r}",
|
|
494
|
+
detail={"pack": requested, "available": ", ".join(available)},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def spec(
|
|
499
|
+
start: Path,
|
|
500
|
+
env: Mapping[str, str],
|
|
501
|
+
dependencies: tuple[DependencyStatus, ...],
|
|
502
|
+
requested: str | None = None,
|
|
503
|
+
) -> SpecReport:
|
|
504
|
+
"""The command's entry point: the contract, and a pack only when one was named.
|
|
505
|
+
|
|
506
|
+
**With no pack named this reads nothing outside the installed package.** No settings file is
|
|
507
|
+
located, no project root is walked to, no pack directory is listed — because there is no
|
|
508
|
+
question that needs them. That is a stated property rather than a saving: the contract is
|
|
509
|
+
package data, and a command that publishes it must work on a machine with no project, no
|
|
510
|
+
configuration and no packs installed, which is the machine a model authoring its first pack is
|
|
511
|
+
sitting at. A test plants an unparseable settings file above the working directory and asserts
|
|
512
|
+
this run still exits 0, which is what makes the property observable rather than argued.
|
|
513
|
+
|
|
514
|
+
With a pack named, the search path is resolved **exactly as ``status`` resolves it**, by
|
|
515
|
+
calling ``resolve()`` — one definition of where packs live, rather than a second one here that
|
|
516
|
+
would drift and would miss a ``pack_paths`` root a settings file added.
|
|
517
|
+
|
|
518
|
+
That means this run inherits every problem resolution found, including ones about pins it did
|
|
519
|
+
not need, and the status is the lowest code among all of them. The alternative is a filter
|
|
520
|
+
deciding which of a project's faults are allowed to matter, and there is no honest rule for
|
|
521
|
+
writing one: a settings file that could not be read is a pack search path the tool cannot vouch
|
|
522
|
+
for, and a report that described a pack found on a path it could not compute would be a report
|
|
523
|
+
that lies about where it looked.
|
|
524
|
+
"""
|
|
525
|
+
if requested is None:
|
|
526
|
+
return describe_contract(contract_root(), periplus.__version__)
|
|
527
|
+
resolution = resolve(start=start, env=env, dependencies=dependencies)
|
|
528
|
+
return describe_contract(
|
|
529
|
+
contract_root(),
|
|
530
|
+
periplus.__version__,
|
|
531
|
+
requested,
|
|
532
|
+
resolution.candidates,
|
|
533
|
+
resolution.problems,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ---------------------------------------------------------------------------------------------
|
|
538
|
+
# The text form
|
|
539
|
+
# ---------------------------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def render_text(report: SpecReport) -> str:
|
|
543
|
+
"""The contract as the bytes a model reads: the summary, then every document in full.
|
|
544
|
+
|
|
545
|
+
**This is the default form and stays the default form.** The consumer is a model authoring a
|
|
546
|
+
pack, and it needs the schemas themselves; a form that named the documents and stopped would
|
|
547
|
+
cost that consumer a second command and a second round trip to reach what it came for, which
|
|
548
|
+
is a real price paid on every run to save bytes nobody was short of.
|
|
549
|
+
|
|
550
|
+
The summary comes first and the documents last, because the summary is what a person scanning
|
|
551
|
+
the output needs and the documents are what a model reading all of it needs. Neither is
|
|
552
|
+
truncated: a contract published in part is a contract an author writes against in part.
|
|
553
|
+
|
|
554
|
+
Each document is introduced by a ruled line naming its path and is then emitted **byte for
|
|
555
|
+
byte**. It is the artifact, and a transformed artifact is not the artifact — a Markdown fence
|
|
556
|
+
re-indented or a schema re-serialised is a document the author of a pack would then be building
|
|
557
|
+
against by inference.
|
|
558
|
+
"""
|
|
559
|
+
text = render_text_summary(report)
|
|
560
|
+
for document in report.documents:
|
|
561
|
+
text += f"\n{'-' * 8} {document.path} {'-' * 8}\n\n{document.text}"
|
|
562
|
+
return text
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def render_text_summary(report: SpecReport) -> str:
|
|
566
|
+
"""The summary block alone: what was published, the pack, the gaps, the problems.
|
|
567
|
+
|
|
568
|
+
``--summary``. It is the same bytes ``render_text`` opens with, and that is the whole
|
|
569
|
+
definition — the two forms cannot drift, because there is one function and the whole form is
|
|
570
|
+
this one plus the documents appended to it.
|
|
571
|
+
|
|
572
|
+
Nothing here is a digest of a document. Every path is named in full, so a reader who wants one
|
|
573
|
+
of them knows exactly what to ask for, and no line of this block stands in for prose it
|
|
574
|
+
summarises. What is dropped is the documents' bytes and nothing else.
|
|
575
|
+
"""
|
|
576
|
+
lines: list[str] = [f"periplus {_escape(report.tool_version)} pack contract"]
|
|
577
|
+
lines += _section("Contract documents", [document.path for document in report.documents])
|
|
578
|
+
lines += _section("Pack", _pack_lines(report.pack))
|
|
579
|
+
lines += _section("Inherited vocabulary", _vocabulary_lines(report.inherited))
|
|
580
|
+
lines += _section("Not yet expressible", _gap_lines(report.gaps))
|
|
581
|
+
lines += _section("Problems", _problem_lines(report.problems))
|
|
582
|
+
return "\n".join(lines) + "\n"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _section(heading: str, rows: Sequence[str]) -> list[str]:
|
|
586
|
+
"""One block: a blank line, its heading, and its rows indented under it.
|
|
587
|
+
|
|
588
|
+
No column alignment and no width computed from anything. ``report._block`` aligns because a
|
|
589
|
+
resolution report is a table; this output is a list of labelled facts followed by whole
|
|
590
|
+
documents, so borrowing that machinery would be borrowing a decision this form does not make.
|
|
591
|
+
An empty block prints ``none`` rather than nothing, because a section a reader cannot find is
|
|
592
|
+
indistinguishable from a section that does not exist.
|
|
593
|
+
"""
|
|
594
|
+
return ["", f"{heading}:", *[f" {row}".rstrip() for row in rows or ["none"]]]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _labelled(label: str, value: str) -> str:
|
|
598
|
+
"""One fact under a heading, its label padded to a fixed width written here and nowhere else."""
|
|
599
|
+
return f"{label:<12}{value}"
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _pack_lines(pack: PackSubject | None) -> list[str]:
|
|
603
|
+
"""The pack that was asked about, or that none was.
|
|
604
|
+
|
|
605
|
+
Every value comes off a command line, a filesystem or a manifest, so every value is escaped.
|
|
606
|
+
"""
|
|
607
|
+
if pack is None:
|
|
608
|
+
return ["no pack was named"]
|
|
609
|
+
declared = (
|
|
610
|
+
"not declared"
|
|
611
|
+
if pack.declares is None
|
|
612
|
+
else f"{_escape(pack.declares)} {_escape(pack.version or 'no version')}"
|
|
613
|
+
)
|
|
614
|
+
return [
|
|
615
|
+
_labelled("requested", _escape(pack.requested)),
|
|
616
|
+
_labelled("directory", _escape(pack.directory or "not found on the pack search path")),
|
|
617
|
+
_labelled("declares", declared),
|
|
618
|
+
_labelled("depends", _joined(pack.depends) or "none"),
|
|
619
|
+
_labelled("inherits", _joined(pack.inherits_from) or "none"),
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _vocabulary_lines(inherited: InheritedVocabulary | None) -> list[str]:
|
|
624
|
+
"""What the pack may name because a dependency declares it, or that nobody has read it.
|
|
625
|
+
|
|
626
|
+
The ``None`` case says which reader is missing rather than printing an empty list. An empty
|
|
627
|
+
list would be a claim, and the claim would be false.
|
|
628
|
+
"""
|
|
629
|
+
if inherited is None:
|
|
630
|
+
return ["not read - no reader opens a pack's rule files for its node types and edge kinds"]
|
|
631
|
+
return [
|
|
632
|
+
_labelled("node types", _joined(inherited.node_types) or "none"),
|
|
633
|
+
_labelled("edge kinds", _joined(inherited.edge_kinds) or "none"),
|
|
634
|
+
]
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _gap_lines(gaps: Sequence[Gap]) -> list[str]:
|
|
638
|
+
"""One line per open question, naming the document that states it.
|
|
639
|
+
|
|
640
|
+
Package data, emitted verbatim. The join in ``_document_gaps`` has already made each one line,
|
|
641
|
+
so there is no line here for a document to forge — and the documents ship inside the wheel,
|
|
642
|
+
which is the reason nothing here is escaped and the pack lines above all are.
|
|
643
|
+
"""
|
|
644
|
+
return [
|
|
645
|
+
f"{gap.document} {gap.title}{': ' if gap.title else ''}{gap.text}".rstrip() for gap in gaps
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _problem_lines(problems: Sequence[Problem]) -> list[str]:
|
|
650
|
+
"""One line per problem, its code first, with its named facts under it."""
|
|
651
|
+
lines: list[str] = []
|
|
652
|
+
for problem in problems:
|
|
653
|
+
lines.append(f"{int(problem.code)} {_escape(problem.message)}")
|
|
654
|
+
lines += [
|
|
655
|
+
f" {_escape(key)}: {_escape(problem.detail[key])}" for key in sorted(problem.detail)
|
|
656
|
+
]
|
|
657
|
+
return lines
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _joined(values: Sequence[str]) -> str:
|
|
661
|
+
"""A list of names as one line, each escaped, because each came from outside the package."""
|
|
662
|
+
return ", ".join(_escape(value) for value in values)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ---------------------------------------------------------------------------------------------
|
|
666
|
+
# The JSON form
|
|
667
|
+
# ---------------------------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def render_json(report: SpecReport) -> str:
|
|
671
|
+
"""The report as one JSON document, keys sorted, every contract file carried in full.
|
|
672
|
+
|
|
673
|
+
An explicit projection rather than a walk over the dataclasses, for the reason
|
|
674
|
+
``report.render_json`` gives: adding a field to a record must not silently add a key whose type
|
|
675
|
+
nobody chose. The whole record is JSON primitives already, so nothing here converts a value —
|
|
676
|
+
the projection is here to name the keys, not to rescue them.
|
|
677
|
+
|
|
678
|
+
``ensure_ascii=True``, matching the other form of every report this tool prints. A pack name
|
|
679
|
+
and a directory name come off a filesystem where a name need not be valid UTF-8, and under
|
|
680
|
+
``ensure_ascii`` a surrogate escape encodes cleanly instead of raising out of the encoder.
|
|
681
|
+
|
|
682
|
+
Nothing is passed through ``_escape`` here, exactly as in ``report.render_json``: that helper
|
|
683
|
+
stops a name forging a *line*, and this document has no lines to forge. The encoder escapes a
|
|
684
|
+
newline itself, losslessly and reversibly, which is what a consumer of this form needs.
|
|
685
|
+
"""
|
|
686
|
+
return _json(
|
|
687
|
+
report, [{"path": document.path, "text": document.text} for document in report.documents]
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def render_json_summary(report: SpecReport) -> str:
|
|
692
|
+
"""The same document with every ``text`` dropped, and nothing else changed.
|
|
693
|
+
|
|
694
|
+
``--summary --format json``, and this is the form the flag exists for. A consumer that wants
|
|
695
|
+
the ``gaps`` array today parses some seventy kilobytes of escaped document text to reach it,
|
|
696
|
+
all of which it then discards; here the whole document is the part it wanted.
|
|
697
|
+
|
|
698
|
+
``documents`` stays a **list of objects**, each carrying its ``path``, rather than collapsing
|
|
699
|
+
to a list of strings. One expression — ``[d["path"] for d in payload["documents"]]`` — then
|
|
700
|
+
reads the paths out of either form, where a type that changed with a flag would make a
|
|
701
|
+
consumer branch on which flag it passed. A consumer that reaches for ``text`` gets a
|
|
702
|
+
``KeyError``, which is the loud failure and the right one: the bytes are genuinely not here,
|
|
703
|
+
and a key holding an empty string or a null would say they were and were empty.
|
|
704
|
+
"""
|
|
705
|
+
return _json(report, [{"path": document.path} for document in report.documents])
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _json(report: SpecReport, documents: list[dict[str, str]]) -> str:
|
|
709
|
+
"""One JSON document over a report and whichever projection of its contract files."""
|
|
710
|
+
payload = {
|
|
711
|
+
"tool_version": report.tool_version,
|
|
712
|
+
"documents": documents,
|
|
713
|
+
"gaps": [
|
|
714
|
+
{"document": gap.document, "title": gap.title, "text": gap.text} for gap in report.gaps
|
|
715
|
+
],
|
|
716
|
+
"pack": _json_pack(report.pack),
|
|
717
|
+
"inherited": _json_vocabulary(report.inherited),
|
|
718
|
+
"problems": [
|
|
719
|
+
{
|
|
720
|
+
"code": int(problem.code),
|
|
721
|
+
"message": problem.message,
|
|
722
|
+
"detail": dict(problem.detail),
|
|
723
|
+
}
|
|
724
|
+
for problem in report.problems
|
|
725
|
+
],
|
|
726
|
+
}
|
|
727
|
+
return json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=True) + "\n"
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _json_pack(pack: PackSubject | None) -> dict[str, object] | None:
|
|
731
|
+
"""The pack that was asked about, or ``null`` when none was named."""
|
|
732
|
+
if pack is None:
|
|
733
|
+
return None
|
|
734
|
+
return {
|
|
735
|
+
"requested": pack.requested,
|
|
736
|
+
"directory": pack.directory,
|
|
737
|
+
"declares": pack.declares,
|
|
738
|
+
"version": pack.version,
|
|
739
|
+
"depends": list(pack.depends),
|
|
740
|
+
"inherits_from": list(pack.inherits_from),
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _json_vocabulary(inherited: InheritedVocabulary | None) -> dict[str, object] | None:
|
|
745
|
+
"""The inherited vocabulary, or ``null`` when no reader has worked it out.
|
|
746
|
+
|
|
747
|
+
``null`` and an object of empty arrays are different facts and both occur: the first means
|
|
748
|
+
nothing read it, the second means it was read and is empty.
|
|
749
|
+
"""
|
|
750
|
+
if inherited is None:
|
|
751
|
+
return None
|
|
752
|
+
return {
|
|
753
|
+
"node_types": list(inherited.node_types),
|
|
754
|
+
"edge_kinds": list(inherited.edge_kinds),
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
#: The formats ``spec --format`` offers, in the order they are offered. ``cli.build_parser`` writes
|
|
759
|
+
#: the same two names as a literal for the reason ``report.RENDERERS`` records — ``cli`` may not
|
|
760
|
+
#: import this module, because it reaches ``ruamel.yaml`` — and a test asserts the two agree.
|
|
761
|
+
RENDERERS: Mapping[str, Callable[[SpecReport], str]] = {
|
|
762
|
+
"text": render_text,
|
|
763
|
+
"json": render_json,
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#: The same two formats under ``--summary``. A second table keyed by the same names, rather than a
|
|
767
|
+
#: ``summary`` argument threaded through both renderers: ``cli`` picks a table and then looks a
|
|
768
|
+
#: format up in it, so the flag is decided once at the call site and neither renderer carries a
|
|
769
|
+
#: branch. Both names are driven through ``--summary`` by a test, so a format present in one table
|
|
770
|
+
#: and not the other is a ``KeyError`` in the suite rather than in somebody's terminal.
|
|
771
|
+
SUMMARY_RENDERERS: Mapping[str, Callable[[SpecReport], str]] = {
|
|
772
|
+
"text": render_text_summary,
|
|
773
|
+
"json": render_json_summary,
|
|
774
|
+
}
|