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