├── MANIFEST.in ├── tests ├── __init__.py ├── test_exceptions_yamlpath.py ├── test_path_collectorterms.py ├── test_merger_exceptions_mergeexception.py ├── test_differ_enums_arraydiffopts.py ├── test_merger_enums_setmergeopts.py ├── test_merger_enums_hashmergeopts.py ├── test_merger_enums_outputdoctypes.py ├── test_merger_enums_multidocmodes.py ├── test_merger_enums_arraymergeopts.py ├── test_differ_enums_aohdiffopts.py ├── test_path_searchterms.py ├── test_merger_enums_aohmergeopts.py ├── test_enums_pathsearchmethods.py ├── test_eyaml_enums_eyamloutputformats.py ├── test_merger_enums_anchorconflictresolutions.py ├── test_wrappers_nodecoords.py ├── test_enums_collectoroperators.py ├── test_enums_pathseparators.py ├── test_path_searchkeywordterms.py ├── test_enums_yamlvalueformats.py ├── test_commands_yaml_validate.py ├── test_common_nodes.py ├── test_common_parsers.py ├── test_common_searches.py └── conftest.py ├── setup.cfg ├── yamlpath ├── patches │ ├── __init__.py │ └── timestamp.py ├── merger │ ├── exceptions │ │ ├── __init__.py │ │ └── mergeexception.py │ ├── __init__.py │ └── enums │ │ ├── __init__.py │ │ ├── outputdoctypes.py │ │ ├── hashmergeopts.py │ │ ├── setmergeopts.py │ │ ├── arraymergeopts.py │ │ ├── multidocmodes.py │ │ ├── anchorconflictresolutions.py │ │ └── aohmergeopts.py ├── eyaml │ ├── exceptions │ │ ├── __init__.py │ │ └── eyamlcommand.py │ ├── __init__.py │ └── enums │ │ ├── __init__.py │ │ └── eyamloutputformats.py ├── wrappers │ ├── __init__.py │ └── nodecoords.py ├── differ │ ├── __init__.py │ ├── enums │ │ ├── __init__.py │ │ ├── diffactions.py │ │ ├── arraydiffopts.py │ │ └── aohdiffopts.py │ └── diffentry.py ├── types │ ├── __init__.py │ ├── ancestryentry.py │ ├── pathsegment.py │ └── pathattributes.py ├── commands │ ├── __init__.py │ ├── yaml_validate.py │ ├── yaml_get.py │ └── eyaml_rotate_keys.py ├── path │ ├── __init__.py │ ├── collectorterms.py │ ├── searchterms.py │ └── searchkeywordterms.py ├── __init__.py ├── common │ ├── __init__.py │ ├── anchors.py │ └── searches.py ├── enums │ ├── pathseperators.py │ ├── __init__.py │ ├── includealiases.py │ ├── anchormatches.py │ ├── pathsegmenttypes.py │ ├── pathsearchmethods.py │ ├── pathsearchkeywords.py │ ├── collectoroperators.py │ ├── pathseparators.py │ └── yamlvalueformats.py ├── exceptions │ ├── __init__.py │ ├── unmatchedyamlpathexception.py │ ├── recursionyamlpathexception.py │ ├── badaliasyamlpathexception.py │ ├── nodocumentyamlpathexception.py │ ├── typemismatchyamlpathexception.py │ ├── duplicatekeyyamlpathexception.py │ └── yamlpathexception.py └── func.py ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug-report.md └── workflows │ ├── python-publish-to-prod-pypi.yml │ ├── python-publish-to-coveralls.yml │ ├── build.yml │ └── python-publish-to-test-pypi.yml ├── mypy.ini ├── .coveragerc ├── .codacy.yaml ├── LICENSE ├── .gitignore ├── setup.py ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── run-tests.sh └── run-tests.ps1 /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for this project.""" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: yamlpath.__version__ 3 | -------------------------------------------------------------------------------- /yamlpath/patches/__init__.py: -------------------------------------------------------------------------------- 1 | """Include ruamel.yaml patches.""" 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [wwkimball] 4 | -------------------------------------------------------------------------------- /yamlpath/merger/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """Merger exceptions.""" 2 | from .mergeexception import MergeException 3 | -------------------------------------------------------------------------------- /yamlpath/eyaml/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all EYAML Exceptions available.""" 2 | from .eyamlcommand import EYAMLCommandException 3 | -------------------------------------------------------------------------------- /yamlpath/merger/__init__.py: -------------------------------------------------------------------------------- 1 | """Core YAML Path Merger classes.""" 2 | from .mergerconfig import MergerConfig 3 | from .merger import Merger 4 | -------------------------------------------------------------------------------- /yamlpath/eyaml/__init__.py: -------------------------------------------------------------------------------- 1 | """EYAML specializations of the core YAML Path processing classes.""" 2 | from .eyamlprocessor import EYAMLProcessor 3 | -------------------------------------------------------------------------------- /yamlpath/eyaml/enums/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all EYAML-specialty enumerations available.""" 2 | from .eyamloutputformats import EYAMLOutputFormats 3 | -------------------------------------------------------------------------------- /yamlpath/wrappers/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all generic wrappers available.""" 2 | from .consoleprinter import ConsolePrinter 3 | from .nodecoords import NodeCoords 4 | -------------------------------------------------------------------------------- /yamlpath/differ/__init__.py: -------------------------------------------------------------------------------- 1 | """YAML Path diff calculation classes.""" 2 | from .diffentry import DiffEntry 3 | from .differconfig import DifferConfig 4 | from .differ import Differ 5 | -------------------------------------------------------------------------------- /yamlpath/differ/enums/__init__.py: -------------------------------------------------------------------------------- 1 | """Differ enumerations.""" 2 | from .aohdiffopts import AoHDiffOpts 3 | from .arraydiffopts import ArrayDiffOpts 4 | from .diffactions import DiffActions 5 | -------------------------------------------------------------------------------- /yamlpath/types/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all custom types available.""" 2 | from .ancestryentry import AncestryEntry 3 | from .pathattributes import PathAttributes 4 | from .pathsegment import PathSegment 5 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_return_any = True 3 | warn_unused_configs = True 4 | 5 | [mypy-ruamel.*] 6 | ignore_missing_imports = True 7 | 8 | [mypy-dateutil.*] 9 | ignore_missing_imports = True 10 | -------------------------------------------------------------------------------- /yamlpath/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all of the command APIs available.""" 2 | from yamlpath.commands import eyaml_rotate_keys 3 | from yamlpath.commands import yaml_get 4 | from yamlpath.commands import yaml_set 5 | -------------------------------------------------------------------------------- /yamlpath/path/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all of the YAML Path components available.""" 2 | from .collectorterms import CollectorTerms 3 | from .searchkeywordterms import SearchKeywordTerms 4 | from .searchterms import SearchTerms 5 | -------------------------------------------------------------------------------- /yamlpath/__init__.py: -------------------------------------------------------------------------------- 1 | """Core YAML Path classes.""" 2 | # Establish the version number common to all components 3 | __version__ = "3.8.2" 4 | 5 | from yamlpath.yamlpath import YAMLPath 6 | from yamlpath.processor import Processor 7 | -------------------------------------------------------------------------------- /yamlpath/common/__init__.py: -------------------------------------------------------------------------------- 1 | """Common library methods.""" 2 | from .anchors import Anchors 3 | from .nodes import Nodes 4 | from .parsers import Parsers 5 | from .searches import Searches 6 | from .keywordsearches import KeywordSearches 7 | -------------------------------------------------------------------------------- /yamlpath/types/ancestryentry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a custom type for data ancestry (parent, parentref). 3 | 4 | Copyright 2021 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Any, Tuple 7 | 8 | AncestryEntry = Tuple[Any, Any] 9 | -------------------------------------------------------------------------------- /yamlpath/eyaml/exceptions/eyamlcommand.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents an exception that occurs during an EYAML command execution. 3 | 4 | Copyright 2019 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | 7 | 8 | class EYAMLCommandException(Exception): 9 | """Exception to raise when an EYAML command executation fails.""" 10 | -------------------------------------------------------------------------------- /yamlpath/types/pathsegment.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a custom type for YAML Path segments. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Tuple 7 | 8 | from yamlpath.enums import PathSegmentTypes 9 | from yamlpath.types import PathAttributes 10 | 11 | PathSegment = Tuple[PathSegmentTypes, PathAttributes] 12 | -------------------------------------------------------------------------------- /yamlpath/enums/pathseperators.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=unused-import 2 | """ 3 | Implements the PathSeparators enumeration. 4 | 5 | This is provided for compatibility with older versions, 6 | before the spelling was updated to "separator." 7 | 8 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 9 | """ 10 | from .pathseparators import PathSeparators as PathSeperators 11 | -------------------------------------------------------------------------------- /yamlpath/types/pathattributes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines a custom type for YAML Path segment attributes. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Union 7 | 8 | from yamlpath.path import CollectorTerms 9 | from yamlpath.path import searchterms 10 | 11 | 12 | PathAttributes = Union[str, int, CollectorTerms, searchterms.SearchTerms, None] 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # Enable multi-threaded processing of tests and coverage scanning 3 | parallel = true 4 | 5 | # Move .coverage and all temporary .coverage* files to a temporary directory 6 | data_file = /tmp/yamlpath-python-coverage-data 7 | 8 | # Ignore these files from coverage analysis 9 | omit = 10 | # Don't bother with ruamel.yaml patches (not my code) 11 | yamlpath/patches/* 12 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all the YAML Path Merger enumerations available.""" 2 | from .anchorconflictresolutions import AnchorConflictResolutions 3 | from .aohmergeopts import AoHMergeOpts 4 | from .arraymergeopts import ArrayMergeOpts 5 | from .hashmergeopts import HashMergeOpts 6 | from .multidocmodes import MultiDocModes 7 | from .outputdoctypes import OutputDocTypes 8 | from .setmergeopts import SetMergeOpts 9 | -------------------------------------------------------------------------------- /.codacy.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | pylint: 4 | enabled: false 5 | pylintpython3: 6 | enabled: false 7 | 8 | exclude_paths: 9 | - 'tests/**' 10 | - '**/*.md' 11 | - '**/__init__.py' 12 | - 'README.md' 13 | - 'yamlpath/func.py' # Deprecated; the entire file is just relays 14 | - 'yamlpath/patches/**' # 3rd Party contributions 15 | - 'yamlpath/enums/pathseperators.py' # Legacy spelling compatibility 16 | -------------------------------------------------------------------------------- /yamlpath/enums/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all the YAML Path enumerations available.""" 2 | from .anchormatches import AnchorMatches 3 | from .collectoroperators import CollectorOperators 4 | from .includealiases import IncludeAliases 5 | from .pathsearchkeywords import PathSearchKeywords 6 | from .pathsearchmethods import PathSearchMethods 7 | from .pathsegmenttypes import PathSegmentTypes 8 | from .pathseparators import PathSeparators 9 | from .yamlvalueformats import YAMLValueFormats 10 | 11 | # Legacy spelling compatibility: 12 | from .pathseperators import PathSeperators 13 | -------------------------------------------------------------------------------- /tests/test_exceptions_yamlpath.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.exceptions import YAMLPathException 4 | 5 | 6 | class Test_exceptions_YAMLPathException(): 7 | def test_str_segmentless(self): 8 | with pytest.raises(YAMLPathException) as ex: 9 | raise YAMLPathException("Test message", "test") 10 | assert str(ex.value) == "Test message, 'test'." 11 | 12 | def test_str_segmentfull(self): 13 | with pytest.raises(YAMLPathException) as ex: 14 | raise YAMLPathException("Test message", "test", "st") 15 | assert str(ex.value) == "Test message at 'st' in 'test'." 16 | -------------------------------------------------------------------------------- /tests/test_path_collectorterms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.enums import CollectorOperators 4 | from yamlpath.path import CollectorTerms 5 | 6 | class Test_path_CollectorTerms(): 7 | """Tests for the CollectorTerms class.""" 8 | 9 | @pytest.mark.parametrize("path,operator,output", [ 10 | ("abc", CollectorOperators.NONE, "(abc)"), 11 | ("abc", CollectorOperators.ADDITION, "+(abc)"), 12 | ("abc", CollectorOperators.SUBTRACTION, "-(abc)"), 13 | ]) 14 | def test_str(self, path, operator, output): 15 | assert output == str(CollectorTerms(path, operator)) 16 | -------------------------------------------------------------------------------- /tests/test_merger_exceptions_mergeexception.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.exceptions import MergeException 4 | 5 | 6 | class Test_exceptions_mergeexception(): 7 | def test_str_pathless(self): 8 | message = "Test message" 9 | with pytest.raises(MergeException) as ex: 10 | raise MergeException(message) 11 | assert str(ex.value) == message 12 | 13 | def test_str_pathfull(self): 14 | message = "Test message" 15 | path = "/test" 16 | with pytest.raises(MergeException) as ex: 17 | raise MergeException(message, path) 18 | assert (str(ex.value) == "{} This issue occurred at YAML Path: {}" 19 | .format(message, path)) 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /yamlpath/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """Make all of the custom YAML Path exceptions available.""" 2 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 3 | from yamlpath.exceptions.badaliasyamlpathexception import ( 4 | BadAliasYAMLPathException) 5 | from yamlpath.exceptions.duplicatekeyyamlpathexception import ( 6 | DuplicateKeyYAMLPathException) 7 | from yamlpath.exceptions.nodocumentyamlpathexception import ( 8 | NoDocumentYAMLPathException) 9 | from yamlpath.exceptions.recursionyamlpathexception import ( 10 | RecursionYAMLPathException) 11 | from yamlpath.exceptions.typemismatchyamlpathexception import ( 12 | TypeMismatchYAMLPathException) 13 | from yamlpath.exceptions.unmatchedyamlpathexception import ( 14 | UnmatchedYAMLPathException) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2001-2020, William W. Kimball Jr. MBA MSIS 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 14 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /tests/test_differ_enums_arraydiffopts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.differ.enums.arraydiffopts import ArrayDiffOpts 4 | 5 | 6 | class Test_differ_enums_arraydiffopts(): 7 | """Tests for the ArrayDiffOpts enumeration.""" 8 | 9 | def test_get_names(self): 10 | assert ArrayDiffOpts.get_names() == [ 11 | "POSITION", 12 | "VALUE", 13 | ] 14 | 15 | def test_get_choices(self): 16 | assert ArrayDiffOpts.get_choices() == [ 17 | "position", 18 | "value", 19 | ] 20 | 21 | @pytest.mark.parametrize("input,output", [ 22 | ("POSITION", ArrayDiffOpts.POSITION), 23 | ("VALUE", ArrayDiffOpts.VALUE), 24 | ]) 25 | def test_from_str(self, input, output): 26 | assert output == ArrayDiffOpts.from_str(input) 27 | 28 | def test_from_str_nameerror(self): 29 | with pytest.raises(NameError): 30 | ArrayDiffOpts.from_str("NO SUCH NAME") 31 | -------------------------------------------------------------------------------- /yamlpath/enums/includealiases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the IncludeAliases enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | 8 | 9 | class IncludeAliases(Enum): 10 | """ 11 | When performing a search, YAML Anchors and Aliases can be evaluated. 12 | 13 | Whether either are is dictated by these options: 14 | 15 | `ANCHORS_ONLY` 16 | Only anchors are evaluated. 17 | 18 | `INCLUDE_KEY_ALIASES` 19 | Anchors and key aliases are evaluated. 20 | 21 | `INCLUDE_VALUE_ALIASES` 22 | Anchors and value aliases are evaluated. 23 | 24 | `INCLUDE_ALL_ALIASES` 25 | Anchors and all aliases are evaluated. 26 | """ 27 | 28 | ANCHORS_ONLY = auto() 29 | INCLUDE_KEY_ALIASES = auto() 30 | INCLUDE_VALUE_ALIASES = auto() 31 | INCLUDE_ALL_ALIASES = auto() 32 | -------------------------------------------------------------------------------- /tests/test_merger_enums_setmergeopts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.setmergeopts import SetMergeOpts 4 | 5 | 6 | class Test_merger_enums_setmergeopts(): 7 | """Tests for the SetMergeOpts enumeration.""" 8 | 9 | def test_get_names(self): 10 | assert SetMergeOpts.get_names() == [ 11 | "LEFT", 12 | "RIGHT", 13 | "UNIQUE", 14 | ] 15 | 16 | def test_get_choices(self): 17 | assert SetMergeOpts.get_choices() == [ 18 | "left", 19 | "right", 20 | "unique", 21 | ] 22 | 23 | @pytest.mark.parametrize("input,output", [ 24 | ("LEFT", SetMergeOpts.LEFT), 25 | ("RIGHT", SetMergeOpts.RIGHT), 26 | ("UNIQUE", SetMergeOpts.UNIQUE), 27 | ]) 28 | def test_from_str(self, input, output): 29 | assert output == SetMergeOpts.from_str(input) 30 | 31 | def test_from_str_nameerror(self): 32 | with pytest.raises(NameError): 33 | SetMergeOpts.from_str("NO SUCH NAME") 34 | -------------------------------------------------------------------------------- /tests/test_merger_enums_hashmergeopts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.hashmergeopts import ( 4 | HashMergeOpts) 5 | 6 | 7 | class Test_merger_enums_hashmergeopts(): 8 | """Tests for the HashMergeOpts enumeration.""" 9 | 10 | def test_get_names(self): 11 | assert HashMergeOpts.get_names() == [ 12 | "DEEP", 13 | "LEFT", 14 | "RIGHT", 15 | ] 16 | 17 | def test_get_choices(self): 18 | assert HashMergeOpts.get_choices() == [ 19 | "deep", 20 | "left", 21 | "right", 22 | ] 23 | 24 | @pytest.mark.parametrize("input,output", [ 25 | ("DEEP", HashMergeOpts.DEEP), 26 | ("LEFT", HashMergeOpts.LEFT), 27 | ("RIGHT", HashMergeOpts.RIGHT), 28 | ]) 29 | def test_from_str(self, input, output): 30 | assert output == HashMergeOpts.from_str(input) 31 | 32 | def test_from_str_nameerror(self): 33 | with pytest.raises(NameError): 34 | HashMergeOpts.from_str("NO SUCH NAME") 35 | -------------------------------------------------------------------------------- /tests/test_merger_enums_outputdoctypes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.outputdoctypes import ( 4 | OutputDocTypes) 5 | 6 | 7 | class Test_merger_enums_outputdoctypes(): 8 | """Tests for the OutputDocTypes enumeration.""" 9 | 10 | def test_get_names(self): 11 | assert OutputDocTypes.get_names() == [ 12 | "AUTO", 13 | "JSON", 14 | "YAML", 15 | ] 16 | 17 | def test_get_choices(self): 18 | assert OutputDocTypes.get_choices() == [ 19 | "auto", 20 | "json", 21 | "yaml", 22 | ] 23 | 24 | @pytest.mark.parametrize("input,output", [ 25 | ("AUTO", OutputDocTypes.AUTO), 26 | ("JSON", OutputDocTypes.JSON), 27 | ("YAML", OutputDocTypes.YAML), 28 | ]) 29 | def test_from_str(self, input, output): 30 | assert output == OutputDocTypes.from_str(input) 31 | 32 | def test_from_str_nameerror(self): 33 | with pytest.raises(NameError): 34 | OutputDocTypes.from_str("NO SUCH NAME") 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a defect 4 | title: '' 5 | labels: '' 6 | assignees: wwkimball 7 | 8 | --- 9 | 10 | ## Operating System 11 | 12 | 1. Name/Distribution: 13 | 2. Version: 14 | 15 | ## Version of Python and packages in use at the time of the issue. 16 | 17 | 1. [Distribution](https://wiki.python.org/moin/PythonDistributions): 18 | 2. Python Version: 19 | 3. Version of yamlpath installed: 20 | 4. Version of ruamel.yaml installed: 21 | 22 | ## Minimum sample of YAML (or compatible) data necessary to trigger the issue 23 | 24 | ## Complete steps to reproduce the issue when triggered via: 25 | 26 | 1. Command-Line Tools (yaml-get, yaml-set, or eyaml-rotate-keys): Precise command-line arguments which trigger the defect. 27 | 2. Libraries (yamlpath.*): Minimum amount of code necessary to trigger the defect. 28 | 29 | ## Expected Outcome 30 | 31 | ## Actual Outcome 32 | 33 | ## Screenshot(s), if Available 34 | -------------------------------------------------------------------------------- /tests/test_merger_enums_multidocmodes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.multidocmodes import MultiDocModes 4 | 5 | 6 | class Test_merger_enums_multidocmodes(): 7 | """Tests for the MultiDocModes enumeration.""" 8 | 9 | def test_get_names(self): 10 | assert MultiDocModes.get_names() == [ 11 | "CONDENSE_ALL", 12 | "MERGE_ACROSS", 13 | "MATRIX_MERGE", 14 | ] 15 | 16 | def test_get_choices(self): 17 | assert MultiDocModes.get_choices() == [ 18 | "condense_all", 19 | "matrix_merge", 20 | "merge_across", 21 | ] 22 | 23 | @pytest.mark.parametrize("input,output", [ 24 | ("CONDENSE_ALL", MultiDocModes.CONDENSE_ALL), 25 | ("MERGE_ACROSS", MultiDocModes.MERGE_ACROSS), 26 | ("MATRIX_MERGE", MultiDocModes.MATRIX_MERGE), 27 | ]) 28 | def test_from_str(self, input, output): 29 | assert output == MultiDocModes.from_str(input) 30 | 31 | def test_from_str_nameerror(self): 32 | with pytest.raises(NameError): 33 | MultiDocModes.from_str("NO SUCH NAME") 34 | -------------------------------------------------------------------------------- /tests/test_merger_enums_arraymergeopts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.arraymergeopts import ( 4 | ArrayMergeOpts) 5 | 6 | 7 | class Test_merger_enums_arraymergeopts(): 8 | """Tests for the ArrayMergeOpts enumeration.""" 9 | 10 | def test_get_names(self): 11 | assert ArrayMergeOpts.get_names() == [ 12 | "ALL", 13 | "LEFT", 14 | "RIGHT", 15 | "UNIQUE", 16 | ] 17 | 18 | def test_get_choices(self): 19 | assert ArrayMergeOpts.get_choices() == [ 20 | "all", 21 | "left", 22 | "right", 23 | "unique", 24 | ] 25 | 26 | @pytest.mark.parametrize("input,output", [ 27 | ("ALL", ArrayMergeOpts.ALL), 28 | ("LEFT", ArrayMergeOpts.LEFT), 29 | ("RIGHT", ArrayMergeOpts.RIGHT), 30 | ("UNIQUE", ArrayMergeOpts.UNIQUE), 31 | ]) 32 | def test_from_str(self, input, output): 33 | assert output == ArrayMergeOpts.from_str(input) 34 | 35 | def test_from_str_nameerror(self): 36 | with pytest.raises(NameError): 37 | ArrayMergeOpts.from_str("NO SUCH NAME") 38 | -------------------------------------------------------------------------------- /tests/test_differ_enums_aohdiffopts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.differ.enums.aohdiffopts import AoHDiffOpts 4 | 5 | 6 | class Test_differ_enums_aohdiffopts(): 7 | """Tests for the AoHDiffOpts enumeration.""" 8 | 9 | def test_get_names(self): 10 | assert AoHDiffOpts.get_names() == [ 11 | "DEEP", 12 | "DPOS", 13 | "KEY", 14 | "POSITION", 15 | "VALUE", 16 | ] 17 | 18 | def test_get_choices(self): 19 | assert AoHDiffOpts.get_choices() == [ 20 | "deep", 21 | "dpos", 22 | "key", 23 | "position", 24 | "value", 25 | ] 26 | 27 | @pytest.mark.parametrize("input,output", [ 28 | ("DEEP", AoHDiffOpts.DEEP), 29 | ("DPOS", AoHDiffOpts.DPOS), 30 | ("KEY", AoHDiffOpts.KEY), 31 | ("POSITION", AoHDiffOpts.POSITION), 32 | ("VALUE", AoHDiffOpts.VALUE), 33 | ]) 34 | def test_from_str(self, input, output): 35 | assert output == AoHDiffOpts.from_str(input) 36 | 37 | def test_from_str_nameerror(self): 38 | with pytest.raises(NameError): 39 | AoHDiffOpts.from_str("NO SUCH NAME") 40 | -------------------------------------------------------------------------------- /tests/test_path_searchterms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.enums import PathSearchMethods 4 | from yamlpath.path import SearchTerms 5 | 6 | class Test_path_SearchTerms(): 7 | """Tests for the SearchTerms class.""" 8 | 9 | @pytest.mark.parametrize("invert,method,attr,term,output", [ 10 | (False, PathSearchMethods.CONTAINS, "abc", "b", "[abc%b]"), 11 | (True, PathSearchMethods.REGEX, "abc", "^abc$", "[abc!=~/^abc$/]"), 12 | ]) 13 | def test_str(self, invert, method, attr, term, output): 14 | assert output == str(SearchTerms(invert, method, attr, term)) 15 | 16 | # Disabled until Python matures enough to permit classes and types to play 17 | # nicely together... 18 | # def test_from_path_segment_attrs(self): 19 | # from yamlpath.types import PathAttributes 20 | # st = SearchTerms(False, PathSearchMethods.EQUALS, ".", "key") 21 | # assert str(st) == str(SearchTerms.from_path_segment_attrs(st)) 22 | 23 | # with pytest.raises(AttributeError): 24 | # _ = SearchTerms.from_path_segment_attrs("nothing-to-see-here") 25 | -------------------------------------------------------------------------------- /tests/test_merger_enums_aohmergeopts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.aohmergeopts import ( 4 | AoHMergeOpts) 5 | 6 | 7 | class Test_merger_enums_aohmergeopts(): 8 | """Tests for the AoHMergeOpts enumeration.""" 9 | 10 | def test_get_names(self): 11 | assert AoHMergeOpts.get_names() == [ 12 | "ALL", 13 | "DEEP", 14 | "LEFT", 15 | "RIGHT", 16 | "UNIQUE", 17 | ] 18 | 19 | def test_get_choices(self): 20 | assert AoHMergeOpts.get_choices() == [ 21 | "all", 22 | "deep", 23 | "left", 24 | "right", 25 | "unique", 26 | ] 27 | 28 | @pytest.mark.parametrize("input,output", [ 29 | ("ALL", AoHMergeOpts.ALL), 30 | ("DEEP", AoHMergeOpts.DEEP), 31 | ("LEFT", AoHMergeOpts.LEFT), 32 | ("RIGHT", AoHMergeOpts.RIGHT), 33 | ("UNIQUE", AoHMergeOpts.UNIQUE), 34 | ]) 35 | def test_from_str(self, input, output): 36 | assert output == AoHMergeOpts.from_str(input) 37 | 38 | def test_from_str_nameerror(self): 39 | with pytest.raises(NameError): 40 | AoHMergeOpts.from_str("NO SUCH NAME") 41 | -------------------------------------------------------------------------------- /.github/workflows/python-publish-to-prod-pypi.yml: -------------------------------------------------------------------------------- 1 | # Manually publish to production PyPI 2 | # @ssee: https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | name: Upload PRODUCTION Python Package 4 | 5 | on: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | name: Publish to Production PyPI 11 | runs-on: ubuntu-latest 12 | environment: 'PyPI: Production' 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 3.9 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.9' 20 | - name: Install Build Tools 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install --upgrade setuptools wheel 24 | - name: Build Artifacts 25 | run: | 26 | python setup.py sdist bdist_wheel 27 | - name: Publish Artifacts 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | -------------------------------------------------------------------------------- /yamlpath/differ/enums/diffactions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the DiffActions enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | 8 | 9 | class DiffActions(Enum): 10 | """ 11 | The action taken for one document to become the next. 12 | 13 | `ADD` 14 | Add an entry 15 | 16 | `CHANGE` 17 | An entry has changed. 18 | 19 | `DELETE` 20 | Remove an entry. 21 | 22 | `SAME` 23 | The two entries are identical. 24 | """ 25 | 26 | ADD = auto() 27 | CHANGE = auto() 28 | DELETE = auto() 29 | SAME = auto() 30 | 31 | def __str__(self) -> str: 32 | """Get the diff-like entry code for this action.""" 33 | diff_type = "" 34 | if self is DiffActions.ADD: 35 | diff_type = "a" 36 | elif self is DiffActions.CHANGE: 37 | diff_type = "c" 38 | elif self is DiffActions.DELETE: 39 | diff_type = "d" 40 | else: 41 | diff_type = "s" 42 | return diff_type 43 | -------------------------------------------------------------------------------- /tests/test_enums_pathsearchmethods.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.enums import PathSearchMethods 4 | 5 | 6 | class Test_enums_PathSearchMethods(): 7 | """Tests for the PathSearchMethods enumeration.""" 8 | 9 | @pytest.mark.parametrize("input,output", [ 10 | (PathSearchMethods.CONTAINS, "%"), 11 | (PathSearchMethods.ENDS_WITH, "$"), 12 | (PathSearchMethods.EQUALS, "="), 13 | (PathSearchMethods.STARTS_WITH, "^"), 14 | (PathSearchMethods.GREATER_THAN, ">"), 15 | (PathSearchMethods.LESS_THAN, "<"), 16 | (PathSearchMethods.GREATER_THAN_OR_EQUAL, ">="), 17 | (PathSearchMethods.LESS_THAN_OR_EQUAL, "<="), 18 | (PathSearchMethods.REGEX, "=~"), 19 | ]) 20 | def test_str(self, input, output): 21 | assert output == str(input) 22 | 23 | @pytest.mark.parametrize("input,result", [ 24 | ("!", False), 25 | ("%", True), 26 | ("$", True), 27 | ("=", True), 28 | ("^", True), 29 | (">", True), 30 | ("<", True), 31 | (">=", True), 32 | ("<=", True), 33 | ("=~", True), 34 | ]) 35 | def test_is_operator(self, input, result): 36 | assert result == PathSearchMethods.is_operator(input) 37 | -------------------------------------------------------------------------------- /tests/test_eyaml_enums_eyamloutputformats.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.eyaml.enums import EYAMLOutputFormats 4 | 5 | 6 | class Test_eyaml_enums_EYAMLOutputFormats(): 7 | def test_get_names(self): 8 | assert EYAMLOutputFormats.get_names() == [ 9 | "BLOCK", 10 | "STRING", 11 | ] 12 | 13 | @pytest.mark.parametrize("input,output", [ 14 | (EYAMLOutputFormats.BLOCK, "block"), 15 | (EYAMLOutputFormats.STRING, "string"), 16 | ]) 17 | def test_str(self, input, output): 18 | assert output == str(input) 19 | 20 | @pytest.mark.parametrize("input,output", [ 21 | ("block", EYAMLOutputFormats.BLOCK), 22 | ("string", EYAMLOutputFormats.STRING), 23 | ("BLOCK", EYAMLOutputFormats.BLOCK), 24 | ("STRING", EYAMLOutputFormats.STRING), 25 | (EYAMLOutputFormats.BLOCK, EYAMLOutputFormats.BLOCK), 26 | (EYAMLOutputFormats.STRING, EYAMLOutputFormats.STRING), 27 | ]) 28 | def test_from_str(self, input, output): 29 | assert output == EYAMLOutputFormats.from_str(input) 30 | 31 | def test_from_str_nameerror(self): 32 | with pytest.raises(NameError): 33 | EYAMLOutputFormats.from_str("NO SUCH NAME") 34 | -------------------------------------------------------------------------------- /tests/test_merger_enums_anchorconflictresolutions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.merger.enums.anchorconflictresolutions import ( 4 | AnchorConflictResolutions) 5 | 6 | 7 | class Test_merger_enum_anchorconflictresolutions(): 8 | """Tests for the AnchorConflictResolutions enumeration.""" 9 | 10 | def test_get_names(self): 11 | assert AnchorConflictResolutions.get_names() == [ 12 | "STOP", 13 | "LEFT", 14 | "RIGHT", 15 | "RENAME", 16 | ] 17 | 18 | def test_get_choices(self): 19 | assert AnchorConflictResolutions.get_choices() == [ 20 | "left", 21 | "rename", 22 | "right", 23 | "stop", 24 | ] 25 | 26 | @pytest.mark.parametrize("input,output", [ 27 | ("STOP", AnchorConflictResolutions.STOP), 28 | ("LEFT", AnchorConflictResolutions.LEFT), 29 | ("RIGHT", AnchorConflictResolutions.RIGHT), 30 | ("RENAME", AnchorConflictResolutions.RENAME), 31 | ]) 32 | def test_from_str(self, input, output): 33 | assert output == AnchorConflictResolutions.from_str(input) 34 | 35 | def test_from_str_nameerror(self): 36 | with pytest.raises(NameError): 37 | AnchorConflictResolutions.from_str("NO SUCH NAME") 38 | -------------------------------------------------------------------------------- /tests/test_wrappers_nodecoords.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.wrappers import NodeCoords 4 | 5 | class Test_wrappers_NodeCoords(): 6 | """Tests for the NodeCoords class.""" 7 | 8 | def test_generic(self): 9 | node_coord = NodeCoords([], None, None) 10 | 11 | def test_repr(self): 12 | node_coord = NodeCoords([], None, None) 13 | assert repr(node_coord) == "NodeCoords('[]', 'None', 'None')" 14 | 15 | def test_str(self): 16 | node_coord = NodeCoords([], None, None) 17 | assert str(node_coord) == "[]" 18 | 19 | def test_gt(self): 20 | lhs_nc = NodeCoords(5, None, None) 21 | rhs_nc = NodeCoords(3, None, None) 22 | assert lhs_nc > rhs_nc 23 | 24 | def test_null_gt(self): 25 | lhs_nc = NodeCoords(5, None, None) 26 | rhs_nc = NodeCoords(None, None, None) 27 | assert not lhs_nc > rhs_nc 28 | 29 | def test_lt(self): 30 | lhs_nc = NodeCoords(5, None, None) 31 | rhs_nc = NodeCoords(7, None, None) 32 | assert lhs_nc < rhs_nc 33 | 34 | def test_null_lt(self): 35 | lhs_nc = NodeCoords(5, None, None) 36 | rhs_nc = NodeCoords(None, None, None) 37 | assert not lhs_nc < rhs_nc 38 | 39 | def test_isa_null(self): 40 | nc = NodeCoords(None, None, None) 41 | assert nc.wraps_a(None) 42 | -------------------------------------------------------------------------------- /yamlpath/exceptions/unmatchedyamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement UnmatchedYAMLPathException. 3 | 4 | Copyright 2023 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 9 | 10 | 11 | class UnmatchedYAMLPathException(YAMLPathException): 12 | """ 13 | Indicate a YAML Path points to nothing. 14 | 15 | Occurs when a YAML Path is improperly formed or fails to lead to a required 16 | YAML node. 17 | """ 18 | 19 | def __init__( 20 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 21 | ) -> None: 22 | """ 23 | Initialize this Exception with all pertinent data. 24 | 25 | Parameters: 26 | 1. user_message (str) The message to convey to the user 27 | 2. yaml_path (str) The stringified YAML Path which lead to the 28 | exception 29 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 30 | the exception, if available 31 | 32 | Returns: N/A 33 | 34 | Raises: N/A 35 | """ 36 | super().__init__( 37 | user_message=user_message, yaml_path=yaml_path, segment=segment) 38 | -------------------------------------------------------------------------------- /yamlpath/exceptions/recursionyamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement RecursionYAMLPathException. 3 | 4 | Copyright 2023 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 9 | 10 | 11 | class RecursionYAMLPathException(YAMLPathException): 12 | """ 13 | Indicate a YAML Path causes an inescapable recursion loop. 14 | 15 | Occurs when a YAML Path is improperly formed or fails to lead to a required 16 | YAML node. 17 | """ 18 | 19 | def __init__( 20 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 21 | ) -> None: 22 | """ 23 | Initialize this Exception with all pertinent data. 24 | 25 | Parameters: 26 | 1. user_message (str) The message to convey to the user 27 | 2. yaml_path (str) The stringified YAML Path which lead to the 28 | exception 29 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 30 | the exception, if available 31 | 32 | Returns: N/A 33 | 34 | Raises: N/A 35 | """ 36 | super().__init__( 37 | user_message=user_message, yaml_path=yaml_path, segment=segment) 38 | -------------------------------------------------------------------------------- /yamlpath/exceptions/badaliasyamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement BadAliasYAMLPathException. 3 | 4 | Copyright 2023 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 9 | 10 | 11 | class BadAliasYAMLPathException(YAMLPathException): 12 | """ 13 | Indicate a YAML Path points to an impossible YAML Anchor/Alias/YMK. 14 | 15 | Occurs when a YAML Path is improperly formed or fails to lead to a required 16 | YAML node. 17 | """ 18 | 19 | def __init__( 20 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 21 | ) -> None: 22 | """ 23 | Initialize this Exception with all pertinent data. 24 | 25 | Parameters: 26 | 1. user_message (str) The message to convey to the user 27 | 2. yaml_path (str) The stringified YAML Path which lead to the 28 | exception 29 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 30 | the exception, if available 31 | 32 | Returns: N/A 33 | 34 | Raises: N/A 35 | """ 36 | super().__init__( 37 | user_message=user_message, yaml_path=yaml_path, segment=segment) 38 | -------------------------------------------------------------------------------- /yamlpath/exceptions/nodocumentyamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement NoDocumentYAMLPathException. 3 | 4 | Copyright 2023 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 9 | 10 | 11 | class NoDocumentYAMLPathException(YAMLPathException): 12 | """ 13 | Indicate a YAML Path points to or creates an empty document. 14 | 15 | Occurs when a YAML Path is improperly formed or fails to lead to a required 16 | YAML node. 17 | """ 18 | 19 | def __init__( 20 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 21 | ) -> None: 22 | """ 23 | Initialize this Exception with all pertinent data. 24 | 25 | Parameters: 26 | 1. user_message (str) The message to convey to the user 27 | 2. yaml_path (str) The stringified YAML Path which lead to the 28 | exception 29 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 30 | the exception, if available 31 | 32 | Returns: N/A 33 | 34 | Raises: N/A 35 | """ 36 | super().__init__( 37 | user_message=user_message, yaml_path=yaml_path, segment=segment) 38 | -------------------------------------------------------------------------------- /yamlpath/exceptions/typemismatchyamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement TypeMismatchYAMLPathException. 3 | 4 | Copyright 2023 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 9 | 10 | 11 | class TypeMismatchYAMLPathException(YAMLPathException): 12 | """ 13 | Indicate a YAML Path contains a data-type mismatch. 14 | 15 | Occurs when a YAML Path is improperly formed or fails to lead to a required 16 | YAML node. 17 | """ 18 | 19 | def __init__( 20 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 21 | ) -> None: 22 | """ 23 | Initialize this Exception with all pertinent data. 24 | 25 | Parameters: 26 | 1. user_message (str) The message to convey to the user 27 | 2. yaml_path (str) The stringified YAML Path which lead to the 28 | exception 29 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 30 | the exception, if available 31 | 32 | Returns: N/A 33 | 34 | Raises: N/A 35 | """ 36 | super().__init__( 37 | user_message=user_message, yaml_path=yaml_path, segment=segment) 38 | -------------------------------------------------------------------------------- /yamlpath/exceptions/duplicatekeyyamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement DuplicateKeyYAMLPathException. 3 | 4 | Copyright 2023 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | from yamlpath.exceptions.yamlpathexception import YAMLPathException 9 | 10 | 11 | class DuplicateKeyYAMLPathException(YAMLPathException): 12 | """ 13 | Indicate a YAML Path operation would violate key uniqueness. 14 | 15 | Occurs when a YAML Path is improperly formed or fails to lead to a required 16 | YAML node. 17 | """ 18 | 19 | def __init__( 20 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 21 | ) -> None: 22 | """ 23 | Initialize this Exception with all pertinent data. 24 | 25 | Parameters: 26 | 1. user_message (str) The message to convey to the user 27 | 2. yaml_path (str) The stringified YAML Path which lead to the 28 | exception 29 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 30 | the exception, if available 31 | 32 | Returns: N/A 33 | 34 | Raises: N/A 35 | """ 36 | super().__init__( 37 | user_message=user_message, yaml_path=yaml_path, segment=segment) 38 | -------------------------------------------------------------------------------- /.github/workflows/python-publish-to-coveralls.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs the full sweep of tests for the yamlpath package and publishes the coverage report to coveralls.io 2 | name: Publish Coverage Report to Coveralls.IO 3 | 4 | on: 5 | push: 6 | branches: [ master, development ] 7 | pull_request: 8 | branches: [ master, development ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | - name: Set Environment Variables 18 | run: | 19 | echo "${HOME}/.local/share/gem/ruby/3.0.0/bin" >> $GITHUB_PATH 20 | - name: Install dependencies 21 | run: | 22 | gem install --user-install hiera-eyaml 23 | python -m pip install --upgrade pip 24 | python -m pip install --upgrade setuptools 25 | python -m pip install --upgrade pytest pytest-cov pytest-console-scripts coveralls 26 | python -m pip install --editable . 27 | - name: Unit Test with pytest 28 | run: | 29 | pytest --verbose --cov=yamlpath --cov-report=term-missing --cov-fail-under=100 --script-launch-mode=subprocess tests 30 | - name: Publish coveralls Report 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | run: | 34 | coveralls --service=github 35 | -------------------------------------------------------------------------------- /yamlpath/enums/anchormatches.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the AnchorMatches enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | 8 | 9 | class AnchorMatches(Enum): 10 | """ 11 | When performing a search, YAML Anchors and Aliases can be evaluated. 12 | 13 | When they are, these are the possible match results: 14 | 15 | `ALIAS_EXCLUDED` 16 | The Anchor is a duplicate that has already matched. 17 | 18 | `ALIAS_INCLUDED` 19 | The Anchor is a duplicate alias and the search parameters permit 20 | duplicates. 21 | 22 | `MATCH` 23 | This original Anchor is a match. 24 | 25 | `NO_ANCHOR` 26 | The given node has no Anchor. 27 | 28 | `NO_MATCH` 29 | This original Anchor is not a match. 30 | 31 | `UNSEARCHABLE_ALIAS` 32 | The node references an Anchor via an Alias that has already been seen, 33 | but the search parameters prohibit searching Anchor names. 34 | 35 | `UNSEARCHABLE_ANCHOR` 36 | The node has an Anchor that is so-far unique, but the search parameters 37 | prohibit searching Anchor names. 38 | """ 39 | 40 | ALIAS_EXCLUDED = auto() 41 | ALIAS_INCLUDED = auto() 42 | MATCH = auto() 43 | NO_ANCHOR = auto() 44 | NO_MATCH = auto() 45 | UNSEARCHABLE_ALIAS = auto() 46 | UNSEARCHABLE_ANCHOR = auto() 47 | -------------------------------------------------------------------------------- /yamlpath/enums/pathsegmenttypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the PathSegmentTypes enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | 8 | 9 | class PathSegmentTypes(Enum): 10 | """ 11 | Supported YAML Path segment types. 12 | 13 | These include: 14 | 15 | `ANCHOR` 16 | A named YAML Anchor. 17 | 18 | `COLLECTOR` 19 | A sub YAML Path for which the result will be returned as a list. The 20 | data pointer is left where it was before the Collector expression is 21 | resolved. 22 | 23 | `INDEX` 24 | A list element index. 25 | 26 | `KEYWORD_SEARCH` 27 | A search based on PathSearchKeywords. 28 | 29 | `KEY` 30 | A dictionary key name. 31 | 32 | `SEARCH` 33 | A search operation for which results are returned as they are matched. 34 | 35 | `TRAVERSE` 36 | Traverses the document tree deeply. If there is a next segment, it 37 | must match or no data is matched. When there is no next segment, every 38 | leaf node matches. 39 | 40 | `MATCH_ALL` 41 | Matches every immediate child node. 42 | """ 43 | 44 | ANCHOR = auto() 45 | COLLECTOR = auto() 46 | INDEX = auto() 47 | KEY = auto() 48 | SEARCH = auto() 49 | TRAVERSE = auto() 50 | KEYWORD_SEARCH = auto() 51 | MATCH_ALL = auto() 52 | -------------------------------------------------------------------------------- /yamlpath/merger/exceptions/mergeexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements MergeException. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional, Union 7 | 8 | from yamlpath import YAMLPath 9 | 10 | 11 | class MergeException(Exception): 12 | """Express an issue with a document merge.""" 13 | 14 | def __init__( 15 | self, user_message: str, 16 | yaml_path: Optional[Union[YAMLPath, str]] = None 17 | ) -> None: 18 | """ 19 | Initialize this Exception with all pertinent data. 20 | 21 | Parameters: 22 | 1. user_message (str) The message to convey to the user 23 | 2. yaml_path (YAMLPath) Location within the document where the issue 24 | was found, if available. 25 | 26 | Returns: N/A 27 | """ 28 | self.user_message = user_message 29 | self.yaml_path = yaml_path 30 | 31 | super().__init__("user_message: {}, yaml_path: {}" 32 | .format(user_message, yaml_path)) 33 | 34 | def __str__(self) -> str: 35 | """Return a String expression of this Exception.""" 36 | message: str = "" 37 | if self.yaml_path is None: 38 | message = "{}".format(self.user_message) 39 | else: 40 | message = "{} This issue occurred at YAML Path: {}".format( 41 | self.user_message, 42 | self.yaml_path) 43 | return message 44 | -------------------------------------------------------------------------------- /yamlpath/path/collectorterms.py: -------------------------------------------------------------------------------- 1 | """ 2 | YAML Path Collector segment terms. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from yamlpath.enums import CollectorOperators 7 | 8 | 9 | class CollectorTerms: 10 | """YAML Path Collector segment terms.""" 11 | 12 | def __init__( 13 | self, expression: str, 14 | operation: CollectorOperators = CollectorOperators.NONE 15 | ) -> None: 16 | """ 17 | Instantiate a Collector Term. 18 | 19 | Parameters: 20 | 1. expression (str) The YAML Path being collected 21 | 2. operation (CollectorOperators) The operation for this Collector, 22 | relative to its subsequent peer Collector 23 | """ 24 | self._expression: str = expression 25 | self._operation: CollectorOperators = operation 26 | 27 | def __str__(self) -> str: 28 | """Get the String rendition of this Collector Term.""" 29 | operator: str = str(self.operation) 30 | return "{}({})".format(operator, self.expression) 31 | 32 | @property 33 | def operation(self) -> CollectorOperators: 34 | """ 35 | Get the operation for this Collector. 36 | 37 | This indicates whether its results are independent, added to the prior 38 | Collector, or removed from the prior Collector. 39 | """ 40 | return self._operation 41 | 42 | @property 43 | def expression(self) -> str: 44 | """Get the Collector expression, which is a stringified YAML Path.""" 45 | return self._expression 46 | -------------------------------------------------------------------------------- /tests/test_enums_collectoroperators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.enums import CollectorOperators 4 | 5 | 6 | class Test_enums_CollectorOperators(): 7 | """Tests for the CollectorOperators enumeration.""" 8 | def test_get_names(self): 9 | assert CollectorOperators.get_names() == [ 10 | "ADDITION", 11 | "NONE", 12 | "SUBTRACTION", 13 | "INTERSECTION", 14 | ] 15 | 16 | @pytest.mark.parametrize("input,output", [ 17 | (CollectorOperators.ADDITION, "+"), 18 | (CollectorOperators.NONE, ""), 19 | (CollectorOperators.SUBTRACTION, "-"), 20 | (CollectorOperators.INTERSECTION, "&"), 21 | ]) 22 | def test_str(self, input, output): 23 | assert output == str(input) 24 | 25 | @pytest.mark.parametrize("input,output", [ 26 | ("+", CollectorOperators.ADDITION), 27 | ("-", CollectorOperators.SUBTRACTION), 28 | ("&", CollectorOperators.INTERSECTION), 29 | ("ADDITION", CollectorOperators.ADDITION), 30 | ("NONE", CollectorOperators.NONE), 31 | ("SUBTRACTION", CollectorOperators.SUBTRACTION), 32 | ("INTERSECTION", CollectorOperators.INTERSECTION), 33 | (CollectorOperators.ADDITION, CollectorOperators.ADDITION), 34 | (CollectorOperators.SUBTRACTION, CollectorOperators.SUBTRACTION), 35 | (CollectorOperators.INTERSECTION, CollectorOperators.INTERSECTION), 36 | (CollectorOperators.NONE, CollectorOperators.NONE), 37 | ]) 38 | def test_from_operator(self, input, output): 39 | assert output == CollectorOperators.from_operator(input) 40 | 41 | def test_from_operator_nameerror(self): 42 | with pytest.raises(NameError): 43 | CollectorOperators.from_operator("NO SUCH NAME") 44 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install dependencies, EYAML (from Ruby), and run all tests 2 | # and linting tools with every supported version of Python. 3 | # @see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: build 6 | 7 | on: 8 | push: 9 | branches: [ master, development ] 10 | pull_request: 11 | branches: [ master, development ] 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Set Environment Variables 28 | run: | 29 | echo "${HOME}/.local/share/gem/ruby/3.0.0/bin" >> $GITHUB_PATH 30 | - name: Install dependencies 31 | run: | 32 | gem install --user-install hiera-eyaml 33 | python -m pip install --upgrade pip 34 | python -m pip install --upgrade setuptools 35 | python -m pip install --upgrade wheel 36 | python -m pip install --upgrade mypy pytest pytest-cov pytest-console-scripts pylint coveralls pydocstyle 37 | python -m pip install --editable . 38 | - name: Validate Compliance with pydocstyle 39 | run: | 40 | pydocstyle yamlpath 41 | - name: Validate Compliance with MyPY 42 | run: | 43 | mypy yamlpath 44 | - name: Lint with pylint 45 | run: | 46 | pylint yamlpath 47 | - name: Unit Test with pytest 48 | run: | 49 | pytest --verbose --cov=yamlpath --cov-report=term-missing --cov-fail-under=100 --script-launch-mode=subprocess tests 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env*/ 88 | venv*/ 89 | ENV*/ 90 | env*.bak/ 91 | venv*.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDE configurations 107 | .vscode/ 108 | .vscode-env*/ 109 | .vscode-venv*/ 110 | .venv*/ 111 | -------------------------------------------------------------------------------- /tests/test_enums_pathseparators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.enums import PathSeparators 4 | 5 | # Legacy spelling compatibility: 6 | from yamlpath.enums import PathSeperators 7 | 8 | 9 | class Test_enums_PathSeparators(): 10 | """Tests for the PathSeparators enumeration.""" 11 | @pytest.mark.parametrize("pathsep_module", [PathSeparators, PathSeperators]) 12 | def test_get_names(self, pathsep_module): 13 | assert pathsep_module.get_names() == [ 14 | "AUTO", 15 | "DOT", 16 | "FSLASH", 17 | ] 18 | 19 | @pytest.mark.parametrize("input,output", [ 20 | (PathSeparators.AUTO, '.'), 21 | (PathSeparators.DOT, '.'), 22 | (PathSeparators.FSLASH, '/'), 23 | ]) 24 | def test_str(self, input, output): 25 | assert output == str(input) 26 | 27 | @pytest.mark.parametrize("input,output", [ 28 | (".", PathSeparators.DOT), 29 | ("/", PathSeparators.FSLASH), 30 | ("DOT", PathSeparators.DOT), 31 | ("FSLASH", PathSeparators.FSLASH), 32 | (PathSeparators.DOT, PathSeparators.DOT), 33 | (PathSeparators.FSLASH, PathSeparators.FSLASH), 34 | ]) 35 | @pytest.mark.parametrize("pathsep_module", [PathSeparators, PathSeperators]) 36 | def test_from_str(self, input, output, pathsep_module): 37 | assert output == pathsep_module.from_str(input) 38 | 39 | @pytest.mark.parametrize("pathsep_module", [PathSeparators, PathSeperators]) 40 | def test_from_str_nameerror(self, pathsep_module): 41 | with pytest.raises(NameError): 42 | pathsep_module.from_str("NO SUCH NAME") 43 | 44 | @pytest.mark.parametrize("input,output", [ 45 | ("abc", PathSeparators.DOT), 46 | ("abc.123", PathSeparators.DOT), 47 | ("/abc", PathSeparators.FSLASH), 48 | ("/abc/123", PathSeparators.FSLASH), 49 | ]) 50 | @pytest.mark.parametrize("pathsep_module", [PathSeparators, PathSeperators]) 51 | @pytest.mark.parametrize("func_name", ["infer_separator", "infer_seperator"]) 52 | def test_infer_separator(self, input, output, pathsep_module, func_name): 53 | assert output == getattr(pathsep_module, func_name)(input) 54 | -------------------------------------------------------------------------------- /yamlpath/eyaml/enums/eyamloutputformats.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the EYAMLOutputFormats enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | class EYAMLOutputFormats(Enum): 10 | """ 11 | Supported EYAML command output formats. 12 | 13 | Options include: 14 | 15 | `BLOCK` 16 | A multi-line version of the otherwise very long encrypted value, 17 | represented in YAML as a folded string. Special to EYAML, the 18 | consequent spaces must be removed from the value when it is read 19 | before it can be decrypted. 20 | 21 | `STRING` 22 | A single-line version of the encrypted value, usually very long. 23 | """ 24 | 25 | BLOCK = auto() 26 | STRING = auto() 27 | 28 | def __str__(self) -> str: 29 | """Get a String rendition of this object.""" 30 | return str(self.name).lower() 31 | 32 | @staticmethod 33 | def get_names() -> List[str]: 34 | """ 35 | Get all entry names for this enumeration. 36 | 37 | Parameters: N/A 38 | 39 | Returns: (List[str]) Upper-case names from this enumeration 40 | 41 | Raises: N/A 42 | """ 43 | return [entry.name.upper() for entry in EYAMLOutputFormats] 44 | 45 | @staticmethod 46 | def from_str(name: str) -> "EYAMLOutputFormats": 47 | """Convert a string value to a value of this enumeration, if valid. 48 | 49 | Parameters: 50 | 1. name (str) The name to convert 51 | 52 | Returns: (EYAMLOutputFormats) the converted enumeration value 53 | 54 | Raises: 55 | - `NameError` when name doesn't match any enumeration values. 56 | """ 57 | check: str = str(name).upper() 58 | if check in EYAMLOutputFormats.get_names(): 59 | return EYAMLOutputFormats[check] 60 | raise NameError( 61 | "EYAMLOutputFormats has no such item: {}" 62 | .format(name)) 63 | -------------------------------------------------------------------------------- /yamlpath/exceptions/yamlpathexception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement YAMLPathException. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Optional 7 | 8 | 9 | class YAMLPathException(Exception): 10 | """ 11 | Indicate a user error with a YAML Path. 12 | 13 | Occurs when a YAML Path is improperly formed or fails to lead to a required 14 | YAML node. 15 | """ 16 | 17 | def __init__( 18 | self, user_message: str, yaml_path: str, segment: Optional[str] = None 19 | ) -> None: 20 | """ 21 | Initialize this Exception with all pertinent data. 22 | 23 | Parameters: 24 | 1. user_message (str) The message to convey to the user 25 | 2. yaml_path (str) The stringified YAML Path which lead to the 26 | exception 27 | 3. segment (Optional[str]) The segment of the YAML Path which triggered 28 | the exception, if available 29 | 30 | Returns: N/A 31 | 32 | Raises: N/A 33 | """ 34 | self.user_message: str = user_message 35 | self.yaml_path: str = yaml_path 36 | self.segment: Optional[str] = segment 37 | 38 | super().__init__( 39 | "user_message: {}, yaml_path: {}, segment: {}" 40 | .format(user_message, yaml_path, segment)) 41 | 42 | # Should Pickling ever be necessary: 43 | # def __reduce__(self): 44 | # return YAMLPathException, ( 45 | # self.user_message, 46 | # self.yaml_path, 47 | # self.segment 48 | # ) 49 | 50 | def __str__(self) -> str: 51 | """Return a String expression of this Exception.""" 52 | message: str = "" 53 | if self.segment is None: 54 | message = "{}, '{}'.".format( 55 | self.user_message, 56 | self.yaml_path) 57 | else: 58 | message = "{} at '{}' in '{}'.".format( 59 | self.user_message, 60 | self.segment, 61 | self.yaml_path) 62 | return message 63 | -------------------------------------------------------------------------------- /tests/test_path_searchkeywordterms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yamlpath.enums import PathSearchKeywords 4 | from yamlpath.path import SearchKeywordTerms 5 | 6 | class Test_path_SearchKeywordTerms(): 7 | """Tests for the SearchKeywordTerms class.""" 8 | 9 | @pytest.mark.parametrize("invert,keyword,parameters,output", [ 10 | (True, PathSearchKeywords.HAS_CHILD, "abc", "[!has_child(abc)]"), 11 | (False, PathSearchKeywords.HAS_CHILD, "abc", "[has_child(abc)]"), 12 | (False, PathSearchKeywords.HAS_CHILD, "abc\\,def", "[has_child(abc\\,def)]"), 13 | (False, PathSearchKeywords.HAS_CHILD, "abc, def", "[has_child(abc, def)]"), 14 | (False, PathSearchKeywords.HAS_CHILD, "abc,' def'", "[has_child(abc,' def')]"), 15 | ]) 16 | def test_str(self, invert, keyword, parameters, output): 17 | assert output == str(SearchKeywordTerms(invert, keyword, parameters)) 18 | 19 | @pytest.mark.parametrize("parameters,output", [ 20 | ("abc", ["abc"]), 21 | ("abc\\,def", ["abc,def"]), 22 | ("abc, def", ["abc", "def"]), 23 | ("abc,' def'", ["abc", " def"]), 24 | ("1,'1', 1, '1', 1 , ' 1', '1 ', ' 1 '", ["1", "1", "1", "1", "1", " 1", "1 ", " 1 "]), 25 | ("true, False,'True','false'", ["true", "False", "True", "false"]), 26 | ("'',,\"\", '', ,,\"\\'\",'\\\"'", ["", "", "", "", "", "", "'", "\""]), 27 | ("'And then, she said, \"Quote!\"'", ["And then, she said, \"Quote!\""]), 28 | (None, []), 29 | ]) 30 | def test_parameter_parsing(self, parameters, output): 31 | skt = SearchKeywordTerms(False, PathSearchKeywords.HAS_CHILD, parameters) 32 | assert output == skt.parameters 33 | 34 | @pytest.mark.parametrize("parameters", [ 35 | ("','a'"), 36 | ("a,\"b,"), 37 | ]) 38 | def test_unmatched_demarcation(self, parameters): 39 | skt = SearchKeywordTerms(False, PathSearchKeywords.HAS_CHILD, parameters) 40 | with pytest.raises(ValueError) as ex: 41 | parmlist = skt.parameters 42 | assert -1 < str(ex.value).find("one or more unmatched demarcation symbol") 43 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/outputdoctypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the OutputDocTypes enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class OutputDocTypes(Enum): 11 | """ 12 | Supported Output Document Types. 13 | 14 | Options include: 15 | 16 | `AUTO` 17 | The output type is inferred from the first source document. 18 | 19 | `JSON` 20 | Force output to be JSON. 21 | 22 | `YAML` 23 | Force output to be YAML. 24 | """ 25 | 26 | AUTO = auto() 27 | JSON = auto() 28 | YAML = auto() 29 | 30 | @staticmethod 31 | def get_names() -> List[str]: 32 | """ 33 | Get all upper-cased entry names for this enumeration. 34 | 35 | Parameters: N/A 36 | 37 | Returns: (List[str]) Upper-case names from this enumeration 38 | 39 | Raises: N/A 40 | """ 41 | return [entry.name.upper() for entry in OutputDocTypes] 42 | 43 | @staticmethod 44 | def get_choices() -> List[str]: 45 | """ 46 | Get all entry names with symbolic representations for this enumeration. 47 | 48 | All returned entries are lower-cased. 49 | 50 | Parameters: N/A 51 | 52 | Returns: (List[str]) Lower-case names and symbols from this 53 | enumeration 54 | 55 | Raises: N/A 56 | """ 57 | names = [l.lower() for l in OutputDocTypes.get_names()] 58 | choices = list(set(names)) 59 | choices.sort() 60 | return choices 61 | 62 | @staticmethod 63 | def from_str(name: str) -> "OutputDocTypes": 64 | """ 65 | Convert a string value to a value of this enumeration, if valid. 66 | 67 | Parameters: 68 | 1. name (str) The name to convert 69 | 70 | Returns: (OutputDocTypes) the converted enumeration value 71 | 72 | Raises: 73 | - `NameError` when name doesn't match any enumeration values 74 | """ 75 | check: str = str(name).upper() 76 | if check in OutputDocTypes.get_names(): 77 | return OutputDocTypes[check] 78 | raise NameError( 79 | "OutputDocTypes has no such item: {}" 80 | .format(name)) 81 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Build this project.""" 2 | from setuptools import find_packages, setup 3 | 4 | with open("README.md", "r", encoding="utf-8") as fh: 5 | long_description = fh.read() 6 | 7 | setup( 8 | name="yamlpath", 9 | description=( 10 | "Command-line get/set/merge/validate/scan/convert/diff processors for" 11 | + " YAML/JSON/Compatible data using powerful, intuitive, command-line" 12 | + " friendly syntax"), 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | classifiers=[ 16 | "Development Status :: 5 - Production/Stable", 17 | "License :: OSI Approved :: ISC License (ISCL)", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Operating System :: OS Independent", 24 | "Environment :: Console", 25 | "Topic :: Utilities", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ], 28 | url="https://github.com/wwkimball/yamlpath", 29 | author="William W. Kimball, Jr., MBA, MSIS", 30 | author_email="github-yamlpath@kimballstuff.com", 31 | license="ISC", 32 | keywords="yaml eyaml json yaml-path diff merge", 33 | packages=find_packages(exclude=["tests"]), 34 | entry_points={ 35 | "console_scripts": [ 36 | "eyaml-rotate-keys = yamlpath.commands.eyaml_rotate_keys:main", 37 | "yaml-get = yamlpath.commands.yaml_get:main", 38 | "yaml-paths = yamlpath.commands.yaml_paths:main", 39 | "yaml-set = yamlpath.commands.yaml_set:main", 40 | "yaml-merge = yamlpath.commands.yaml_merge:main", 41 | "yaml-validate = yamlpath.commands.yaml_validate:main", 42 | "yaml-diff = yamlpath.commands.yaml_diff:main", 43 | ] 44 | }, 45 | python_requires=">3.7.0", 46 | install_requires=[ 47 | "ruamel.yaml>0.17.5,!=0.17.18,<=0.17.21", 48 | "python-dateutil<=3" 49 | ], 50 | tests_require=[ 51 | "pytest", 52 | "pytest-cov", 53 | "pytest-console-scripts", 54 | ], 55 | include_package_data=True, 56 | zip_safe=False 57 | ) 58 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/hashmergeopts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the HashMergeOpts enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class HashMergeOpts(Enum): 11 | """ 12 | Supported Hash (AKA: Map, dict) Merge Options. 13 | 14 | Options include: 15 | 16 | `DEEP` 17 | RHS Hashes are deeply merged into LHS Hashes (full merge). 18 | 19 | `LEFT` 20 | LHS Hashes are not overwritten by RHS Hashes (no merge). 21 | 22 | `RIGHT` 23 | RHS Hashes fully replace LHS Hashes (no merge). 24 | """ 25 | 26 | DEEP = auto() 27 | LEFT = auto() 28 | RIGHT = auto() 29 | 30 | @staticmethod 31 | def get_names() -> List[str]: 32 | """ 33 | Get all upper-cased entry names for this enumeration. 34 | 35 | Parameters: N/A 36 | 37 | Returns: (List[str]) Upper-case names from this enumeration 38 | 39 | Raises: N/A 40 | """ 41 | return [entry.name.upper() for entry in HashMergeOpts] 42 | 43 | @staticmethod 44 | def get_choices() -> List[str]: 45 | """ 46 | Get all entry names with symbolic representations for this enumeration. 47 | 48 | All returned entries are lower-cased. 49 | 50 | Parameters: N/A 51 | 52 | Returns: (List[str]) Lower-case names and symbols from this 53 | enumeration 54 | 55 | Raises: N/A 56 | """ 57 | names = [l.lower() for l in HashMergeOpts.get_names()] 58 | choices = list(set(names)) 59 | choices.sort() 60 | return choices 61 | 62 | @staticmethod 63 | def from_str(name: str) -> "HashMergeOpts": 64 | """ 65 | Convert a string value to a value of this enumeration, if valid. 66 | 67 | Parameters: 68 | 1. name (str) The name to convert 69 | 70 | Returns: (HashMergeOpts) the converted enumeration value 71 | 72 | Raises: 73 | - `NameError` when name doesn't match any enumeration values 74 | """ 75 | check: str = str(name).upper() 76 | if check in HashMergeOpts.get_names(): 77 | return HashMergeOpts[check] 78 | raise NameError( 79 | "HashMergeOpts has no such item: {}" 80 | .format(name)) 81 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/setmergeopts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the SetMergeOpts enumeration. 3 | 4 | Copyright 2021 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class SetMergeOpts(Enum): 11 | """ 12 | Supported Set Merge Options. 13 | 14 | Options include: 15 | 16 | `LEFT` 17 | LHS Sets are not overwritten/appended by RHS Sets (no merge). 18 | 19 | `RIGHT` 20 | RHS Sets fully replace LHS Sets (no merge). 21 | 22 | `UNIQUE` 23 | Only RHS Set elements not alread in LHS Sets are appended to LHS Sets 24 | (merge). 25 | """ 26 | 27 | LEFT = auto() 28 | RIGHT = auto() 29 | UNIQUE = auto() 30 | 31 | @staticmethod 32 | def get_names() -> List[str]: 33 | """ 34 | Get all upper-cased entry names for this enumeration. 35 | 36 | Parameters: N/A 37 | 38 | Returns: (List[str]) Upper-case names from this enumeration 39 | 40 | Raises: N/A 41 | """ 42 | return [entry.name.upper() for entry in SetMergeOpts] 43 | 44 | @staticmethod 45 | def get_choices() -> List[str]: 46 | """ 47 | Get all entry names with symbolic representations for this enumeration. 48 | 49 | All returned entries are lower-cased. 50 | 51 | Parameters: N/A 52 | 53 | Returns: (List[str]) Lower-case names and symbols from this 54 | enumeration 55 | 56 | Raises: N/A 57 | """ 58 | names = [l.lower() for l in SetMergeOpts.get_names()] 59 | choices = list(set(names)) 60 | choices.sort() 61 | return choices 62 | 63 | @staticmethod 64 | def from_str(name: str) -> "SetMergeOpts": 65 | """ 66 | Convert a string value to a value of this enumeration, if valid. 67 | 68 | Parameters: 69 | 1. name (str) The name to convert 70 | 71 | Returns: (SetMergeOpts) the converted enumeration value 72 | 73 | Raises: 74 | - `NameError` when name doesn't match any enumeration values 75 | """ 76 | check: str = str(name).upper() 77 | if check in SetMergeOpts.get_names(): 78 | return SetMergeOpts[check] 79 | raise NameError( 80 | "SetMergeOpts has no such item: {}" 81 | .format(name)) 82 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/arraymergeopts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the ArrayMergeOpts enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class ArrayMergeOpts(Enum): 11 | """ 12 | Supported Array (AKA: List) Merge Options. 13 | 14 | Options include: 15 | 16 | `ALL` 17 | All RHS Arrays elements are appended to LHS Arrays (no deduplication). 18 | 19 | `LEFT` 20 | LHS Arrays are not overwritten/appended by RHS Arrays (no merge). 21 | 22 | `RIGHT` 23 | RHS Arrays fully replace LHS Arrays (no merge). 24 | 25 | `UNIQUE` 26 | Only unique RHS Array elements are appended to LHS Arrays (merge). 27 | """ 28 | 29 | ALL = auto() 30 | LEFT = auto() 31 | RIGHT = auto() 32 | UNIQUE = auto() 33 | 34 | @staticmethod 35 | def get_names() -> List[str]: 36 | """ 37 | Get all upper-cased entry names for this enumeration. 38 | 39 | Parameters: N/A 40 | 41 | Returns: (List[str]) Upper-case names from this enumeration 42 | 43 | Raises: N/A 44 | """ 45 | return [entry.name.upper() for entry in ArrayMergeOpts] 46 | 47 | @staticmethod 48 | def get_choices() -> List[str]: 49 | """ 50 | Get all entry names with symbolic representations for this enumeration. 51 | 52 | All returned entries are lower-cased. 53 | 54 | Parameters: N/A 55 | 56 | Returns: (List[str]) Lower-case names and symbols from this 57 | enumeration 58 | 59 | Raises: N/A 60 | """ 61 | names = [l.lower() for l in ArrayMergeOpts.get_names()] 62 | choices = list(set(names)) 63 | choices.sort() 64 | return choices 65 | 66 | @staticmethod 67 | def from_str(name: str) -> "ArrayMergeOpts": 68 | """ 69 | Convert a string value to a value of this enumeration, if valid. 70 | 71 | Parameters: 72 | 1. name (str) The name to convert 73 | 74 | Returns: (ArrayMergeOpts) the converted enumeration value 75 | 76 | Raises: 77 | - `NameError` when name doesn't match any enumeration values 78 | """ 79 | check: str = str(name).upper() 80 | if check in ArrayMergeOpts.get_names(): 81 | return ArrayMergeOpts[check] 82 | raise NameError( 83 | "ArrayMergeOpts has no such item: {}" 84 | .format(name)) 85 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/multidocmodes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the MultiDocModes enumeration. 3 | 4 | Copyright 2021 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class MultiDocModes(Enum): 11 | """ 12 | Supported means of merging multi-document content. 13 | 14 | Options include: 15 | 16 | `CONDENSE_ALL` 17 | Merge all multi-documents up into single documents during the merge. 18 | 19 | `MERGE_ACROSS` 20 | Condence no multi-documents; rather, only merge documents "across" from 21 | right to left. 22 | 23 | `MATRIX_MERGE` 24 | Condence no multi-documents; rather, merge every RHS document into 25 | every LHS document. 26 | """ 27 | 28 | CONDENSE_ALL = auto() 29 | MERGE_ACROSS = auto() 30 | MATRIX_MERGE = auto() 31 | 32 | @staticmethod 33 | def get_names() -> List[str]: 34 | """ 35 | Get all upper-cased entry names for this enumeration. 36 | 37 | Parameters: N/A 38 | 39 | Returns: (List[str]) Upper-case names from this enumeration 40 | 41 | Raises: N/A 42 | """ 43 | return [entry.name.upper() for entry in MultiDocModes] 44 | 45 | @staticmethod 46 | def get_choices() -> List[str]: 47 | """ 48 | Get all entry names with symbolic representations for this enumeration. 49 | 50 | All returned entries are lower-cased. 51 | 52 | Parameters: N/A 53 | 54 | Returns: (List[str]) Lower-case names and symbols from this 55 | enumeration 56 | 57 | Raises: N/A 58 | """ 59 | names = [l.lower() for l in MultiDocModes.get_names()] 60 | choices = list(set(names)) 61 | choices.sort() 62 | return choices 63 | 64 | @staticmethod 65 | def from_str(name: str) -> "MultiDocModes": 66 | """ 67 | Convert a string value to a value of this enumeration, if valid. 68 | 69 | Parameters: 70 | 1. name (str) The name to convert 71 | 72 | Returns: (MultiDocModes) the converted enumeration value 73 | 74 | Raises: 75 | - `NameError` when name doesn't match any enumeration values 76 | """ 77 | check: str = str(name).upper() 78 | if check in MultiDocModes.get_names(): 79 | return MultiDocModes[check] 80 | raise NameError( 81 | "MultiDocModes has no such item: {}" 82 | .format(name)) 83 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/anchorconflictresolutions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the AnchorConflictResolutions enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class AnchorConflictResolutions(Enum): 11 | """ 12 | Supported Anchor Conflict Resolutions. 13 | 14 | Resolutions include: 15 | 16 | `STOP` 17 | Abort the merge upon conflict detection. 18 | 19 | `LEFT` 20 | The first-encountered definition overrides all other uses. 21 | 22 | `RIGHT` 23 | The last-encountered definition overrides all other uses. 24 | 25 | `RENAME` 26 | Conflicting anchors are renamed within the affected documents. 27 | """ 28 | 29 | STOP = auto() 30 | LEFT = auto() 31 | RIGHT = auto() 32 | RENAME = auto() 33 | 34 | @staticmethod 35 | def get_names() -> List[str]: 36 | """ 37 | Get all upper-cased entry names for this enumeration. 38 | 39 | Parameters: N/A 40 | 41 | Returns: (List[str]) Upper-case names from this enumeration 42 | 43 | Raises: N/A 44 | """ 45 | return [entry.name.upper() for entry in AnchorConflictResolutions] 46 | 47 | @staticmethod 48 | def get_choices() -> List[str]: 49 | """ 50 | Get all entry names with symbolic representations for this enumeration. 51 | 52 | All returned entries are lower-cased. 53 | 54 | Parameters: N/A 55 | 56 | Returns: (List[str]) Lower-case names and symbols from this 57 | enumeration 58 | 59 | Raises: N/A 60 | """ 61 | names = [l.lower() for l in AnchorConflictResolutions.get_names()] 62 | choices = list(set(names)) 63 | choices.sort() 64 | return choices 65 | 66 | @staticmethod 67 | def from_str(name: str) -> "AnchorConflictResolutions": 68 | """ 69 | Convert a string value to a value of this enumeration, if valid. 70 | 71 | Parameters: 72 | 1. name (str) The name to convert 73 | 74 | Returns: (AnchorConflictResolutions) the converted enumeration value 75 | 76 | Raises: 77 | - `NameError` when name doesn't match any enumeration values 78 | """ 79 | check: str = str(name).upper() 80 | if check in AnchorConflictResolutions.get_names(): 81 | return AnchorConflictResolutions[check] 82 | raise NameError( 83 | "AnchorConflictResolutions has no such item: {}" 84 | .format(name)) 85 | -------------------------------------------------------------------------------- /yamlpath/differ/enums/arraydiffopts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the ArrayDiffOpts enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class ArrayDiffOpts(Enum): 11 | """ 12 | Supported Array (AKA: List) Diff Options. 13 | 14 | Options include: 15 | 16 | `POSITION` 17 | Array elements are compared based on their ordinal position in each 18 | document. 19 | 20 | `VALUE` 21 | Array alements are synchronized by value before being compared. 22 | """ 23 | 24 | POSITION = auto() 25 | VALUE = auto() 26 | 27 | def __str__(self) -> str: 28 | """ 29 | Stringify one instance of this enumeration. 30 | 31 | Parameters: N/A 32 | 33 | Returns: (str) String value of this enumeration. 34 | 35 | Raises: N/A 36 | """ 37 | return str(self.name).lower() 38 | 39 | @staticmethod 40 | def get_names() -> List[str]: 41 | """ 42 | Get all upper-cased entry names for this enumeration. 43 | 44 | Parameters: N/A 45 | 46 | Returns: (List[str]) Upper-case names from this enumeration 47 | 48 | Raises: N/A 49 | """ 50 | return [entry.name.upper() for entry in ArrayDiffOpts] 51 | 52 | @staticmethod 53 | def get_choices() -> List[str]: 54 | """ 55 | Get all entry names with symbolic representations for this enumeration. 56 | 57 | All returned entries are lower-cased. 58 | 59 | Parameters: N/A 60 | 61 | Returns: (List[str]) Lower-case names and symbols from this 62 | enumeration 63 | 64 | Raises: N/A 65 | """ 66 | names = [l.lower() for l in ArrayDiffOpts.get_names()] 67 | choices = list(set(names)) 68 | choices.sort() 69 | return choices 70 | 71 | @staticmethod 72 | def from_str(name: str) -> "ArrayDiffOpts": 73 | """ 74 | Convert a string value to a value of this enumeration, if valid. 75 | 76 | Parameters: 77 | 1. name (str) The name to convert 78 | 79 | Returns: (ArrayDiffOpts) the converted enumeration value 80 | 81 | Raises: 82 | - `NameError` when name doesn't match any enumeration values 83 | """ 84 | check: str = str(name).upper() 85 | if check in ArrayDiffOpts.get_names(): 86 | return ArrayDiffOpts[check] 87 | raise NameError( 88 | "ArrayDiffOpts has no such item: {}" 89 | .format(name)) 90 | -------------------------------------------------------------------------------- /yamlpath/merger/enums/aohmergeopts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the AoHMergeOpts enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class AoHMergeOpts(Enum): 11 | """ 12 | Supported Array-of-Hash (AKA: List of Map, list of dict) Merge Options. 13 | 14 | Options include: 15 | 16 | `ALL` 17 | RHS Hashes are appended to the LHS Array (shallow merge with no 18 | de-duplication). 19 | 20 | `DEEP` 21 | RHS Hashes are deeply merged into LHS Hashes (full merge) IIF an 22 | identifier key is also provided via the --aohkey option. 23 | 24 | `LEFT` 25 | RHS Hashes are neither merged with nor appended to LHS Hashes (no 26 | merge). 27 | 28 | `RIGHT` 29 | LHS Hashes are discarded and fully replaced by RHS Hashes (no merge). 30 | 31 | `UNIQUE` 32 | RHS Hashes which do not already exist IN FULL within LHS are appended 33 | to the LHS Array (no merge). 34 | """ 35 | 36 | ALL = auto() 37 | DEEP = auto() 38 | LEFT = auto() 39 | RIGHT = auto() 40 | UNIQUE = auto() 41 | 42 | @staticmethod 43 | def get_names() -> List[str]: 44 | """ 45 | Get all upper-cased entry names for this enumeration. 46 | 47 | Parameters: N/A 48 | 49 | Returns: (List[str]) Upper-case names from this enumeration 50 | 51 | Raises: N/A 52 | """ 53 | return [entry.name.upper() for entry in AoHMergeOpts] 54 | 55 | @staticmethod 56 | def get_choices() -> List[str]: 57 | """ 58 | Get all entry names with symbolic representations for this enumeration. 59 | 60 | All returned entries are lower-cased. 61 | 62 | Parameters: N/A 63 | 64 | Returns: (List[str]) Lower-case names and symbols from this 65 | enumeration 66 | 67 | Raises: N/A 68 | """ 69 | names = [l.lower() for l in AoHMergeOpts.get_names()] 70 | choices = list(set(names)) 71 | choices.sort() 72 | return choices 73 | 74 | @staticmethod 75 | def from_str(name: str) -> "AoHMergeOpts": 76 | """ 77 | Convert a string value to a value of this enumeration, if valid. 78 | 79 | Parameters: 80 | 1. name (str) The name to convert 81 | 82 | Returns: (AoHMergeOpts) the converted enumeration value 83 | 84 | Raises: 85 | - `NameError` when name doesn't match any enumeration values 86 | """ 87 | check: str = str(name).upper() 88 | if check in AoHMergeOpts.get_names(): 89 | return AoHMergeOpts[check] 90 | raise NameError( 91 | "AoHMergeOpts has no such item: {}" 92 | .format(name)) 93 | -------------------------------------------------------------------------------- /tests/test_enums_yamlvalueformats.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, date 3 | 4 | from ruamel.yaml.scalarstring import ( 5 | PlainScalarString, 6 | DoubleQuotedScalarString, 7 | SingleQuotedScalarString, 8 | FoldedScalarString, 9 | LiteralScalarString, 10 | ) 11 | from ruamel.yaml.scalarbool import ScalarBoolean 12 | from ruamel.yaml.scalarfloat import ScalarFloat 13 | from ruamel.yaml.scalarint import ScalarInt 14 | from ruamel.yaml import version_info as ryversion 15 | if ryversion < (0, 17, 22): # pragma: no cover 16 | from yamlpath.patches.timestamp import ( 17 | AnchoredTimeStamp, 18 | ) # type: ignore 19 | else: 20 | # Temporarily fool MYPY into resolving the future-case imports 21 | from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp 22 | #from ruamel.yaml.timestamp import AnchoredTimeStamp 23 | 24 | from yamlpath.enums import YAMLValueFormats 25 | 26 | 27 | class Test_enums_YAMLValueFormats(): 28 | """Tests for the YAMLValueFormats enumeration.""" 29 | def test_get_names(self): 30 | assert YAMLValueFormats.get_names() == [ 31 | "BARE", 32 | "BOOLEAN", 33 | "DATE", 34 | "DEFAULT", 35 | "DQUOTE", 36 | "FLOAT", 37 | "FOLDED", 38 | "INT", 39 | "LITERAL", 40 | "SQUOTE", 41 | "TIMESTAMP", 42 | ] 43 | 44 | @pytest.mark.parametrize("input,output", [ 45 | ("BARE", YAMLValueFormats.BARE), 46 | ("BOOLEAN", YAMLValueFormats.BOOLEAN), 47 | ("DATE", YAMLValueFormats.DATE), 48 | ("DEFAULT", YAMLValueFormats.DEFAULT), 49 | ("DQUOTE", YAMLValueFormats.DQUOTE), 50 | ("FLOAT", YAMLValueFormats.FLOAT), 51 | ("FOLDED", YAMLValueFormats.FOLDED), 52 | ("INT", YAMLValueFormats.INT), 53 | ("LITERAL", YAMLValueFormats.LITERAL), 54 | ("SQUOTE", YAMLValueFormats.SQUOTE), 55 | ("TIMESTAMP", YAMLValueFormats.TIMESTAMP), 56 | ]) 57 | def test_from_str(self, input, output): 58 | assert output == YAMLValueFormats.from_str(input) 59 | 60 | def test_from_str_nameerror(self): 61 | with pytest.raises(NameError): 62 | YAMLValueFormats.from_str("NO SUCH NAME") 63 | 64 | @pytest.mark.parametrize("input,output", [ 65 | (FoldedScalarString(""), YAMLValueFormats.FOLDED), 66 | (LiteralScalarString(""), YAMLValueFormats.LITERAL), 67 | (date(2022, 9, 24), YAMLValueFormats.DATE), 68 | (DoubleQuotedScalarString(''), YAMLValueFormats.DQUOTE), 69 | (SingleQuotedScalarString(""), YAMLValueFormats.SQUOTE), 70 | (PlainScalarString(""), YAMLValueFormats.BARE), 71 | (ScalarBoolean(False), YAMLValueFormats.BOOLEAN), 72 | (ScalarFloat(1.01), YAMLValueFormats.FLOAT), 73 | (ScalarInt(10), YAMLValueFormats.INT), 74 | (AnchoredTimeStamp(2022, 9, 24, 7, 42, 38), YAMLValueFormats.TIMESTAMP), 75 | (None, YAMLValueFormats.DEFAULT), 76 | ]) 77 | def test_from_node(self, input, output): 78 | assert output == YAMLValueFormats.from_node(input) 79 | -------------------------------------------------------------------------------- /yamlpath/enums/pathsearchmethods.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the PathSearchMethods enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class PathSearchMethods(Enum): 11 | """ 12 | Supported methods for searching YAML Path segments. 13 | 14 | These include: 15 | 16 | `CONTAINS` 17 | Matches when the haystack contains the needle. 18 | 19 | `ENDS_WITH` 20 | Matches when the haystack ends with the needle. 21 | 22 | `EQUALS` 23 | Matches when the haystack and needle are identical. 24 | 25 | `STARTS_WITH` 26 | Matches when the haystack starts with the needle. 27 | 28 | `GREATER_THAN` 29 | Matches when the needle is greater than the haystack. 30 | 31 | `LESS_THAN` 32 | Matches when the needle is less than the haystack. 33 | 34 | `GREATER_THAN_OR_EQUAL` 35 | Matches when the needle is greater than or equal to the haystack. 36 | 37 | `LESS_THAN_OR_EQUAL` 38 | Matches when the needle is less than or equal to the haystack. 39 | 40 | `REGEX` 41 | Matches when the needle Regular Expression matches the haystack. 42 | """ 43 | 44 | CONTAINS = auto() 45 | ENDS_WITH = auto() 46 | EQUALS = auto() 47 | STARTS_WITH = auto() 48 | GREATER_THAN = auto() 49 | LESS_THAN = auto() 50 | GREATER_THAN_OR_EQUAL = auto() 51 | LESS_THAN_OR_EQUAL = auto() 52 | REGEX = auto() 53 | 54 | def __str__(self) -> str: 55 | """Get a String representation of an employed value of this enum.""" 56 | operator = '' 57 | if self is PathSearchMethods.EQUALS: 58 | operator = '=' 59 | elif self is PathSearchMethods.STARTS_WITH: 60 | operator = '^' 61 | elif self is PathSearchMethods.ENDS_WITH: 62 | operator = '$' 63 | elif self is PathSearchMethods.CONTAINS: 64 | operator = '%' 65 | elif self is PathSearchMethods.LESS_THAN: 66 | operator = '<' 67 | elif self is PathSearchMethods.GREATER_THAN: 68 | operator = '>' 69 | elif self is PathSearchMethods.LESS_THAN_OR_EQUAL: 70 | operator = '<=' 71 | elif self is PathSearchMethods.GREATER_THAN_OR_EQUAL: 72 | operator = '>=' 73 | elif self is PathSearchMethods.REGEX: 74 | operator = '=~' 75 | 76 | return operator 77 | 78 | @staticmethod 79 | def get_operators() -> List[str]: 80 | """Return the full list of supported symbolic search operators.""" 81 | return [str(o) for o in PathSearchMethods] 82 | 83 | @staticmethod 84 | def is_operator(symbol: str) -> bool: 85 | """Indicate whether symbol is a known search method operator.""" 86 | return symbol in PathSearchMethods.get_operators() 87 | -------------------------------------------------------------------------------- /.github/workflows/python-publish-to-test-pypi.yml: -------------------------------------------------------------------------------- 1 | # Test publishing to PyPI. 2 | # @see: https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | name: Upload Python TEST Package 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - development 10 | pull_request: 11 | branches: 12 | - development 13 | 14 | jobs: 15 | validate: 16 | name: Code Quality Assessment 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Set Environment Variables 29 | run: | 30 | echo "${HOME}/.local/share/gem/ruby/3.0.0/bin" >> $GITHUB_PATH 31 | - name: Install dependencies 32 | run: | 33 | gem install --user-install hiera-eyaml 34 | python -m pip install --upgrade pip 35 | python -m pip install --upgrade setuptools 36 | python -m pip install --upgrade wheel 37 | python -m pip install --upgrade mypy pytest pytest-cov pytest-console-scripts pylint coveralls pydocstyle 38 | python -m pip install --editable . 39 | - name: Validate Compliance with pydocstyle 40 | run: | 41 | pydocstyle yamlpath 42 | - name: Validate Compliance with MyPY 43 | run: | 44 | mypy yamlpath 45 | - name: Lint with pylint 46 | run: | 47 | pylint yamlpath 48 | - name: Unit Test with pytest 49 | run: | 50 | pytest --verbose --cov=yamlpath --cov-report=term-missing --cov-fail-under=100 --script-launch-mode=subprocess tests 51 | 52 | publish: 53 | name: Publish to TEST PyPI 54 | if: github.ref == 'refs/heads/development' 55 | runs-on: ubuntu-latest 56 | environment: 'PyPI: Test' 57 | needs: validate 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | - name: Set up Python 3.9 62 | uses: actions/setup-python@v5 63 | with: 64 | python-version: '3.9' 65 | - name: Install Build Tools 66 | run: | 67 | python -m pip install --upgrade pip 68 | python -m pip install --upgrade setuptools wheel 69 | - name: Build Artifacts 70 | run: | 71 | sed -i -r -e "s/(^__version__[[:space:]]*=[[:space:]]*)("'"'"[[:digit:]](\.[[:digit:]])+)"'"'"/\1\2.RC$(date "+%Y%m%d%H%M%S")"'"'"/" yamlpath/__init__.py 72 | python setup.py sdist bdist_wheel 73 | - name: Publish Artifacts 74 | uses: pypa/gh-action-pypi-publish@release/v1 75 | with: 76 | user: __token__ 77 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 78 | repository_url: https://test.pypi.org/legacy/ 79 | -------------------------------------------------------------------------------- /yamlpath/enums/pathsearchkeywords.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the PathSearchKeywords enumeration. 3 | 4 | Copyright 2021, 2022 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class PathSearchKeywords(Enum): 11 | """ 12 | Supported keyword methods for searching YAML Path segments. 13 | 14 | These include: 15 | 16 | `DISTINCT` 17 | Match exactly one of every value within collections, discarding 18 | duplicates. 19 | 20 | `HAS_CHILD` 21 | Matches when the node has a direct child with a given name. 22 | 23 | `NAME` 24 | Matches only the key-name or element-index of the present node, 25 | discarding any and all child node data. Can be used to rename the 26 | matched key as long as the new name is unique within the parent, lest 27 | the preexisting node be overwritten. Cannot be used to reassign an 28 | Array/sequence/list element to another position. 29 | 30 | `MAX` 31 | Matches whichever node(s) has/have the maximum value for a named child 32 | key or the maximum value within an Array/sequence/list. When used 33 | against a scalar value, that value is always its own maximum. 34 | 35 | `MIN` 36 | Matches whichever node(s) has/have the minimum value for a named child 37 | key or the minimum value within an Array/sequence/list. When used 38 | against a scalar value, that value is always its own minimum. 39 | 40 | `PARENT` 41 | Access the parent(s) of the present node. 42 | 43 | `UNIQUE` 44 | Match only values which have no duplicates within collections. 45 | """ 46 | 47 | DISTINCT = auto() 48 | HAS_CHILD = auto() 49 | NAME = auto() 50 | MAX = auto() 51 | MIN = auto() 52 | PARENT = auto() 53 | UNIQUE = auto() 54 | 55 | def __str__(self) -> str: 56 | """Get a String representation of an employed value of this enum.""" 57 | keyword = '' 58 | if self is PathSearchKeywords.DISTINCT: 59 | keyword = 'distinct' 60 | elif self is PathSearchKeywords.HAS_CHILD: 61 | keyword = 'has_child' 62 | elif self is PathSearchKeywords.NAME: 63 | keyword = 'name' 64 | elif self is PathSearchKeywords.MAX: 65 | keyword = 'max' 66 | elif self is PathSearchKeywords.MIN: 67 | keyword = 'min' 68 | elif self is PathSearchKeywords.PARENT: 69 | keyword = 'parent' 70 | elif self is PathSearchKeywords.UNIQUE: 71 | keyword = 'unique' 72 | 73 | return keyword 74 | 75 | @staticmethod 76 | def get_keywords() -> List[str]: 77 | """Return the full list of supported search keywords.""" 78 | return [str(o).lower() for o in PathSearchKeywords] 79 | 80 | @staticmethod 81 | def is_keyword(keyword: str) -> bool: 82 | """Indicate whether keyword is known.""" 83 | return keyword in PathSearchKeywords.get_keywords() 84 | -------------------------------------------------------------------------------- /yamlpath/func.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of general helper functions. 3 | 4 | Copyright 2018, 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | import sys 7 | 8 | from yamlpath.common import Anchors, Nodes, Parsers, Searches 9 | from yamlpath.wrappers import NodeCoords 10 | from yamlpath import YAMLPath 11 | 12 | 13 | DEPRECATION_WARNING = ("WARNING: Deprecated functions will be removed in the" 14 | " next major release of yamlpath. Please refer to the" 15 | " CHANGES file for more information (and how to get rid" 16 | " of this message).") 17 | print(DEPRECATION_WARNING, file=sys.stderr) 18 | 19 | def get_yaml_editor(*args, **kwargs): 20 | """Relay function call to static method.""" 21 | return Parsers.get_yaml_editor(*args, **kwargs) 22 | 23 | def get_yaml_data(*args, **kwargs): 24 | """Relay function call to static method.""" 25 | return Parsers.get_yaml_data(*args, **kwargs) 26 | 27 | def get_yaml_multidoc_data(*args, **kwargs): 28 | """Relay function call to static method.""" 29 | for (data, loaded) in Parsers.get_yaml_multidoc_data(*args, **kwargs): 30 | yield (data, loaded) 31 | 32 | def build_next_node(*args): 33 | """Relay function call to static method.""" 34 | return Nodes.build_next_node(*args) 35 | 36 | def append_list_element(*args): 37 | """Relay function call to static method.""" 38 | return Nodes.append_list_element(*args) 39 | 40 | def wrap_type(*args): 41 | """Relay function call to static method.""" 42 | return Nodes.wrap_type(*args) 43 | 44 | def clone_node(*args): 45 | """Relay function call to static method.""" 46 | return Nodes.clone_node(*args) 47 | 48 | def make_float_node(*args): 49 | """Relay function call to static method.""" 50 | return Nodes.make_float_node(*args) 51 | 52 | def make_new_node(*args): 53 | """Relay function call to static method.""" 54 | return Nodes.make_new_node(*args) 55 | 56 | def get_node_anchor(*args): 57 | """Relay function call to static method.""" 58 | return Anchors.get_node_anchor(*args) 59 | 60 | def search_matches(*args): 61 | """Relay function call to static method.""" 62 | return Searches.search_matches(*args) 63 | 64 | def search_anchor(*args, **kwargs): 65 | """Relay function call to static method.""" 66 | return Searches.search_anchor(*args, **kwargs) 67 | 68 | def ensure_escaped(*args): 69 | """Relay function call to static method.""" 70 | return YAMLPath.ensure_escaped(*args) 71 | 72 | def escape_path_section(*args): 73 | """Relay function call to static method.""" 74 | return YAMLPath.escape_path_section(*args) 75 | 76 | def create_searchterms_from_pathattributes(*args): 77 | """Relay function call to static method.""" 78 | return Searches.create_searchterms_from_pathattributes(*args) 79 | 80 | def unwrap_node_coords(*args): 81 | """Relay function call to static method.""" 82 | return NodeCoords.unwrap_node_coords(*args) 83 | 84 | def stringify_dates(*args): 85 | """Relay function call to static method.""" 86 | return Parsers.stringify_dates(*args) 87 | -------------------------------------------------------------------------------- /yamlpath/enums/collectoroperators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the CollectorOperators enumeration. 3 | 4 | Copyright 2019, 2020, 2021 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class CollectorOperators(Enum): 11 | """ 12 | Supported Collector operators. 13 | 14 | These identify how one Collector's results are to be combined with its 15 | predecessor Collector, if there is one. Operations include: 16 | 17 | `NONE` 18 | The Collector's results are not combined with its predecessor. 19 | Instead, the Collector creates a new result derived from its position 20 | with the data. 21 | 22 | `ADDITION` 23 | The Collector's results are concatenated with its immediate predecessor 24 | Collector's results. No effort is made to limit the resluting data to 25 | unique values. 26 | 27 | `SUBTRACTION` 28 | The Collector's results are removed from its immediate predecessor 29 | Collector's results. Only exact matches are removed. 30 | 31 | `INTERSECTION` 32 | Only those elements which are common to both Collectors are returned. 33 | """ 34 | 35 | ADDITION = auto() 36 | NONE = auto() 37 | SUBTRACTION = auto() 38 | INTERSECTION = auto() 39 | 40 | def __str__(self) -> str: 41 | """Get a String representation of an employed value of this enum.""" 42 | operator: str = '' 43 | if self is CollectorOperators.ADDITION: 44 | operator = '+' 45 | elif self is CollectorOperators.SUBTRACTION: 46 | operator = '-' 47 | elif self is CollectorOperators.INTERSECTION: 48 | operator = '&' 49 | return operator 50 | 51 | @staticmethod 52 | def get_names() -> List[str]: 53 | """ 54 | Get all entry names for this enumeration. 55 | 56 | Parameters: N/A 57 | 58 | Returns: (List[str]) Upper-case names from this enumeration 59 | 60 | Raises: N/A 61 | """ 62 | return [entry.name.upper() for entry in CollectorOperators] 63 | 64 | @staticmethod 65 | def from_operator(operator: str) -> "CollectorOperators": 66 | """ 67 | Convert a string value to a value of this enumeration, if valid. 68 | 69 | Parameters: 70 | 1. operator (str) The name to convert 71 | 72 | Returns: (CollectorOperators) the converted enumeration value 73 | 74 | Raises: 75 | - `NameError` when name doesn't match any enumeration values. 76 | """ 77 | if isinstance(operator, CollectorOperators): 78 | return operator 79 | 80 | check: str = str(operator).upper() 81 | 82 | if check == '+': 83 | check = "ADDITION" 84 | elif check == '-': 85 | check = "SUBTRACTION" 86 | elif check == '&': 87 | check = "INTERSECTION" 88 | 89 | if check in CollectorOperators.get_names(): 90 | return CollectorOperators[check] 91 | raise NameError( 92 | "CollectorOperators has no such item, {}.".format(check)) 93 | -------------------------------------------------------------------------------- /yamlpath/differ/enums/aohdiffopts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the AoHDiffOpts enumeration. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class AoHDiffOpts(Enum): 11 | """ 12 | Supported Array-of-Hash (AKA: List-of-Dictionaries) Diff Options. 13 | 14 | Options include: 15 | 16 | `DEEP` 17 | Like KEY except the record pairs are deeply traversed, looking for 18 | specific internal differences, after being matched up. 19 | 20 | `DPOS` 21 | Like POSITION (no KEY matching) except the record pairs are deeply 22 | traversed to report every specific difference between them. 23 | 24 | `KEY` 25 | AoH records are synchronized by their identity key before being 26 | compared as whole units (no deep traversal). 27 | 28 | `POSITION` 29 | AoH records are compared as whole units (no deep traversal) based on 30 | their ordinal position in each document. 31 | 32 | `VALUE` 33 | AoH records are synchronized as whole units (no deep traversal) before 34 | being compared. 35 | """ 36 | 37 | DEEP = auto() 38 | DPOS = auto() 39 | KEY = auto() 40 | POSITION = auto() 41 | VALUE = auto() 42 | 43 | def __str__(self) -> str: 44 | """ 45 | Stringify one instance of this enumeration. 46 | 47 | Parameters: N/A 48 | 49 | Returns: (str) String value of this enumeration. 50 | 51 | Raises: N/A 52 | """ 53 | return str(self.name).lower() 54 | 55 | @staticmethod 56 | def get_names() -> List[str]: 57 | """ 58 | Get all upper-cased entry names for this enumeration. 59 | 60 | Parameters: N/A 61 | 62 | Returns: (List[str]) Upper-case names from this enumeration 63 | 64 | Raises: N/A 65 | """ 66 | return [entry.name.upper() for entry in AoHDiffOpts] 67 | 68 | @staticmethod 69 | def get_choices() -> List[str]: 70 | """ 71 | Get all entry names with symbolic representations for this enumeration. 72 | 73 | All returned entries are lower-cased. 74 | 75 | Parameters: N/A 76 | 77 | Returns: (List[str]) Lower-case names and symbols from this 78 | enumeration 79 | 80 | Raises: N/A 81 | """ 82 | names = [l.lower() for l in AoHDiffOpts.get_names()] 83 | choices = list(set(names)) 84 | choices.sort() 85 | return choices 86 | 87 | @staticmethod 88 | def from_str(name: str) -> "AoHDiffOpts": 89 | """ 90 | Convert a string value to a value of this enumeration, if valid. 91 | 92 | Parameters: 93 | 1. name (str) The name to convert 94 | 95 | Returns: (AoHDiffOpts) the converted enumeration value 96 | 97 | Raises: 98 | - `NameError` when name doesn't match any enumeration values 99 | """ 100 | check: str = str(name).upper() 101 | if check in AoHDiffOpts.get_names(): 102 | return AoHDiffOpts[check] 103 | raise NameError( 104 | "AoHDiffOpts has no such item: {}" 105 | .format(name)) 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to yamlpath 2 | 3 | Contents: 4 | 5 | 1. [Introduction](#introduction) 6 | 2. [Issues](#issues) 7 | 1. [Bug Reports](#bug-reports) 8 | 2. [Feature Requests](#feature-requests) 9 | 3. [Code](#code) 10 | 1. [Unit Testing](#unit-testing) 11 | 4. [Thank You](#thank-you) 12 | 13 | ## Introduction 14 | 15 | Contributions are welcome! This Python project is a [publicly-accessible package](https://pypi.org/project/yamlpath/), so high-quality 16 | contributions are the foremost expectation. Whether you wish to [report an issue](#issues) or [contribute code](#code) for a bug-fix or 17 | new feature, you have found the right place. 18 | 19 | ## Issues 20 | 21 | Please report issues via [GitHub's Issues mechanism](https://github.com/wwkimball/yamlpath/issues). Both bug reports and new feature 22 | requests are welcome. 23 | 24 | ### Bug Reports 25 | 26 | When reporting a defect, you must include *all* of the following information in your issue report: 27 | 28 | 1. Operating System and its version on the machine(s) exhibiting the unfavorable outcome. 29 | 2. Version of Python in use at the time of the issue. 30 | 3. Precise version of yamlpath installed. 31 | 4. Precise version of ruamel.yaml installed. 32 | 5. Minimum sample of YAML (or compatible) data necessary to trigger the issue. 33 | 6. Complete steps to reproduce the issue when triggered via: 34 | 1. Command-Line Tools (yaml-get, yaml-set, or eyaml-rotate-keys): Precise command-line arguments which trigger the defect. 35 | 2. Libraries (yamlpath.*): Minimum amount of code necessary to trigger the defect. 36 | 7. Expected outcome. 37 | 8. Actual outcome. 38 | 39 | ### Feature Requests 40 | 41 | When submitting a Feature Request as an Issue, please prefix the Title of your issue report with the term, "FEATURE REQUEST". Bug Reports 42 | usually take priority over Feature Requests, so this prefix will help sort through Issue reports. The body of your request should include 43 | details of what you'd like to see this project do. If possible, include minimal examples of the data and the outcome you want. 44 | 45 | ## Code 46 | 47 | All code contributions must be submitted via Pull Requests against an appropriate Branch of this project. The "development" branch is a 48 | suitable PR target for most contributions. When possible, be sure to reference the Issue number in your source Branch name, like: 49 | 50 | * feature/123 51 | * bugfix/456 52 | 53 | If an Issue doesn't exist for the contribution you wish to make, please consider creating one along with your PR. Include the Issue 54 | number in the comments of your PR, like: "Adds feature #123" or "Fixes #456". 55 | 56 | ### Unit Testing 57 | 58 | Every code contribution must include `pytest` unit tests. Any contributions which _reduce_ the code coverage of the unit testing suite 59 | will be blocked until the missing tests are added. Any contributins which break existing unit tests *must* include updated unit tests 60 | along with documentation explaining why the test(s) had to change. Such documentation must be verbose and rational. 61 | 62 | ## Thank You 63 | 64 | For any of you willing to contribute to this project, you have my most sincere appreciation! Unless you specifically object, I will 65 | include your identity along with a note about your contribution(s) in the [CHANGES](CHANGES) file. 66 | -------------------------------------------------------------------------------- /tests/test_commands_yaml_validate.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.conftest import create_temp_yaml_file 4 | 5 | 6 | class Test_commands_yaml_validate(): 7 | """Tests for the yaml-validate command-line tool.""" 8 | command = "yaml-validate" 9 | 10 | def test_no_arguments(self, script_runner): 11 | result = script_runner.run([self.command, "--nostdin"]) 12 | assert not result.success, result.stderr 13 | assert "There must be at least one YAML_FILE" in result.stderr 14 | 15 | def test_too_many_pseudofiles(self, script_runner): 16 | result = script_runner.run([ 17 | self.command 18 | , '-' 19 | , '-']) 20 | assert not result.success, result.stderr 21 | assert "Only one YAML_FILE may be the - pseudo-file" in result.stderr 22 | 23 | def test_valid_singledoc(self, script_runner, tmp_path_factory): 24 | yaml_file = create_temp_yaml_file(tmp_path_factory, """--- 25 | this: 26 | single-document: 27 | is: valid 28 | """) 29 | result = script_runner.run([ 30 | self.command 31 | , "--nostdin" 32 | , yaml_file]) 33 | assert result.success, result.stderr 34 | 35 | def test_invalid_singledoc(self, script_runner, tmp_path_factory): 36 | yaml_file = create_temp_yaml_file(tmp_path_factory, "{[}") 37 | result = script_runner.run([ 38 | self.command 39 | , "--nostdin" 40 | , yaml_file]) 41 | assert not result.success, result.stderr 42 | assert " * YAML parsing error in" in result.stdout 43 | 44 | def test_valid_stdin_explicit(self, script_runner, tmp_path_factory): 45 | import subprocess 46 | stdin_content = "{this: {is: valid}}" 47 | result = subprocess.run( 48 | [self.command 49 | , "-"] 50 | , stdout=subprocess.PIPE 51 | , input=stdin_content 52 | , universal_newlines=True 53 | ) 54 | assert 0 == result.returncode, result.stderr 55 | 56 | def test_valid_stdin_implicit(self, script_runner, tmp_path_factory): 57 | import subprocess 58 | stdin_content = "{this: {is: valid}}" 59 | result = subprocess.run( 60 | [self.command] 61 | , stdout=subprocess.PIPE 62 | , input=stdin_content 63 | , universal_newlines=True 64 | ) 65 | assert 0 == result.returncode, result.stderr 66 | 67 | def test_invalid_stdin_explicit(self, script_runner, tmp_path_factory): 68 | import subprocess 69 | stdin_content = "{this: {is not: valid}]" 70 | result = subprocess.run( 71 | [self.command 72 | , "-"] 73 | , stdout=subprocess.PIPE 74 | , input=stdin_content 75 | , universal_newlines=True 76 | ) 77 | assert 2 == result.returncode, result.stderr 78 | assert " * YAML parsing error in" in result.stdout 79 | 80 | def test_invalid_stdin_implicit(self, script_runner, tmp_path_factory): 81 | import subprocess 82 | stdin_content = "{this: {is not: valid}]" 83 | result = subprocess.run( 84 | [self.command] 85 | , stdout=subprocess.PIPE 86 | , input=stdin_content 87 | , universal_newlines=True 88 | ) 89 | assert 2 == result.returncode, result.stderr 90 | assert " * YAML parsing error in" in result.stdout 91 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at github-yamlpath@kimballstuff.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ########################################################################## 3 | # Run Python code quality tests against this project. 4 | ########################################################################## 5 | if ! [ -d tests -a -d yamlpath ]; then 6 | echo "Please start this script only from within the top directory of the YAML Path project." >&2 7 | exit 2 8 | fi 9 | 10 | if [ 1 -gt "$#" ]; then 11 | echo "You must specify at least one Python version. Space-delimit multiples like: $0 3.7 3.8 3.9 3.10 3.11" >&2 12 | exit 2 13 | fi 14 | 15 | # Delete all cached data 16 | find ./ -name '__pycache__' -type d -print0 | xargs -0 rm -rf || exit $? 17 | rm -rf yamlpath.egg-info 18 | rm -rf /tmp/yamlpath-python-coverage-data 19 | rm -f .coverage 20 | 21 | for pythonVersion in "${@}"; do 22 | if which deactivate &>/dev/null; then 23 | echo "Deactivating Python $(python --version). If this dumps you right back to the shell prompt, you were running Microsoft's VSCode-embedded Python and were just put into a sub-shell; just exit to resume tests." 24 | deactivate 25 | fi 26 | 27 | pyCommand=python${pythonVersion} 28 | if ! which "$pyCommand" &>/dev/null; then 29 | echo -e "\nWARNING: Unable to find a Python binary named, ${pyCommand}!" >&2 30 | continue 31 | fi 32 | pyVersion=$("$pyCommand" --version) 33 | 34 | cat <<-EOF 35 | 36 | ============================================================================= 37 | Using Python ${pyVersion}... 38 | ============================================================================= 39 | EOF 40 | 41 | echo "...spawning a new temporary Virtual Environment..." 42 | tmpVEnv=$(mktemp -d -t yamlpath-$(date +%Y%m%dT%H%M%S)-XXXXXXXXXX) 43 | if ! "$pyCommand" -m venv "$tmpVEnv"; then 44 | rm -rf "$tmpVEnv" 45 | echo -e "\nERROR: Unable to spawn a new temporary virtual environment at ${tmpVEnv}!" >&2 46 | exit 125 47 | fi 48 | if ! source "${tmpVEnv}/bin/activate"; then 49 | rm -rf "$tmpVEnv" 50 | echo -e "\nWARNING: Unable to activate ${tmpVEnv}!" >&2 51 | continue 52 | fi 53 | 54 | echo "...upgrading pip" 55 | python -m pip install --upgrade pip >/dev/null 56 | 57 | echo "...upgrading setuptools" 58 | pip install --upgrade setuptools >/dev/null 59 | 60 | echo "...upgrading wheel" 61 | pip install --upgrade wheel >/dev/null 62 | 63 | echo "...installing self (editable because without it, pytest-cov cannot trace code execution!)" 64 | if ! pip install --editable . >/dev/null; then 65 | deactivate 66 | rm -rf "$tmpVEnv" 67 | echo -e "\nERROR: Unable to install self!" >&2 68 | exit 124 69 | fi 70 | 71 | echo "...upgrading testing tools" 72 | pip install --upgrade mypy pytest pytest-cov pytest-console-scripts \ 73 | pylint coveralls pydocstyle >/dev/null 74 | 75 | echo -e "\nPYDOCSTYLE..." 76 | if ! pydocstyle yamlpath; then 77 | deactivate 78 | rm -rf "$tmpVEnv" 79 | echo "PYDOCSTYLE Error: $?" 80 | exit 9 81 | fi 82 | 83 | echo -e "\nMYPY..." 84 | if ! mypy yamlpath; then 85 | deactivate 86 | rm -rf "$tmpVEnv" 87 | echo "MYPY Error: $?" 88 | exit 10 89 | fi 90 | 91 | echo -e "\nPYLINT..." 92 | if ! pylint yamlpath; then 93 | deactivate 94 | rm -rf "$tmpVEnv" 95 | echo "PYLINT Error: $?" 96 | exit 11 97 | fi 98 | 99 | echo -e "\nPYTEST..." 100 | if ! pytest \ 101 | --verbose \ 102 | --cov=yamlpath \ 103 | --cov-report=term-missing \ 104 | --cov-fail-under=100 \ 105 | --script-launch-mode=subprocess \ 106 | tests 107 | then 108 | deactivate 109 | rm -rf "$tmpVEnv" 110 | echo "PYTEST Error: $?" 111 | exit 12 112 | fi 113 | 114 | deactivate 115 | rm -rf "$tmpVEnv" 116 | done 117 | -------------------------------------------------------------------------------- /run-tests.ps1: -------------------------------------------------------------------------------- 1 | $HasTestsDir = Test-Path -Path tests -PathType Container 2 | $HasProjectDir = Test-Path -Path yamlpath -PathType Container 3 | if (-Not $HasTestsDir -Or -Not $HasProjectDir) { 4 | Write-Error "Please start this script only from within the top directory of the YAML Path project." 5 | exit 2 6 | } 7 | 8 | # Credit: https://stackoverflow.com/a/54935264 9 | function New-TemporaryDirectory { 10 | [CmdletBinding(SupportsShouldProcess = $true)] 11 | param() 12 | $parent = [System.IO.Path]::GetTempPath() 13 | do { 14 | $name = [System.IO.Path]::GetRandomFileName() 15 | $item = New-Item -Path $parent -Name $name -ItemType "directory" -ErrorAction SilentlyContinue 16 | } while (-not $item) 17 | return $Item 18 | } 19 | 20 | $EnvDirs = Get-ChildItem -Directory -Filter "venv*" 21 | ForEach ($EnvDir in $EnvDirs) { 22 | & "$($EnvDir.FullName)\Scripts\Activate.ps1" 23 | if (!$?) { 24 | Write-Error "`nERROR: Unable to activate $EnvDir!" 25 | continue 26 | } 27 | 28 | $PythonVersion = $(python --version) 29 | Write-Output @" 30 | 31 | ========================================================================= 32 | Using Python $PythonVersion... 33 | ========================================================================= 34 | "@ 35 | 36 | Write-Output "...spawning a new temporary Virtual Environment..." 37 | $TmpVEnv = New-TemporaryDirectory 38 | python -m venv $TmpVEnv 39 | if (!$?) { 40 | Write-Error "`nERROR: Unable to spawn a new temporary virtual environment at $TmpVEnv!" 41 | exit 125 42 | } 43 | & deactivate 44 | & "$($TmpVEnv.FullName)\Scripts\Activate.ps1" 45 | if (!$?) { 46 | Write-Error "`nERROR: Unable to activate $TmpVEnv!" 47 | continue 48 | } 49 | 50 | Write-Output "...upgrading pip" 51 | python -m pip install --upgrade pip 52 | 53 | Write-Output "...upgrading setuptools" 54 | pip install --upgrade setuptools 55 | 56 | Write-Output "...upgrading wheel" 57 | pip install --upgrade wheel 58 | 59 | Write-Output "...installing self" 60 | pip install --editable . 61 | if (!$?) { 62 | & deactivate 63 | Remove-Item -Recurse -Force $TmpVEnv 64 | Write-Error "`nERROR: Unable to install self!" 65 | exit 124 66 | } 67 | 68 | Write-Output "...upgrading testing tools" 69 | pip install --upgrade mypy pytest pytest-cov pytest-console-scripts pylint coveralls pydocstyle 70 | 71 | Write-Output "`nPYDOCSTYLE..." 72 | pydocstyle yamlpath | Out-String 73 | if (!$?) { 74 | & deactivate 75 | Remove-Item -Recurse -Force $TmpVEnv 76 | Write-Error "PYDOCSTYLE Error: $?" 77 | exit 9 78 | } 79 | 80 | Write-Output "`nMYPY..." 81 | mypy yamlpath | Out-String 82 | if (!$?) { 83 | & deactivate 84 | Remove-Item -Recurse -Force $TmpVEnv 85 | Write-Error "MYPY Error: $?" 86 | exit 10 87 | } 88 | 89 | Write-Output "`nPYLINT..." 90 | pylint yamlpath | Out-String 91 | if (!$?) { 92 | & deactivate 93 | Remove-Item -Recurse -Force $TmpVEnv 94 | Write-Error "PYLINT Error: $?" 95 | exit 11 96 | } 97 | 98 | Write-Output "`n PYTEST..." 99 | pytest -vv --cov=yamlpath --cov-report=term-missing --cov-fail-under=100 --script-launch-mode=subprocess tests 100 | if (!$?) { 101 | & deactivate 102 | Remove-Item -Recurse -Force $TmpVEnv 103 | Write-Error "PYTEST Error: $?" 104 | exit 12 105 | } 106 | 107 | Write-Output "Deactivating virtual Python environment..." 108 | & deactivate 109 | Remove-Item -Recurse -Force $TmpVEnv 110 | } 111 | -------------------------------------------------------------------------------- /yamlpath/path/searchterms.py: -------------------------------------------------------------------------------- 1 | """ 2 | YAML path Search segment terms. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from yamlpath.enums import PathSearchMethods 7 | 8 | 9 | class SearchTerms: 10 | """YAML path Search segment terms.""" 11 | 12 | def __init__( 13 | self, inverted: bool, method: PathSearchMethods, attribute: str, 14 | term: str 15 | ) -> None: 16 | """ 17 | Instantiate a Search Term. 18 | 19 | Parameters: 20 | 1. inverted (bool) true = invert the search results; false, otherwise 21 | 2. method (PathSearchMethods) the method of search 22 | 3. attribute (str) the attribute to search 23 | 4. term (str) the term to search for within attribute 24 | """ 25 | self._inverted: bool = inverted 26 | self._method: PathSearchMethods = method 27 | self._attribute: str = attribute 28 | self._term: str = term 29 | 30 | # While this works in Python 3.7.3, it does not work in Python 3.6.3. In 31 | # the older Python, this code creates a cyclic ImportError. Because this 32 | # works in newer Pythons, I'm leaving this code here but commented-out 33 | # should I ever decide to stop supporting older Pythons. Until then, I'm 34 | # moving this code to a neutral helper function that'll import both 35 | # SearchTerms and PathAttributes, thus neutering this nonsense. 36 | # @classmethod 37 | # def from_path_segment_attrs( 38 | # cls: Type, 39 | # rhs: "pathattributes.PathAttributes") -> "SearchTerms": 40 | # """ 41 | # Generates a new SearchTerms instance by copying SearchTerms 42 | # attributes from a YAML Path segment's attributes. 43 | # """ 44 | # if isinstance(rhs, SearchTerms): 45 | # newinst: SearchTerms = cls( 46 | # rhs.inverted, rhs.method, rhs.attribute, rhs.term 47 | # ) 48 | # return newinst 49 | # raise AttributeError 50 | 51 | def __str__(self) -> str: 52 | """Get a String representation of this Search Term.""" 53 | if self.method == PathSearchMethods.REGEX: 54 | safe_term = "/{}/".format(self.term.replace("/", r"\/")) 55 | else: 56 | # Replace unescaped spaces with escaped spaces 57 | safe_term = r"\ ".join( 58 | list(map( 59 | lambda ele: ele.replace(" ", r"\ ") 60 | , self.term.split(r"\ ") 61 | )) 62 | ) 63 | 64 | return ( 65 | "[" 66 | + str(self.attribute) 67 | + ("!" if self.inverted else "") 68 | + str(self.method) 69 | + safe_term 70 | + "]" 71 | ) 72 | 73 | @property 74 | def inverted(self) -> bool: 75 | """ 76 | Access the inversion flag for this Search. 77 | 78 | This indicates whether the results are to be inverted. 79 | """ 80 | return self._inverted 81 | 82 | @property 83 | def method(self) -> PathSearchMethods: 84 | """ 85 | Access the search method. 86 | 87 | This indicates what kind of search is to be performed. 88 | """ 89 | return self._method 90 | 91 | @property 92 | def attribute(self) -> str: 93 | """ 94 | Accessor for the attribute being searched. 95 | 96 | This is the "haystack" and may reference a particular dictionary key, 97 | all values of a dictionary, or the elements of a list. 98 | """ 99 | return self._attribute 100 | 101 | @property 102 | def term(self) -> str: 103 | """ 104 | Accessor for the search term. 105 | 106 | This is the "needle" to search for within the attribute ("haystack"). 107 | """ 108 | return self._term 109 | -------------------------------------------------------------------------------- /tests/test_common_nodes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import date, datetime 3 | from types import SimpleNamespace 4 | 5 | from ruamel.yaml.comments import CommentedSeq, CommentedMap, TaggedScalar 6 | from ruamel.yaml.scalarstring import PlainScalarString 7 | from ruamel.yaml.scalarbool import ScalarBoolean 8 | from ruamel.yaml.scalarfloat import ScalarFloat 9 | from ruamel.yaml.scalarint import ScalarInt 10 | from ruamel.yaml import version_info as ryversion 11 | if ryversion < (0, 17, 22): # pragma: no cover 12 | from yamlpath.patches.timestamp import ( 13 | AnchoredTimeStamp, 14 | AnchoredDate, 15 | ) # type: ignore 16 | else: # pragma: no cover 17 | # Temporarily fool MYPY into resolving the future-case imports 18 | from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp 19 | AnchoredDate = AnchoredTimeStamp 20 | #from ruamel.yaml.timestamp import AnchoredTimeStamp 21 | # From whence shall come AnchoredDate? 22 | 23 | from yamlpath.enums import YAMLValueFormats 24 | from yamlpath.common import Nodes 25 | 26 | class Test_common_nodes(): 27 | """Tests for the Nodes helper class.""" 28 | 29 | ### 30 | # make_new_node 31 | ### 32 | def test_dict_to_str(self): 33 | assert "{}" == Nodes.make_new_node("", "{}", YAMLValueFormats.DEFAULT) 34 | 35 | def test_list_to_str(self): 36 | assert "[]" == Nodes.make_new_node("", "[]", YAMLValueFormats.DEFAULT) 37 | 38 | def test_anchored_string(self): 39 | node = PlainScalarString("value") 40 | node.yaml_set_anchor("anchored") 41 | new_node = Nodes.make_new_node(node, "new", YAMLValueFormats.DEFAULT) 42 | assert new_node.anchor.value == node.anchor.value 43 | 44 | 45 | ### 46 | # apply_yaml_tag 47 | ### 48 | def test_tag_map(self): 49 | new_tag = "!something" 50 | old_node = CommentedMap({"key": "value"}) 51 | new_node = Nodes.apply_yaml_tag(old_node, new_tag) 52 | assert new_node.tag.value == new_tag 53 | 54 | def test_update_tag(self): 55 | old_tag = "!tagged" 56 | new_tag = "!changed" 57 | old_node = PlainScalarString("tagged value") 58 | tagged_node = TaggedScalar(old_node, tag=old_tag) 59 | new_node = Nodes.apply_yaml_tag(tagged_node, new_tag) 60 | assert new_node.tag.value == new_tag 61 | assert new_node.value == old_node 62 | 63 | def test_delete_tag(self): 64 | old_tag = "!tagged" 65 | new_tag = "" 66 | old_node = PlainScalarString("tagged value") 67 | tagged_node = TaggedScalar(old_node, tag=old_tag) 68 | new_node = Nodes.apply_yaml_tag(tagged_node, new_tag) 69 | assert not hasattr(new_node, "tag") 70 | assert new_node == old_node 71 | 72 | 73 | ### 74 | # tagless_value 75 | ### 76 | def test_tagless_value_syntax_error(self): 77 | assert "[abc" == Nodes.tagless_value("[abc") 78 | 79 | 80 | ### 81 | # node_is_aoh 82 | ### 83 | def test_aoh_node_is_none(self): 84 | assert False == Nodes.node_is_aoh(None) 85 | 86 | def test_aoh_node_is_not_list(self): 87 | assert False == Nodes.node_is_aoh({"key": "value"}) 88 | 89 | def test_aoh_is_inconsistent(self): 90 | assert False == Nodes.node_is_aoh([ 91 | {"key": "value"}, 92 | None 93 | ]) 94 | 95 | 96 | ### 97 | # wrap_type 98 | ### 99 | @pytest.mark.parametrize("value,checktype", [ 100 | ([], CommentedSeq), 101 | ({}, CommentedMap), 102 | ("", PlainScalarString), 103 | (1, ScalarInt), 104 | (1.1, ScalarFloat), 105 | (True, ScalarBoolean), 106 | (date(2022, 8, 2), AnchoredDate), 107 | (datetime(2022, 8, 2, 13, 22, 31), AnchoredTimeStamp), 108 | (SimpleNamespace(), SimpleNamespace), 109 | ]) 110 | def test_wrap_type(self, value, checktype): 111 | assert isinstance(Nodes.wrap_type(value), checktype) 112 | -------------------------------------------------------------------------------- /yamlpath/enums/pathseparators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the PathSeparators enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from enum import Enum, auto 7 | from typing import List 8 | 9 | 10 | class PathSeparators(Enum): 11 | """ 12 | Supported YAML Path segment separators. 13 | 14 | Separators include: 15 | 16 | `AUTO` 17 | The separator must be manually dictated or automatically inferred from 18 | the YAML Path being evaluated. 19 | 20 | `DOT` 21 | YAML Path segments are separated via dots (.). 22 | 23 | `FSLASH` 24 | YAML Path segments are separated via forward-slashes (/). 25 | """ 26 | 27 | AUTO = auto() 28 | DOT = auto() 29 | FSLASH = auto() 30 | 31 | def __str__(self) -> str: 32 | """Get a String representation of this employed enum's value.""" 33 | separator = '.' 34 | if self is PathSeparators.FSLASH: 35 | separator = '/' 36 | return separator 37 | 38 | @staticmethod 39 | def get_names() -> List[str]: 40 | """ 41 | Get all upper-cased entry names for this enumeration. 42 | 43 | Parameters: N/A 44 | 45 | Returns: (List[str]) Upper-case names from this enumeration 46 | 47 | Raises: N/A 48 | """ 49 | return [entry.name.upper() for entry in PathSeparators] 50 | 51 | @staticmethod 52 | def get_choices() -> List[str]: 53 | """ 54 | Get all entry names with symbolic representations for this enumeration. 55 | 56 | All returned entries are lower-cased. 57 | 58 | Parameters: N/A 59 | 60 | Returns: (List[str]) Lower-case names and symbols from this 61 | enumeration 62 | 63 | Raises: N/A 64 | """ 65 | names = [l.lower() for l in PathSeparators.get_names()] 66 | symbols = [str(e) for e in PathSeparators] 67 | choices = list(set(names + symbols)) 68 | choices.sort() 69 | return choices 70 | 71 | @staticmethod 72 | def from_str(name: str) -> "PathSeparators": 73 | """ 74 | Convert a string value to a value of this enumeration, if valid. 75 | 76 | Parameters: 77 | 1. name (str) The name to convert 78 | 79 | Returns: (PathSeparators) the converted enumeration value 80 | 81 | Raises: 82 | - `NameError` when name doesn't match any enumeration values 83 | """ 84 | if isinstance(name, PathSeparators): 85 | return name 86 | 87 | check: str = str(name).upper() 88 | if check == '.': 89 | check = "DOT" 90 | elif check == '/': 91 | check = "FSLASH" 92 | 93 | if check in PathSeparators.get_names(): 94 | return PathSeparators[check] 95 | raise NameError("PathSeparators has no such item, {}.".format(check)) 96 | 97 | @staticmethod 98 | def infer_separator(yaml_path: str) -> "PathSeparators": 99 | """ 100 | Infer the separator used within a sample YAML Path. 101 | 102 | Will attempt to return the best PathSeparators match. Always returns 103 | `PathSeparators.AUTO` when the sample is empty. 104 | 105 | Parameters: 106 | 1. yaml_path (str) The sample YAML Path to evaluate 107 | 108 | Returns: (PathSeparators) the inferred PathSeparators value 109 | 110 | Raises: N/A 111 | """ 112 | separator: PathSeparators = PathSeparators.AUTO 113 | 114 | if yaml_path: 115 | if yaml_path[0] == '/': 116 | separator = PathSeparators.FSLASH 117 | else: 118 | separator = PathSeparators.DOT 119 | 120 | return separator 121 | 122 | @staticmethod 123 | def infer_seperator(yaml_path: str) -> "PathSeparators": 124 | """ 125 | Infer the separator used within a sample YAML Path. 126 | 127 | Will attempt to return the best PathSeparators match. Always returns 128 | `PathSeparators.AUTO` when the sample is empty. 129 | 130 | This is provided for compatibility with older versions, 131 | before the spelling was updated to "separator." 132 | 133 | Parameters: 134 | 1. yaml_path (str) The sample YAML Path to evaluate 135 | 136 | Returns: (PathSeparators) the inferred PathSeparators value 137 | 138 | Raises: N/A 139 | """ 140 | return PathSeparators.infer_separator(yaml_path) 141 | -------------------------------------------------------------------------------- /yamlpath/path/searchkeywordterms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement SearchKeywordTerms. 3 | 4 | Copyright 2021 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import List 7 | 8 | from yamlpath.enums import PathSearchKeywords 9 | 10 | 11 | class SearchKeywordTerms: 12 | """YAML path Search Keyword segment terms.""" 13 | 14 | def __init__( 15 | self, inverted: bool, keyword: PathSearchKeywords, parameters: str 16 | ) -> None: 17 | """ 18 | Instantiate a Keyword Search Term segment. 19 | 20 | Parameters: 21 | 1. inverted (bool) true = invert the search operation; false, otherwise 22 | 2. keyword (PathSearchKeywords) the search keyword 23 | 3. parameters (str) the parameters to the keyword-named operation 24 | """ 25 | self._inverted: bool = inverted 26 | self._keyword: PathSearchKeywords = keyword 27 | self._parameters: str = parameters 28 | self._lparameters: List[str] = [] 29 | self._parameters_parsed: bool = False 30 | 31 | def __str__(self) -> str: 32 | """Get a String representation of this Keyword Search Term.""" 33 | return ( 34 | "[" 35 | + ("!" if self._inverted else "") 36 | + str(self._keyword) 37 | + "(" 38 | + self._parameters 39 | + ")]" 40 | ) 41 | 42 | @property 43 | def inverted(self) -> bool: 44 | """ 45 | Access the inversion flag for this Keyword Search. 46 | 47 | This indicates whether the search logic is to be inverted. 48 | """ 49 | return self._inverted 50 | 51 | @property 52 | def keyword(self) -> PathSearchKeywords: 53 | """ 54 | Access the search keyword. 55 | 56 | This indicates what kind of search logic is to be performed. 57 | """ 58 | return self._keyword 59 | 60 | @property 61 | # pylint: disable=locally-disabled,too-many-branches 62 | def parameters(self) -> List[str]: 63 | """Accessor for the parameters being fed to the search operation.""" 64 | if self._parameters_parsed: 65 | return self._lparameters 66 | 67 | if self._parameters is None: 68 | self._parameters_parsed = True 69 | self._lparameters = [] 70 | return self._lparameters 71 | 72 | param: str = "" 73 | params: List[str] = [] 74 | escape_next: bool = False 75 | demarc_stack: List[str] = [] 76 | demarc_count: int = 0 77 | 78 | # pylint: disable=locally-disabled,too-many-nested-blocks 79 | for char in self._parameters: 80 | demarc_count = len(demarc_stack) 81 | 82 | if escape_next: 83 | # Pass-through; capture this escaped character 84 | escape_next = False 85 | 86 | elif char == "\\": 87 | escape_next = True 88 | continue 89 | 90 | elif ( 91 | char == " " 92 | and (demarc_count < 1) 93 | ): 94 | # Ignore unescaped, non-demarcated whitespace 95 | continue 96 | 97 | elif char in ['"', "'"]: 98 | # Found a string demarcation mark 99 | if demarc_count > 0: 100 | # Already appending to an ongoing demarcated value 101 | if char == demarc_stack[-1]: 102 | # Close a matching pair 103 | demarc_stack.pop() 104 | demarc_count -= 1 105 | 106 | if demarc_count < 1: 107 | # Final close; seek the next delimiter 108 | continue 109 | 110 | else: 111 | # Embed a nested, demarcated component 112 | demarc_stack.append(char) 113 | demarc_count += 1 114 | else: 115 | # Fresh demarcated value 116 | demarc_stack.append(char) 117 | demarc_count += 1 118 | continue 119 | 120 | elif demarc_count < 1 and char == ",": 121 | params.append(param) 122 | param = "" 123 | continue 124 | 125 | param = param + char 126 | 127 | # Check for mismatched demarcations 128 | if demarc_count > 0: 129 | raise ValueError( 130 | "Keyword search parameters contain one or more unmatched" 131 | " demarcation symbol(s): {}".format(" ".join(demarc_stack))) 132 | 133 | # Add the last parameter, if there is one 134 | if param: 135 | params.append(param) 136 | 137 | self._lparameters = params 138 | self._parameters_parsed = True 139 | return self._lparameters 140 | -------------------------------------------------------------------------------- /yamlpath/patches/timestamp.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | """ 3 | Fix missing anchors from timestamp and date nodes. 4 | 5 | This must be removed once incorporated into ruamel.yaml, likely at version 6 | 0.17.22. 7 | 8 | Source: https://sourceforge.net/p/ruamel-yaml/tickets/440/ 9 | Copyright 2022 Anthon van der Neut, William W. Kimball Jr. MBA MSIS 10 | """ 11 | import ruamel.yaml 12 | from ruamel.yaml.constructor import ConstructorError 13 | from ruamel.yaml.anchor import Anchor 14 | from ruamel.yaml.timestamp import TimeStamp 15 | 16 | from typing import Any, Dict, Union # NOQA 17 | import datetime 18 | import copy 19 | 20 | 21 | class AnchoredTimeStamp(TimeStamp): 22 | """Extend TimeStamp to track YAML Anchors.""" 23 | 24 | def __init__(self, *args: Any, **kw: Any) -> None: 25 | """Initialize a new instance.""" 26 | self._yaml: Dict[Any, Any] = dict(t=False, tz=None, delta=0) 27 | 28 | def __new__(cls, *args: Any, **kw: Any) -> Any: # datetime is immutable 29 | """Create a new, immutable instance.""" 30 | anchor = kw.pop('anchor', None) 31 | ts = TimeStamp.__new__(cls, *args, **kw) 32 | if anchor is not None: 33 | ts.yaml_set_anchor(anchor, always_dump=True) 34 | return ts 35 | 36 | def __deepcopy__(self, memo: Any) -> Any: 37 | """Deeply copy this instance to another.""" 38 | ts = AnchoredTimeStamp(self.year, self.month, self.day, self.hour, self.minute, self.second) 39 | ts._yaml = copy.deepcopy(self._yaml) 40 | return ts 41 | 42 | @property 43 | def anchor(self) -> Any: 44 | """Access the YAML Anchor.""" 45 | if not hasattr(self, Anchor.attrib): 46 | setattr(self, Anchor.attrib, Anchor()) 47 | return getattr(self, Anchor.attrib) 48 | 49 | def yaml_anchor(self, any: bool = False) -> Any: 50 | """Get the YAML Anchor.""" 51 | if not hasattr(self, Anchor.attrib): 52 | return None 53 | if any or self.anchor.always_dump: 54 | return self.anchor 55 | return None 56 | 57 | def yaml_set_anchor(self, value: Any, always_dump: bool = False) -> None: 58 | """Set the YAML Anchor.""" 59 | self.anchor.value = value 60 | self.anchor.always_dump = always_dump 61 | 62 | 63 | class AnchoredDate(AnchoredTimeStamp): 64 | """Define AnchoredDate.""" 65 | 66 | pass 67 | 68 | 69 | def construct_anchored_timestamp( 70 | self, node: Any, values: Any = None 71 | ) -> Union[AnchoredTimeStamp, AnchoredDate]: 72 | """Construct an AnchoredTimeStamp.""" 73 | try: 74 | match = self.timestamp_regexp.match(node.value) 75 | except TypeError: 76 | match = None 77 | if match is None: 78 | raise ConstructorError( 79 | None, 80 | None, 81 | f'failed to construct timestamp from "{node.value}"', 82 | node.start_mark, 83 | ) 84 | values = match.groupdict() 85 | dd = ruamel.yaml.util.create_timestamp(**values) # this has delta applied 86 | delta = None 87 | if values['tz_sign']: 88 | tz_hour = int(values['tz_hour']) 89 | minutes = values['tz_minute'] 90 | tz_minute = int(minutes) if minutes else 0 91 | delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute) 92 | if values['tz_sign'] == '-': 93 | delta = -delta 94 | if isinstance(dd, datetime.datetime): 95 | data = AnchoredTimeStamp( 96 | dd.year, dd.month, dd.day, dd.hour, dd.minute, dd.second, dd.microsecond, anchor=node.anchor 97 | ) 98 | else: 99 | data = AnchoredDate(dd.year, dd.month, dd.day, 0, 0, 0, 0, anchor=node.anchor) 100 | return data 101 | if delta: 102 | data._yaml['delta'] = delta 103 | tz = values['tz_sign'] + values['tz_hour'] 104 | if values['tz_minute']: 105 | tz += ':' + values['tz_minute'] 106 | data._yaml['tz'] = tz 107 | else: 108 | if values['tz']: # no delta 109 | data._yaml['tz'] = values['tz'] 110 | if values['t']: 111 | data._yaml['t'] = True 112 | return data 113 | 114 | ruamel.yaml.constructor.RoundTripConstructor.add_constructor('tag:yaml.org,2002:timestamp', construct_anchored_timestamp) 115 | 116 | def represent_anchored_timestamp(self, data: Any): 117 | """Render an AnchoredTimeStamp.""" 118 | try: 119 | anchor = data.yaml_anchor() 120 | except AttributeError: 121 | anchor = None 122 | inter = 'T' if data._yaml['t'] else ' ' 123 | _yaml = data._yaml 124 | if _yaml['delta']: 125 | data += _yaml['delta'] 126 | if isinstance(data, AnchoredDate): 127 | value = data.date().isoformat() 128 | else: 129 | value = data.isoformat(inter) 130 | if _yaml['tz']: 131 | value += _yaml['tz'] 132 | return self.represent_scalar('tag:yaml.org,2002:timestamp', value, anchor=anchor) 133 | 134 | ruamel.yaml.representer.RoundTripRepresenter.add_representer(AnchoredTimeStamp, represent_anchored_timestamp) 135 | ruamel.yaml.representer.RoundTripRepresenter.add_representer(AnchoredDate, represent_anchored_timestamp) 136 | -------------------------------------------------------------------------------- /yamlpath/wrappers/nodecoords.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement NodeCoords. 3 | 4 | Copyright 2020, 2021 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Any, List, Optional, Type 7 | 8 | from yamlpath.types import AncestryEntry, PathSegment 9 | from yamlpath import YAMLPath 10 | 11 | class NodeCoords: 12 | """ 13 | Wrap a node's data along with its relative coordinates within its DOM. 14 | 15 | A node's "coordinates" includes these properties: 16 | 1. Reference to the node itself, 17 | 2. Immediate parent node of the wrapped node, 18 | 3. Index or Key of the node within its immediate parent 19 | 20 | Additional, optional data can be wrapped along with the node's coordinates 21 | to facilitate other specific operations upon the node/DOM. See the 22 | `__init__` method for details. 23 | """ 24 | 25 | # pylint: disable=locally-disabled,too-many-arguments 26 | def __init__( 27 | self, node: Any, parent: Any, parentref: Any, 28 | path: Optional[YAMLPath] = None, 29 | ancestry: Optional[List[AncestryEntry]] = None, 30 | path_segment: Optional[PathSegment] = None 31 | ) -> None: 32 | """ 33 | Initialize a new NodeCoords. 34 | 35 | Positional Parameters: 36 | 1. node (Any) Reference to the ruamel.yaml DOM data element 37 | 2. parent (Any) Reference to `node`'s immediate DOM parent 38 | 3. parentref (Any) The `list` index or `dict` key which indicates where 39 | within `parent` the `node` is located 40 | 4. path (YAMLPath) The YAML Path for this node, as reported by its 41 | creator process 42 | 5. ancestry (List[AncestryEntry]) Stack of AncestryEntry (parent, 43 | parentref) tracking the hierarchical ancestry of this node through 44 | its parent document 45 | 6. path_segment (PathSegment) The YAML Path segment which most directly 46 | caused the generation of this NodeCoords 47 | 48 | Returns: N/A 49 | 50 | Raises: N/A 51 | """ 52 | self.node: Any = node 53 | self.parent: Any = parent 54 | self.parentref: Any = parentref 55 | self.path: Optional[YAMLPath] = path 56 | self.ancestry: List[AncestryEntry] = ([] 57 | if ancestry is None 58 | else ancestry) 59 | self.path_segment: Optional[PathSegment] = path_segment 60 | 61 | def __str__(self) -> str: 62 | """Get a String representation of this object.""" 63 | return str(self.node) 64 | 65 | def __repr__(self) -> str: 66 | """ 67 | Generate an eval()-safe representation of this object. 68 | 69 | Assumes all of the ruamel.yaml components are similarly safe. 70 | """ 71 | return ("{}('{}', '{}', '{}')".format( 72 | self.__class__.__name__, self.node, self.parent, 73 | self.parentref)) 74 | 75 | def __gt__(self, rhs: "NodeCoords") -> Any: 76 | """Indicate whether this node's data is greater-than another's.""" 77 | if self.node is None or rhs.node is None: 78 | return False 79 | return self.node > rhs.node 80 | 81 | def __lt__(self, rhs: "NodeCoords") -> Any: 82 | """Indicate whether this node's data is less-than another's.""" 83 | if self.node is None or rhs.node is None: 84 | return False 85 | return self.node < rhs.node 86 | 87 | @property 88 | def unwrapped_node(self) -> Any: 89 | """Unwrap the data, no matter how deeply nested it may be.""" 90 | return NodeCoords.unwrap_node_coords(self) 91 | 92 | @property 93 | def deepest_node_coord(self) -> "NodeCoords": 94 | """Get the deepest wrapped NodeCoord contained within.""" 95 | return NodeCoords._deepest_node_coord(self) 96 | 97 | def wraps_a(self, compare_type: Type) -> bool: 98 | """Indicate whether the wrapped node is of a given data-type.""" 99 | if compare_type is None: 100 | return self.unwrapped_node is None 101 | return isinstance(self.unwrapped_node, compare_type) 102 | 103 | @staticmethod 104 | def _deepest_node_coord(node: "NodeCoords") -> "NodeCoords": 105 | """Get the deepest nested NodeCoord.""" 106 | if (not isinstance(node, NodeCoords) 107 | or not isinstance(node.node, NodeCoords) 108 | ): 109 | return node 110 | 111 | return NodeCoords._deepest_node_coord(node.node) 112 | 113 | @staticmethod 114 | def unwrap_node_coords(data: Any) -> Any: 115 | """ 116 | Recursively strips all DOM tracking data off of a NodeCoords wrapper. 117 | 118 | Parameters: 119 | 1. data (Any) the source data to strip. 120 | 121 | Returns: (Any) the stripped data. 122 | """ 123 | if isinstance(data, NodeCoords): 124 | return NodeCoords.unwrap_node_coords(data.node) 125 | 126 | if isinstance(data, list): 127 | stripped_nodes = [] 128 | for ele in data: 129 | stripped_nodes.append(NodeCoords.unwrap_node_coords(ele)) 130 | return stripped_nodes 131 | 132 | return data 133 | -------------------------------------------------------------------------------- /yamlpath/enums/yamlvalueformats.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements the YAMLValueFormats enumeration. 3 | 4 | Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | import datetime 7 | from enum import Enum, auto 8 | from typing import Any, List 9 | 10 | from ruamel.yaml.scalarstring import ( 11 | PlainScalarString, 12 | DoubleQuotedScalarString, 13 | SingleQuotedScalarString, 14 | FoldedScalarString, 15 | LiteralScalarString, 16 | ) 17 | from ruamel.yaml.scalarbool import ScalarBoolean 18 | from ruamel.yaml.scalarfloat import ScalarFloat 19 | from ruamel.yaml.scalarint import ScalarInt 20 | from yamlpath.patches.timestamp import ( 21 | AnchoredTimeStamp, 22 | AnchoredDate, 23 | ) 24 | 25 | 26 | class YAMLValueFormats(Enum): 27 | """ 28 | Supported representation formats for YAML values. 29 | 30 | These include: 31 | 32 | `BARE` 33 | The value is written as-is, when possible, with neither demarcation nor 34 | reformatting. The YAML parser may convert the format to something else 35 | if it deems necessary. 36 | 37 | `BOOLEAN` 38 | The value is written as a bare True or False. 39 | 40 | `DATE` 41 | The value is written as a bare ISO8601 date without a time component. 42 | 43 | `DEFAULT` 44 | The value is written in whatever format is deemed most appropriate. 45 | 46 | `DQUOTE` 47 | The value is demarcated via quotation-marks ("). 48 | 49 | `FLOAT` 50 | The value is written as a bare floating-point decimal. 51 | 52 | `FOLDED` 53 | An otherwise long single-line string is written as a multi-line value 54 | which YAML data parsers can read back as the original single-line 55 | string. 56 | 57 | `INT` 58 | The value is written as a bare integer number with no fractional 59 | component. 60 | 61 | `LITERAL` 62 | A multi-line string is written as-is, preserving newline characters and 63 | any other white-space. 64 | 65 | `SQUOTE` 66 | The value is demarcated via apostrophes ('). 67 | 68 | `TIMESTAMP` 69 | The value is a timestamp per the supported syntax of ISO8601 by 70 | http://yaml.org/type/timestamp.html. 71 | """ 72 | 73 | BARE = auto() 74 | BOOLEAN = auto() 75 | DATE = auto() 76 | DEFAULT = auto() 77 | DQUOTE = auto() 78 | FLOAT = auto() 79 | FOLDED = auto() 80 | INT = auto() 81 | LITERAL = auto() 82 | SQUOTE = auto() 83 | TIMESTAMP = auto() 84 | 85 | @staticmethod 86 | def get_names() -> List[str]: 87 | """ 88 | Return all entry names for this enumeration. 89 | 90 | Parameters: N/A 91 | 92 | Returns: (List[str]) Upper-case names from this enumeration 93 | 94 | Raises: N/A 95 | """ 96 | return [entry.name.upper() for entry in YAMLValueFormats] 97 | 98 | @staticmethod 99 | def from_str(name: str) -> "YAMLValueFormats": 100 | """ 101 | Convert a string value to a value of this enumeration, if valid. 102 | 103 | Parameters: 104 | 1. name (str) The name to convert 105 | 106 | Returns: (YAMLValueFormats) the converted enumeration value 107 | 108 | Raises: 109 | - `NameError` when name doesn't match any enumeration values. 110 | """ 111 | check: str = str(name).upper() 112 | if check in YAMLValueFormats.get_names(): 113 | return YAMLValueFormats[check] 114 | raise NameError( 115 | "YAMLValueFormats has no such item: {}" 116 | .format(name)) 117 | 118 | @staticmethod 119 | def from_node(node: Any) -> "YAMLValueFormats": 120 | """ 121 | Identify the best matching enumeration value from a sample data node. 122 | 123 | Will return YAMLValueFormats.DEFAULT if the node is None or its best 124 | match cannot be determined. 125 | 126 | Parameters: 127 | 1. node (Any) The node to type 128 | 129 | Returns: (YAMLValueFormats) one of the enumerated values 130 | 131 | Raises: N/A 132 | """ 133 | best_type: YAMLValueFormats = YAMLValueFormats.DEFAULT 134 | if node is None: 135 | return best_type 136 | 137 | node_type: type = type(node) 138 | if node_type is FoldedScalarString: 139 | best_type = YAMLValueFormats.FOLDED 140 | elif node_type is LiteralScalarString: 141 | best_type = YAMLValueFormats.LITERAL 142 | elif node_type is DoubleQuotedScalarString: 143 | best_type = YAMLValueFormats.DQUOTE 144 | elif node_type is SingleQuotedScalarString: 145 | best_type = YAMLValueFormats.SQUOTE 146 | elif node_type is PlainScalarString: 147 | best_type = YAMLValueFormats.BARE 148 | elif node_type is ScalarBoolean: 149 | best_type = YAMLValueFormats.BOOLEAN 150 | elif node_type is ScalarFloat: 151 | best_type = YAMLValueFormats.FLOAT 152 | elif node_type is ScalarInt: 153 | best_type = YAMLValueFormats.INT 154 | elif node_type is AnchoredDate or node_type is datetime.date: 155 | best_type = YAMLValueFormats.DATE 156 | elif node_type is AnchoredTimeStamp or node_type is datetime.datetime: 157 | best_type = YAMLValueFormats.TIMESTAMP 158 | 159 | return best_type 160 | -------------------------------------------------------------------------------- /tests/test_common_parsers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | import datetime as dt 4 | 5 | import ruamel.yaml as ry 6 | from ruamel.yaml import version_info as ryversion 7 | if ryversion < (0, 17, 22): # pragma: no cover 8 | from yamlpath.patches.timestamp import ( 9 | AnchoredTimeStamp, 10 | AnchoredDate, 11 | ) # type: ignore 12 | else: # pragma: no cover 13 | # Temporarily fool MYPY into resolving the future-case imports 14 | from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp 15 | AnchoredDate = AnchoredTimeStamp 16 | #from ruamel.yaml.timestamp import AnchoredTimeStamp 17 | # From whence shall come AnchoredDate? 18 | 19 | from yamlpath.enums import YAMLValueFormats 20 | from yamlpath.common import Parsers 21 | 22 | class Test_common_parsers(): 23 | """Tests for the Parsers helper class.""" 24 | 25 | ### 26 | # get_yaml_data (literal=True) 27 | ### 28 | def test_get_yaml_data_literally(self, quiet_logger): 29 | serialized_yaml = """--- 30 | hash: 31 | key: value 32 | 33 | list: 34 | - ichi 35 | - ni 36 | - san 37 | """ 38 | yaml = Parsers.get_yaml_editor() 39 | (data, loaded) = Parsers.get_yaml_data( 40 | yaml, quiet_logger, serialized_yaml, 41 | literal=True) 42 | assert loaded == True 43 | assert data["hash"]["key"] == "value" 44 | assert data["list"][0] == "ichi" 45 | assert data["list"][1] == "ni" 46 | assert data["list"][2] == "san" 47 | 48 | ### 49 | # get_yaml_multidoc_data (literal=True) 50 | ### 51 | def test_get_yaml_multidoc_data_literally(self, quiet_logger): 52 | serialized_yaml = """--- 53 | document: 1st 54 | has: data 55 | ... 56 | --- 57 | document: 2nd 58 | has: different data 59 | """ 60 | yaml = Parsers.get_yaml_editor() 61 | doc_id = 0 62 | for (data, loaded) in Parsers.get_yaml_multidoc_data( 63 | yaml, quiet_logger, serialized_yaml, 64 | literal=True): 65 | assert loaded == True 66 | if doc_id == 0: 67 | document = "1st" 68 | has = "data" 69 | else: 70 | document= "2nd" 71 | has = "different data" 72 | doc_id = doc_id + 1 73 | 74 | assert data["document"] == document 75 | assert data["has"] == has 76 | 77 | ### 78 | # stringify_dates 79 | ### 80 | def test_stringify_complex_data_with_dates(self): 81 | cdata = ry.comments.CommentedMap({ 82 | "dates": ry.comments.CommentedSeq([ 83 | dt.date(2020, 10, 31), 84 | dt.date(2020, 11, 3) 85 | ]) 86 | }) 87 | sdata = Parsers.stringify_dates(cdata) 88 | assert sdata["dates"][0] == "2020-10-31" 89 | assert sdata["dates"][1] == "2020-11-03" 90 | 91 | ### 92 | # jsonify_yaml_data 93 | ### 94 | def test_jsonify_complex_ruamel_data(self): 95 | tagged_tag = "!tagged" 96 | tagged_value = "tagged value" 97 | tagged_scalar = ry.scalarstring.PlainScalarString(tagged_value) 98 | tagged_node = ry.comments.TaggedScalar(tagged_scalar, tag=tagged_tag) 99 | 100 | null_tag = "!null" 101 | null_value = None 102 | null_node = ry.comments.TaggedScalar(None, tag=null_tag) 103 | 104 | cdata = ry.comments.CommentedMap({ 105 | "tagged": tagged_node, 106 | "null": null_node, 107 | "dates": ry.comments.CommentedSeq([ 108 | dt.date(2020, 10, 31), 109 | dt.date(2020, 11, 3), 110 | AnchoredDate(2020, 12, 1), 111 | AnchoredTimeStamp(2021, 1, 13, 1, 2, 3) 112 | ]), 113 | "t_bool": ry.scalarbool.ScalarBoolean(1), 114 | "f_bool": ry.scalarbool.ScalarBoolean(0) 115 | }) 116 | jdata = Parsers.jsonify_yaml_data(cdata) 117 | assert jdata["tagged"] == tagged_value 118 | assert jdata["null"] == null_value 119 | assert jdata["dates"][0] == "2020-10-31" 120 | assert jdata["dates"][1] == "2020-11-03" 121 | assert jdata["dates"][2] == "2020-12-01" 122 | assert jdata["dates"][3] == "2021-01-13T01:02:03" 123 | assert jdata["t_bool"] == 1 124 | assert jdata["f_bool"] == 0 125 | 126 | jstr = json.dumps(jdata) 127 | assert jstr == """{"tagged": "tagged value", "null": null, "dates": ["2020-10-31", "2020-11-03", "2020-12-01", "2021-01-13T01:02:03"], "t_bool": true, "f_bool": false}""" 128 | 129 | def test_jsonify_complex_python_data(self): 130 | cdata = { 131 | "dates": [ 132 | dt.date(2020, 10, 31), 133 | dt.date(2020, 11, 3) 134 | ], 135 | "bytes": b"abc", 136 | "t_bool": True, 137 | "f_bool": False 138 | } 139 | jdata = Parsers.jsonify_yaml_data(cdata) 140 | assert jdata["dates"][0] == "2020-10-31" 141 | assert jdata["dates"][1] == "2020-11-03" 142 | assert jdata["t_bool"] == True 143 | assert jdata["f_bool"] == False 144 | 145 | jstr = json.dumps(jdata) 146 | assert jstr == """{"dates": ["2020-10-31", "2020-11-03"], "bytes": "b'abc'", "t_bool": true, "f_bool": false}""" 147 | -------------------------------------------------------------------------------- /yamlpath/commands/yaml_validate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Validate YAML/JSON/Compatible data. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | import sys 7 | import argparse 8 | 9 | from yamlpath import __version__ as YAMLPATH_VERSION 10 | from yamlpath.common import Parsers 11 | from yamlpath.wrappers import ConsolePrinter 12 | 13 | class LogErrorCap: 14 | """Capture only ERROR messages as a fake ConsolePrinter.""" 15 | 16 | def __init__(self): 17 | """Initialize this class instance.""" 18 | self.lines = [] 19 | def info(self, message): 20 | """Discard INFO messages.""" 21 | def verbose(self, message): 22 | """Discard verbose INFO messages.""" 23 | def warning(self, message): 24 | """Discard WARNING messages.""" 25 | # pylint: disable=unused-argument 26 | def error(self, message, *args): 27 | """Capture ERROR messages.""" 28 | self.lines.append(message) 29 | # pylint: disable=unused-argument 30 | def critical(self, message, *args): 31 | """Discard critical ERROR messages.""" 32 | def debug(self, message, **kwargs): 33 | """Discard DEBUG messages.""" 34 | 35 | def processcli(): 36 | """Process command-line arguments.""" 37 | parser = argparse.ArgumentParser( 38 | description="Validate YAML, JSON, and compatible files.", 39 | epilog=( 40 | "Except when suppressing all report output with --quiet|-q," 41 | " validation issues are printed to STDOUT (not STDERR). Further," 42 | " the exit-state will report 0 when there are no issues, 1 when" 43 | " there is an issue with the supplied command-line arguments, or 2" 44 | " when validation has failed for any document. To report issues" 45 | " with this tool or to request enhancements, please visit" 46 | " https://github.com/wwkimball/yamlpath/issues.") 47 | ) 48 | parser.add_argument("-V", "--version", action="version", 49 | version="%(prog)s " + YAMLPATH_VERSION) 50 | 51 | parser.add_argument( 52 | "-S", "--nostdin", action="store_true", 53 | help=( 54 | "Do not implicitly read from STDIN, even when there are\n" 55 | "no - pseudo-files in YAML_FILEs with a non-TTY session")) 56 | 57 | noise_group = parser.add_mutually_exclusive_group() 58 | noise_group.add_argument( 59 | "-d", "--debug", 60 | action="store_true", 61 | help="output debugging details") 62 | noise_group.add_argument( 63 | "-v", "--verbose", 64 | action="store_true", 65 | help="increase output verbosity (show valid documents)") 66 | noise_group.add_argument( 67 | "-q", "--quiet", 68 | action="store_true", 69 | help="suppress all output except system errors") 70 | 71 | parser.add_argument("yaml_files", metavar="YAML_FILE", nargs="*", 72 | help="one or more single- or multi-document" 73 | " YAML/JSON/compatible files to validate; omit or use" 74 | " - to read from STDIN") 75 | 76 | return parser.parse_args() 77 | 78 | def validateargs(args, log): 79 | """Validate command-line arguments.""" 80 | has_errors = False 81 | 82 | # There must be at least one input file or stream 83 | input_file_count = len(args.yaml_files) 84 | if (input_file_count == 0 and ( 85 | sys.stdin.isatty() 86 | or args.nostdin) 87 | ): 88 | has_errors = True 89 | log.error( 90 | "There must be at least one YAML_FILE or STDIN document.") 91 | 92 | # There can be only one - 93 | pseudofile_count = 0 94 | for infile in args.yaml_files: 95 | if infile.strip() == '-': 96 | pseudofile_count += 1 97 | if pseudofile_count > 1: 98 | has_errors = True 99 | log.error("Only one YAML_FILE may be the - pseudo-file.") 100 | 101 | if has_errors: 102 | sys.exit(1) 103 | 104 | def process_file(log, yaml, yaml_file): 105 | """Process a (potentially multi-doc) YAML file.""" 106 | logcap = LogErrorCap() 107 | subdoc_index = 0 108 | exit_state = 0 109 | file_name = "STDIN" if yaml_file.strip() == "-" else yaml_file 110 | for (_, doc_loaded) in Parsers.get_yaml_multidoc_data( 111 | yaml, logcap, yaml_file 112 | ): 113 | if doc_loaded: 114 | log.verbose("{}/{} is valid.".format(file_name, subdoc_index)) 115 | else: 116 | # An error message has been captured 117 | exit_state = 2 118 | log.info( 119 | "{}/{} is invalid due to:".format(file_name, subdoc_index)) 120 | for line in logcap.lines: 121 | log.info(" * {}".format(line)) 122 | logcap.lines.clear() 123 | subdoc_index += 1 124 | 125 | return exit_state 126 | 127 | def main(): 128 | """Perform the work specified via CLI arguments and exit. 129 | 130 | Main code. 131 | """ 132 | # Process any command-line arguments 133 | args = processcli() 134 | log = ConsolePrinter(args) 135 | validateargs(args, log) 136 | exit_state = 0 137 | consumed_stdin = False 138 | yaml = Parsers.get_yaml_editor() 139 | 140 | for yaml_file in args.yaml_files: 141 | if yaml_file.strip() == '-': 142 | consumed_stdin = True 143 | 144 | log.debug( 145 | "yaml_merge::main: Processing file, {}".format( 146 | "STDIN" if yaml_file.strip() == "-" else yaml_file)) 147 | 148 | proc_state = process_file(log, yaml, yaml_file) 149 | 150 | if proc_state != 0: 151 | exit_state = proc_state 152 | 153 | # Check for a waiting STDIN document 154 | if (exit_state == 0 155 | and not consumed_stdin 156 | and not args.nostdin 157 | and not sys.stdin.isatty() 158 | ): 159 | exit_state = process_file(log, yaml, "-") 160 | 161 | sys.exit(exit_state) 162 | 163 | if __name__ == "__main__": 164 | main() # pragma: no cover 165 | -------------------------------------------------------------------------------- /tests/test_common_searches.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ruamel.yaml as ry 4 | 5 | from yamlpath.enums import AnchorMatches, PathSearchMethods 6 | from yamlpath.path import SearchTerms 7 | from yamlpath.common import Searches 8 | 9 | class Test_common_searches(): 10 | """Tests for the Searches helper class.""" 11 | 12 | ### 13 | # search_matches 14 | ### 15 | @pytest.mark.parametrize("match, method, needle, haystack", [ 16 | (True, PathSearchMethods.CONTAINS, "a", "parents"), 17 | (True, PathSearchMethods.ENDS_WITH, "ts", "parents"), 18 | (True, PathSearchMethods.EQUALS, "parents", "parents"), 19 | (True, PathSearchMethods.EQUALS, 42, 42), 20 | (True, PathSearchMethods.EQUALS, "42", 42), 21 | (True, PathSearchMethods.EQUALS, 3.14159265385, 3.14159265385), 22 | (True, PathSearchMethods.EQUALS, "3.14159265385", 3.14159265385), 23 | (True, PathSearchMethods.EQUALS, True, True), 24 | (True, PathSearchMethods.EQUALS, "True", True), 25 | (True, PathSearchMethods.EQUALS, "true", True), 26 | (True, PathSearchMethods.EQUALS, False, False), 27 | (True, PathSearchMethods.EQUALS, "False", False), 28 | (True, PathSearchMethods.EQUALS, "false", False), 29 | (True, PathSearchMethods.GREATER_THAN, 2, 4), 30 | (True, PathSearchMethods.GREATER_THAN, "2", 4), 31 | (True, PathSearchMethods.GREATER_THAN, 2, "4"), 32 | (True, PathSearchMethods.GREATER_THAN, "2", "4"), 33 | (True, PathSearchMethods.GREATER_THAN, 2.1, 2.2), 34 | (True, PathSearchMethods.GREATER_THAN, "2.1", 2.2), 35 | (True, PathSearchMethods.GREATER_THAN, 2.1, "2.2"), 36 | (True, PathSearchMethods.GREATER_THAN, "2.1", "2.2"), 37 | (True, PathSearchMethods.GREATER_THAN, 2, 2.1), 38 | (True, PathSearchMethods.GREATER_THAN, "2", 2.1), 39 | (True, PathSearchMethods.GREATER_THAN, 2, "2.1"), 40 | (True, PathSearchMethods.GREATER_THAN, "2", "2.1"), 41 | (True, PathSearchMethods.GREATER_THAN, 2.9, 3), 42 | (True, PathSearchMethods.GREATER_THAN, "2.9", 3), 43 | (True, PathSearchMethods.GREATER_THAN, 2.9, "3"), 44 | (True, PathSearchMethods.GREATER_THAN, "2.9", "3"), 45 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2, 4), 46 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2", 4), 47 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2, "4"), 48 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2", "4"), 49 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2.1, 2.2), 50 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2.1", 2.2), 51 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2.1, "2.2"), 52 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2.1", "2.2"), 53 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2, 2.1), 54 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2", 2.1), 55 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2, "2.1"), 56 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2", "2.1"), 57 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2.9, 3), 58 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2.9", 3), 59 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, 2.9, "3"), 60 | (True, PathSearchMethods.GREATER_THAN_OR_EQUAL, "2.9", "3"), 61 | (True, PathSearchMethods.LESS_THAN, 4, 2), 62 | (True, PathSearchMethods.LESS_THAN, "4", 2), 63 | (True, PathSearchMethods.LESS_THAN, 4, "2"), 64 | (True, PathSearchMethods.LESS_THAN, "4", "2"), 65 | (True, PathSearchMethods.LESS_THAN, 4.2, 4.1), 66 | (True, PathSearchMethods.LESS_THAN, "4.2", 4.1), 67 | (True, PathSearchMethods.LESS_THAN, 4.2, "4.1"), 68 | (True, PathSearchMethods.LESS_THAN, "4.2", "4.1"), 69 | (True, PathSearchMethods.LESS_THAN, 4.2, 4), 70 | (True, PathSearchMethods.LESS_THAN, "4.2", 4), 71 | (True, PathSearchMethods.LESS_THAN, 4.2, "4"), 72 | (True, PathSearchMethods.LESS_THAN, "4.2", "4"), 73 | (True, PathSearchMethods.LESS_THAN, 4, 3.9), 74 | (True, PathSearchMethods.LESS_THAN, "4", 3.9), 75 | (True, PathSearchMethods.LESS_THAN, 4, "3.9"), 76 | (True, PathSearchMethods.LESS_THAN, "4", "3.9"), 77 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4, 2), 78 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4", 2), 79 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4, "2"), 80 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4", "2"), 81 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4.2, 4.1), 82 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4.2", 4.1), 83 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4.2, "4.1"), 84 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4.2", "4.1"), 85 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4.2, 4), 86 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4.2", 4), 87 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4.2, "4"), 88 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4.2", "4"), 89 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4, 3.9), 90 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4", 3.9), 91 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, 4, "3.9"), 92 | (True, PathSearchMethods.LESS_THAN_OR_EQUAL, "4", "3.9"), 93 | (True, PathSearchMethods.REGEX, ".+", "a"), 94 | (True, PathSearchMethods.STARTS_WITH, "p", "parents") 95 | ]) 96 | def test_search_matches(self, match, method, needle, haystack): 97 | assert match == Searches.search_matches(method, needle, haystack) 98 | 99 | ### 100 | # search_anchor 101 | ### 102 | def test_search_anchor(self): 103 | anchor_value = "anchor_name" 104 | node = ry.scalarstring.PlainScalarString("anchored value", anchor=anchor_value) 105 | terms = SearchTerms(False, PathSearchMethods.CONTAINS, ".", "name") 106 | seen_anchors = [] 107 | search_anchors = True 108 | include_aliases = True 109 | assert Searches.search_anchor(node, terms, seen_anchors, search_anchors=search_anchors, include_aliases=include_aliases) == AnchorMatches.MATCH 110 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Define reusable pytest fixtures.""" 2 | import tempfile 3 | from subprocess import run 4 | from types import SimpleNamespace 5 | 6 | import pytest 7 | 8 | from yamlpath.wrappers import ConsolePrinter 9 | from yamlpath.eyaml import EYAMLProcessor 10 | 11 | # Implied constants 12 | EYAML_PRIVATE_KEY_FILENAME = "private_key.pkcs7.pem" 13 | EYAML_PUBLIC_KEY_FILENAME = "public_key.pkcs7.pem" 14 | 15 | # pylint: disable=locally-disabled,invalid-name 16 | requireseyaml = pytest.mark.skipif( 17 | EYAMLProcessor.get_eyaml_executable("eyaml") is None 18 | , reason="The 'eyaml' command must be installed and accessible on the PATH" 19 | + " to test and use EYAML features. Try: 'gem install hiera-eyaml'" 20 | + " after intalling ruby and rubygems." 21 | ) 22 | 23 | @pytest.fixture 24 | def quiet_logger(): 25 | """Returns a quiet ConsolePrinter.""" 26 | args = SimpleNamespace(verbose=False, quiet=True, debug=False) 27 | return ConsolePrinter(args) 28 | 29 | @pytest.fixture 30 | def info_warn_logger(): 31 | """Returns a quiet ConsolePrinter.""" 32 | args = SimpleNamespace(verbose=False, quiet=False, debug=False) 33 | return ConsolePrinter(args) 34 | 35 | @pytest.fixture(scope="session") 36 | def old_eyaml_keys(tmp_path_factory): 37 | """Creates temporary keys for encryption/decryption tests.""" 38 | old_key_path_name = "old-keys" 39 | old_key_dir = tmp_path_factory.mktemp(old_key_path_name) 40 | old_private_key_file = old_key_dir / EYAML_PRIVATE_KEY_FILENAME 41 | old_public_key_file = old_key_dir / EYAML_PUBLIC_KEY_FILENAME 42 | 43 | old_private_key = r"""-----BEGIN RSA PRIVATE KEY----- 44 | MIIEpAIBAAKCAQEA1BuytnsdHdt6NkNfLoGJIlf9hrWux8raPP3W57cONh2MrQ6d 45 | aoAX+L+igTSjvSTI6oxsO0dqdYXZO1+rOK3gI9OnZQhkCjq9IRoWx7AIvM7skaD0 46 | Lne9YsvA7mGY/z9lm3IALI6OBVV5k6xnBR2PVi6A7FnDm0CRLit2Bn9eHLN3k4oL 47 | S/ynxgXBmWWgnKtJNJwGmeD5PwzJfXCcJ3kPItiktFizJZoPmAlBP7LIzamlfSXV 48 | VoniRs45aGrTGpmZSdvossL41KBCYJGjP+lIL/UpDJHBeiuqVQoDl4/UZqb5xF9S 49 | C2p2Of21fmZmj4uUAT5FPtKMKCspmLWQeUEfiwIDAQABAoIBAEyXR9wu7p+mbiYE 50 | A+22Jr+5CDpJhrhsXovhmWWIq2ANIYwoF92qLX3MLTD8whd9nfNcC4UIT7/qOjv/ 51 | WsOXvbUSK4MHGaC7/ylh01H+Fdmf2rrnZOUWpdN0AdHSej3JNbaA3uE4BL6WU9Vo 52 | TrcBKo4TMsilzUVVdlc2qGLGQUSZPLnIJWMLQIdCe2kZ9YvUlGloD4uGT3RH6+vH 53 | TOtXqDgLGS/79K0rnflnBsUBkXpukxzOcTRHxR0s7SJ2XCB0JfdLWfR6X1nzM4mh 54 | rn/m2nzEOG9ICe5hoHqAEZ/ezKd/jwxMg1YMZnGAzDMw7/UPWo87wgVdxxOHOsHG 55 | v/pK+okCgYEA/SToT82qtvWIiUTbkdDislGhTet2eEu2Bi9wCxAUQn045Y812r9d 56 | TvJyfKJyvvpxzejaecJb8oOXyypMDay7aPOVBI1E2OqfImxF8pJ0QqejAUCinXrj 57 | KnV72L/hjTavivWq1vHZYXSxufAMG0C7UeztwkOfk85N3wuuYYWYrc0CgYEA1oBG 58 | 2fQ0PXDyionE3c4bpRGZMJxD+3RFRMCJiE+xheRR22ObSDLUH123ZGmU0m2FTS9O 59 | M+GJbZgdV1Oe0EJ5rWfzFYUmVJIreH+oQWaY/HMkoe705LAMcPyd0bSjRVWWiz+l 60 | anIGjj5HaPSI7XFqdQu7j3ww67k4BBAca8++arcCgYA/cIhnt3sY7t+QxxjfqiGl 61 | 3p82D9RYwWCUnD7QBu+M2iTwIru0XlDcABaA9ZUcF1d96uUVroesdx4LZEY7BxbQ 62 | bnrh8SVX1zSaQ9gjumA4dBp9rd0S6kET2u12nF/CK/pCMN7njySTL9N6bZYbHlXT 63 | ajULgjbzq7gINb01420n4QKBgQCqu0epy9qY3QHwi2ALPDZ82NkZ/AeQaieIZcgS 64 | m3wtmmIdQdcjTHHS1YFXh0JRi6MCoJiaavY8KUuRapmKIp8/CvJNOsIbpoy7SMDf 65 | 7Y3vwqZxzgVW0VnVxPzJIgKi+VDuXSaI52GYbrHgNGOYuyGFMGWF+8/kkHSppzk4 66 | Bw8FWQKBgQCo/7cV19n3e7ZlZ/aGhIOtSCTopMBV8/9PIw+s+ZDiQ7vRSj9DwkAQ 67 | +x97V0idgh16tnly0xKvGCGTQR7qDsDTjHmmF4LZUGjcq7pHsTi/umCM/toE+BCk 68 | 7ayr+G0DWr5FjhQ7uCt2Rz1NKcj6EkDcM1WZxkDLAvBXjlj+T+eqtQ== 69 | -----END RSA PRIVATE KEY----- 70 | """ 71 | old_public_key = r"""-----BEGIN CERTIFICATE----- 72 | MIIC2TCCAcGgAwIBAgIBATANBgkqhkiG9w0BAQsFADAAMCAXDTE5MDUwNzE4MDAw 73 | NVoYDzIwNjkwNDI0MTgwMDA1WjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 74 | CgKCAQEA1BuytnsdHdt6NkNfLoGJIlf9hrWux8raPP3W57cONh2MrQ6daoAX+L+i 75 | gTSjvSTI6oxsO0dqdYXZO1+rOK3gI9OnZQhkCjq9IRoWx7AIvM7skaD0Lne9YsvA 76 | 7mGY/z9lm3IALI6OBVV5k6xnBR2PVi6A7FnDm0CRLit2Bn9eHLN3k4oLS/ynxgXB 77 | mWWgnKtJNJwGmeD5PwzJfXCcJ3kPItiktFizJZoPmAlBP7LIzamlfSXVVoniRs45 78 | aGrTGpmZSdvossL41KBCYJGjP+lIL/UpDJHBeiuqVQoDl4/UZqb5xF9SC2p2Of21 79 | fmZmj4uUAT5FPtKMKCspmLWQeUEfiwIDAQABo1wwWjAPBgNVHRMBAf8EBTADAQH/ 80 | MB0GA1UdDgQWBBTUHb3HX8dBfYFL1J1sCv+uCum5AzAoBgNVHSMEITAfgBTUHb3H 81 | X8dBfYFL1J1sCv+uCum5A6EEpAIwAIIBATANBgkqhkiG9w0BAQsFAAOCAQEAcw+0 82 | dfHSLNAZD95G2pDnT2qShjmdLdbrDQhAXWhLeoWpXsKvC0iUyQaOF9ckl++tHM2g 83 | ejm1vEOrZ+1uXK3qnMXPF99Wet686OhyoDt262Mt3wzGHNijAHEvQtjap8ZIwfOM 84 | zFTvjmOlUScqF/Yg+htcGnJdQhWIrsD+exiY5Kz2IMtuW+yWLLP8bY5vPg6qfrp2 85 | 4VVJ3Md1gdSownd1Au5tqPXm6VfSgLiCm9iDPVsjDII9h8ydate1d2TBHPup+4tN 86 | JZ5/muctimydC+S2oCn7ucsilxZD89N7rJjKXNfoUOGHjOEVQMa8RtZLzH2sUEaS 87 | FktE6rH8a+8SwO+TGw== 88 | -----END CERTIFICATE----- 89 | """ 90 | 91 | with open(old_private_key_file, 'w') as key_file: 92 | key_file.write(old_private_key) 93 | 94 | with open(old_public_key_file, 'w') as key_file: 95 | key_file.write(old_public_key) 96 | 97 | return (old_private_key_file, old_public_key_file) 98 | 99 | @pytest.fixture(scope="session") 100 | def new_eyaml_keys(tmp_path_factory): 101 | """Creates temporary keys for encryption/decryption tests.""" 102 | 103 | new_key_path_name = "new-keys" 104 | new_key_dir = tmp_path_factory.mktemp(new_key_path_name) 105 | new_private_key_file = new_key_dir / EYAML_PRIVATE_KEY_FILENAME 106 | new_public_key_file = new_key_dir / EYAML_PUBLIC_KEY_FILENAME 107 | 108 | run( 109 | "{} createkeys --pkcs7-private-key={} --pkcs7-public-key={}" 110 | .format( 111 | EYAMLProcessor.get_eyaml_executable("eyaml"), 112 | new_private_key_file, 113 | new_public_key_file 114 | ) 115 | .split(), 116 | check=True 117 | ) 118 | 119 | return (new_private_key_file, new_public_key_file) 120 | 121 | def create_temp_yaml_file(tmp_path_factory, content): 122 | """Creates a test YAML input file.""" 123 | fhnd = tempfile.NamedTemporaryFile(mode='w', 124 | dir=tmp_path_factory.getbasetemp(), 125 | suffix='.yaml', 126 | delete=False) 127 | fhnd.write(content) 128 | return fhnd.name 129 | 130 | @pytest.fixture(scope="session") 131 | def imparsible_yaml_file(tmp_path_factory): 132 | """ 133 | Creates a YAML file that causes a ParserError when read by ruamel.yaml. 134 | """ 135 | content = '''{"json": "is YAML", "but_bad_json": "isn't anything!"''' 136 | return create_temp_yaml_file(tmp_path_factory, content) 137 | 138 | @pytest.fixture(scope="session") 139 | def badsyntax_yaml_file(tmp_path_factory): 140 | """ 141 | Creates a YAML file that causes a ScannerError when read by ruamel.yaml. 142 | """ 143 | content = """--- 144 | # This YAML content contains a critical syntax error 145 | & bad_anchor: is bad 146 | """ 147 | return create_temp_yaml_file(tmp_path_factory, content) 148 | 149 | @pytest.fixture(scope="session") 150 | def badcmp_yaml_file(tmp_path_factory): 151 | """ 152 | Creates a YAML file that causes a ComposerError when read by ruamel.yaml. 153 | """ 154 | content = """--- 155 | # This YAML file is improperly composed 156 | this is a parsing error: *no such capability 157 | """ 158 | return create_temp_yaml_file(tmp_path_factory, content) 159 | -------------------------------------------------------------------------------- /yamlpath/differ/diffentry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement DiffEntry. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | import json 7 | from typing import Any 8 | 9 | from ruamel.yaml.comments import CommentedBase, TaggedScalar 10 | 11 | from yamlpath.common import Parsers 12 | from yamlpath.enums import PathSeparators 13 | from yamlpath import YAMLPath 14 | from .enums.diffactions import DiffActions 15 | 16 | 17 | class DiffEntry: 18 | """One entry of a diff.""" 19 | 20 | def __init__( 21 | self, action: DiffActions, path: YAMLPath, lhs: Any, rhs: Any, 22 | **kwargs 23 | ): 24 | """ 25 | Instantiate a new DiffEntry. 26 | 27 | Parameters: 28 | 1. action (DiffAction) The action taken for one document to become the 29 | next 30 | 2. path (YAMLPath) Location within the LHS document which changes to 31 | becomes the RHS document 32 | 3. lhs (Any) The Left-Hand-Side (original) document 33 | 4. rhs (Any) The Right-Hand-Side (altered) document 34 | 35 | Keyword Arguments: 36 | * lhs_iteration (Any) "Rough" position of the original element within 37 | its document before it was changed 38 | * lhs_parent (Any) Parent of the original data element 39 | * rhs_iteration (Any) "Rough" position of the changed element within 40 | its document, if it existed before the change (otherwise it'll be 0s) 41 | * rhs_parent (Any) Parent of the changed data element 42 | 43 | Returns: N/A 44 | """ 45 | self._action: DiffActions = action 46 | self._path: YAMLPath = path 47 | self._lhs: Any = lhs 48 | self._rhs: Any = rhs 49 | self._key_tag = kwargs.pop("key_tag", None) 50 | self._set_index(lhs, rhs, **kwargs) 51 | self._verbose = False 52 | 53 | def _set_index(self, lhs: Any, rhs: Any, **kwargs) -> None: 54 | """ 55 | Build the sortable index for this entry. 56 | 57 | Parameters: 58 | 1. lhs (Any) The Left-Hand-Side (original) document 59 | 2. rhs (Any) The Right-Hand-Side (altered) document 60 | 61 | Keyword Arguments: 62 | * lhs_iteration (Any) "Rough" position of the original element within 63 | its document before it was changed 64 | * lhs_parent (Any) Parent of the original data element 65 | * rhs_iteration (Any) "Rough" position of the changed element within 66 | its document, if it existed before the change (otherwise it'll be 0s) 67 | * rhs_parent (Any) Parent of the changed data element 68 | 69 | Returns: N/A 70 | """ 71 | lhs_lc = DiffEntry._get_index(lhs, kwargs.pop("lhs_parent", None)) 72 | rhs_lc = DiffEntry._get_index(rhs, kwargs.pop("rhs_parent", None)) 73 | lhs_iteration = kwargs.pop("lhs_iteration", 0) 74 | rhs_iteration = kwargs.pop("rhs_iteration", 0) 75 | lhs_iteration = 0 if lhs_iteration is None else lhs_iteration 76 | rhs_iteration = 0 if rhs_iteration is None else rhs_iteration 77 | lhs_line = float(lhs_lc) 78 | if lhs_line == 0.0 or self.action is DiffActions.ADD: 79 | lhs_lc, rhs_lc = rhs_lc, lhs_lc 80 | self._index = "{}.{}.{}.{}".format( 81 | lhs_lc, lhs_iteration, rhs_lc, rhs_iteration) 82 | 83 | def __str__(self) -> str: 84 | """Get the string representation of this object.""" 85 | diffaction = self._action 86 | path = self._path if self._path else "-" 87 | key_tag = "" 88 | if self._key_tag: 89 | key_tag = " {}".format(self._key_tag) 90 | output = "{}{} {}{}\n".format( 91 | diffaction, self._index if self.verbose else "", path, key_tag) 92 | if diffaction is DiffActions.ADD: 93 | output += DiffEntry._present_data(self._rhs, ">") 94 | elif diffaction is DiffActions.CHANGE: 95 | output += "{}\n---\n{}".format( 96 | DiffEntry._present_data(self._lhs, "<"), 97 | DiffEntry._present_data(self._rhs, ">")) 98 | elif diffaction is DiffActions.DELETE: 99 | output += "{}".format(DiffEntry._present_data(self._lhs, "<")) 100 | else: 101 | output += "{}".format(DiffEntry._present_data(self._lhs, "=")) 102 | return output 103 | 104 | @property 105 | def action(self) -> DiffActions: 106 | """Get the action of this difference (read-only).""" 107 | return self._action 108 | 109 | @property 110 | def path(self) -> YAMLPath: 111 | """Get the YAML Path of this difference (read-only).""" 112 | return self._path 113 | 114 | @property 115 | def lhs(self) -> Any: 116 | """Get the LHS value of this difference (read-only).""" 117 | return self._lhs 118 | 119 | @property 120 | def index(self) -> str: 121 | """Get the sortable index for this entry (read-only).""" 122 | return self._index 123 | 124 | @property 125 | def pathsep(self) -> PathSeparators: 126 | """Separator used to delimit reported YAML Paths (accessor).""" 127 | return self._path.separator 128 | 129 | @pathsep.setter 130 | def pathsep(self, value: PathSeparators) -> None: 131 | """Separator used to delimit reported YAML Paths (mutator).""" 132 | # No unnecessary changes 133 | if value is not self.pathsep: 134 | self._path.separator = value 135 | 136 | @property 137 | def verbose(self) -> bool: 138 | """Output verbosity (accessor).""" 139 | return self._verbose 140 | 141 | @verbose.setter 142 | def verbose(self, value: bool) -> None: 143 | """Output verbosity (mutator).""" 144 | # No unnecessary changes 145 | if value != self.verbose: 146 | self._verbose = value 147 | 148 | @classmethod 149 | def _get_lc(cls, data: Any) -> str: 150 | """Get the line.column of a data element.""" 151 | data_lc = "0.0" 152 | if isinstance(data, CommentedBase): 153 | dlc = data.lc 154 | data_lc = "{}.{}".format( 155 | dlc.line if dlc.line is not None else 0, 156 | dlc.col if dlc.col is not None else 0 157 | ) 158 | return data_lc 159 | 160 | @classmethod 161 | def _get_index(cls, data: Any, parent: Any) -> str: 162 | """Get the document index of a data element.""" 163 | data_lc = DiffEntry._get_lc(data) 164 | if data_lc == "0.0": 165 | data_lc = DiffEntry._get_lc(parent) 166 | return data_lc 167 | 168 | @classmethod 169 | def _present_data(cls, data: Any, prefix: str) -> str: 170 | """Stringify data.""" 171 | json_safe_data = Parsers.jsonify_yaml_data(data) 172 | formatted_data = json_safe_data 173 | if isinstance(json_safe_data, str): 174 | formatted_data = json_safe_data.strip() 175 | json_data = json.dumps( 176 | formatted_data).replace("\\n", "\n{} ".format(prefix)) 177 | data_tag = "" 178 | if isinstance(data, TaggedScalar) and data.tag.value: 179 | data_tag = "{} ".format(data.tag.value) 180 | return "{} {}{}".format(prefix, data_tag, json_data) 181 | -------------------------------------------------------------------------------- /yamlpath/common/anchors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement Anchors, a static library of generally-useful code for YAML Anchors. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | from typing import Any, Dict, Optional 7 | 8 | from ruamel.yaml.comments import CommentedSeq, CommentedMap 9 | 10 | from yamlpath.wrappers import NodeCoords 11 | 12 | 13 | class Anchors: 14 | """Helper methods for common YAML Anchor operations.""" 15 | 16 | @staticmethod 17 | def scan_for_anchors(dom: Any, anchors: Dict[str, Any]): 18 | """ 19 | Scan a document for all anchors contained within. 20 | 21 | Parameters: 22 | 1. dom (Any) The document to scan. 23 | 2. anchors (dict) Collection of discovered anchors along with 24 | references to the nodes they apply to. 25 | 26 | Returns: N/A 27 | """ 28 | if isinstance(dom, CommentedMap): 29 | for key, val in dom.items(): 30 | if hasattr(key, "anchor") and key.anchor.value is not None: 31 | anchors[key.anchor.value] = key 32 | 33 | if hasattr(val, "anchor") and val.anchor.value is not None: 34 | anchors[val.anchor.value] = val 35 | 36 | # Recurse into complex values 37 | if isinstance(val, (CommentedMap, CommentedSeq)): 38 | Anchors.scan_for_anchors(val, anchors) 39 | 40 | elif isinstance(dom, CommentedSeq): 41 | for ele in dom: 42 | Anchors.scan_for_anchors(ele, anchors) 43 | 44 | elif hasattr(dom, "anchor") and dom.anchor.value is not None: 45 | anchors[dom.anchor.value] = dom 46 | 47 | @staticmethod 48 | def rename_anchor(dom: Any, anchor: str, new_anchor: str): 49 | """ 50 | Rename every use of an anchor in a document. 51 | 52 | Parameters: 53 | 1. dom (Any) The document to modify. 54 | 2. anchor (str) The old anchor name to rename. 55 | 3. new_anchor (str) The new name to apply to the anchor. 56 | 57 | Returns: N/A 58 | """ 59 | if isinstance(dom, CommentedMap): 60 | for key, val in dom.non_merged_items(): 61 | if hasattr(key, "anchor") and key.anchor.value == anchor: 62 | key.anchor.value = new_anchor 63 | if hasattr(val, "anchor") and val.anchor.value == anchor: 64 | val.anchor.value = new_anchor 65 | Anchors.rename_anchor(val, anchor, new_anchor) 66 | elif isinstance(dom, CommentedSeq): 67 | for ele in dom: 68 | Anchors.rename_anchor(ele, anchor, new_anchor) 69 | elif hasattr(dom, "anchor") and dom.anchor.value == anchor: 70 | dom.anchor.value = new_anchor 71 | 72 | @staticmethod 73 | def replace_merge_anchor(data: Any, old_node: Any, repl_node: Any) -> None: 74 | """ 75 | Replace YAML Merge Key references. 76 | 77 | Anchor merge references in YAML are formed using the `<<: *anchor` 78 | operator. 79 | 80 | Parameters: 81 | 1. data (Any) The DOM to adjust. 82 | 2. old_node (Any) The former anchor node. 83 | 3. repl_node (Any) The replacement anchor node. 84 | 85 | Returns: N/A 86 | """ 87 | if hasattr(data, "merge") and len(data.merge) > 0: 88 | for midx, merge_node in enumerate(data.merge): 89 | if merge_node[1] is old_node: 90 | data.merge[midx] = (data.merge[midx][0], repl_node) 91 | 92 | @staticmethod 93 | def combine_merge_anchors(lhs: CommentedMap, rhs: CommentedMap) -> None: 94 | """ 95 | Merge YAML Merge Keys. 96 | 97 | Parameters: 98 | 1. lhs (CommentedMap) The map to merge into 99 | 2. rhs (CommentedMap) The map to merge from 100 | """ 101 | for mele in rhs.merge: 102 | lhs.add_yaml_merge([mele]) 103 | 104 | @staticmethod 105 | def replace_anchor(data: Any, old_node: Any, repl_node: Any) -> None: 106 | """ 107 | Recursively replace every use of an anchor within a DOM. 108 | 109 | Parameters: 110 | 1. data (Any) The DOM to adjust. 111 | 2. old_node (Any) The former anchor node. 112 | 3. repl_node (Any) The replacement anchor node. 113 | 114 | Returns: N/A 115 | """ 116 | anchor_name = repl_node.anchor.value 117 | if isinstance(data, CommentedMap): 118 | Anchors.replace_merge_anchor(data, old_node, repl_node) 119 | for idx, key in [ 120 | (idx, key) for idx, key in enumerate(data.keys()) 121 | if hasattr(key, "anchor") 122 | and key.anchor.value == anchor_name 123 | ]: 124 | Anchors.replace_merge_anchor(key, old_node, repl_node) 125 | data.insert(idx, repl_node, data.pop(key)) 126 | 127 | for key, val in data.non_merged_items(): 128 | Anchors.replace_merge_anchor(key, old_node, repl_node) 129 | Anchors.replace_merge_anchor(val, old_node, repl_node) 130 | if (hasattr(val, "anchor") 131 | and val.anchor.value == anchor_name): 132 | data[key] = repl_node 133 | else: 134 | Anchors.replace_anchor(val, old_node, repl_node) 135 | elif isinstance(data, CommentedSeq): 136 | for idx, ele in enumerate(data): 137 | Anchors.replace_merge_anchor(ele, old_node, repl_node) 138 | if (hasattr(ele, "anchor") 139 | and ele.anchor.value == anchor_name): 140 | data[idx] = repl_node 141 | else: 142 | Anchors.replace_anchor(ele, old_node, repl_node) 143 | 144 | @staticmethod 145 | def generate_unique_anchor_name( 146 | document: Any, node_coord: NodeCoords, 147 | known_anchors: Optional[Dict[str, Any]] = None 148 | ) -> str: 149 | """ 150 | Generate a unique Anchor name to a given node. 151 | 152 | Parameters: 153 | 1. document (Any) The DOM to adjust. 154 | 2. node_coord (NodeCoords) The node to adjust. 155 | 3. known_anchors (Dict[str, Any]) Optional set of Anchors already in 156 | `document`; will be generated on-the-fly when unset. 157 | 158 | Returns: (str) The newly generated Anchor name. 159 | """ 160 | if not known_anchors: 161 | known_anchors = {} 162 | Anchors.scan_for_anchors(document, known_anchors) 163 | 164 | parentref = node_coord.parentref 165 | base_name = "id" 166 | if isinstance(parentref, str): 167 | base_name = parentref 168 | if base_name not in known_anchors: 169 | return base_name 170 | 171 | anchor_id = 1 172 | new_anchor = "{}{:03d}".format(base_name, anchor_id) 173 | while new_anchor in known_anchors: 174 | anchor_id += 1 175 | new_anchor = "{}{:03d}".format(base_name, anchor_id) 176 | 177 | return new_anchor 178 | 179 | @staticmethod 180 | def get_node_anchor(node: Any) -> Optional[str]: 181 | """ 182 | Return a node's Anchor/Alias name or None when there isn't one. 183 | 184 | Parameters: 185 | 1. node (Any) The node to evaluate 186 | 187 | Returns: (str) The node's Anchor/Alias name or None when unset 188 | """ 189 | if ( 190 | not hasattr(node, "anchor") 191 | or node.anchor is None 192 | or node.anchor.value is None 193 | or not node.anchor.value 194 | ): 195 | return None 196 | return str(node.anchor.value) 197 | -------------------------------------------------------------------------------- /yamlpath/common/searches.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement Searches, a static library of generally-useful code for searching. 3 | 4 | Copyright 2020 William W. Kimball, Jr. MBA MSIS 5 | """ 6 | import re 7 | from typing import Any, List 8 | 9 | from yamlpath.enums import ( 10 | AnchorMatches, 11 | PathSearchMethods, 12 | ) 13 | from yamlpath.common import Anchors, Nodes 14 | from yamlpath.types import PathAttributes 15 | from yamlpath.path import SearchTerms 16 | 17 | 18 | class Searches: 19 | """Helper methods for common data searching operations.""" 20 | 21 | @staticmethod 22 | # pylint: disable=too-many-branches,too-many-statements 23 | def search_matches( 24 | method: PathSearchMethods, needle: str, haystack: Any 25 | ) -> bool: 26 | """ 27 | Perform a search comparison. 28 | 29 | NOTE: For less-than, greather-than and related operations, the test is 30 | whether `haystack` is less/greater-than `needle`. 31 | 32 | Parameters: 33 | 1. method (PathSearchMethods) The search method to employ 34 | 2. needle (str) The value to look for. 35 | 3. haystack (Any) The value to look in. 36 | 37 | Returns: (bool) True = comparision passes; False = comparison fails. 38 | """ 39 | typed_haystack = Nodes.typed_value(haystack) 40 | typed_needle = Nodes.typed_value(needle) 41 | needle_type = type(typed_needle) 42 | matches: bool = False 43 | 44 | if method is PathSearchMethods.EQUALS: 45 | if isinstance(typed_haystack, bool) and needle_type is bool: 46 | matches = typed_haystack == typed_needle 47 | elif isinstance(typed_haystack, int) and needle_type is int: 48 | matches = typed_haystack == typed_needle 49 | elif isinstance(typed_haystack, float) and needle_type is float: 50 | matches = typed_haystack == typed_needle 51 | else: 52 | matches = str(typed_haystack) == str(needle) 53 | elif method is PathSearchMethods.STARTS_WITH: 54 | matches = str(typed_haystack).startswith(needle) 55 | elif method is PathSearchMethods.ENDS_WITH: 56 | matches = str(typed_haystack).endswith(needle) 57 | elif method is PathSearchMethods.CONTAINS: 58 | matches = needle in str(typed_haystack) 59 | elif method is PathSearchMethods.GREATER_THAN: 60 | if isinstance(typed_haystack, int): 61 | if isinstance(typed_needle, (int, float)): 62 | matches = typed_haystack > typed_needle 63 | else: 64 | matches = False 65 | elif isinstance(typed_haystack, float): 66 | if isinstance(typed_needle, (int, float)): 67 | matches = typed_haystack > typed_needle 68 | else: 69 | matches = False 70 | else: 71 | matches = str(typed_haystack) > str(needle) 72 | elif method is PathSearchMethods.LESS_THAN: 73 | if isinstance(typed_haystack, int): 74 | if isinstance(typed_needle, (int, float)): 75 | matches = typed_haystack < typed_needle 76 | else: 77 | matches = False 78 | elif isinstance(typed_haystack, float): 79 | if isinstance(typed_needle, (int, float)): 80 | matches = typed_haystack < typed_needle 81 | else: 82 | matches = False 83 | else: 84 | matches = str(typed_haystack) < str(needle) 85 | elif method is PathSearchMethods.GREATER_THAN_OR_EQUAL: 86 | if isinstance(typed_haystack, int): 87 | if isinstance(typed_needle, (int, float)): 88 | matches = typed_haystack >= typed_needle 89 | else: 90 | matches = False 91 | elif isinstance(typed_haystack, float): 92 | if isinstance(typed_needle, (int, float)): 93 | matches = typed_haystack >= typed_needle 94 | else: 95 | matches = False 96 | else: 97 | matches = str(typed_haystack) >= str(needle) 98 | elif method is PathSearchMethods.LESS_THAN_OR_EQUAL: 99 | if isinstance(typed_haystack, int): 100 | if isinstance(typed_needle, (int, float)): 101 | matches = typed_haystack <= typed_needle 102 | else: 103 | matches = False 104 | elif isinstance(typed_haystack, float): 105 | if isinstance(typed_needle, (int, float)): 106 | matches = typed_haystack <= typed_needle 107 | else: 108 | matches = False 109 | else: 110 | matches = str(typed_haystack) <= str(needle) 111 | elif method == PathSearchMethods.REGEX: 112 | matcher = re.compile(needle) 113 | matches = matcher.search(str(typed_haystack)) is not None 114 | else: 115 | raise NotImplementedError 116 | 117 | return matches 118 | 119 | @staticmethod 120 | def search_anchor( 121 | node: Any, terms: SearchTerms, seen_anchors: List[str], **kwargs: bool 122 | ) -> AnchorMatches: 123 | """ 124 | Indicate whether a node has an Anchor matching given search terms. 125 | 126 | Parameters: 127 | 1. node (Any) The node to search (the haystack) 128 | 2. terms (SearchTerms) The search terms (the needle) 129 | 3. seen_anchors (List[str]) Tracks whether the present Anchor under 130 | evaluation is really an Alias to another node 131 | 132 | Keyword Arguments: 133 | * search_anchors (bool) User-specific preference indicating whether to 134 | search Anchors and/or Aliases 135 | * include_aliases (bool) User-specified preference indicating whether 136 | to include Aliases in search results 137 | 138 | Returns: (AnchorMatches) The search result 139 | """ 140 | anchor_name = Anchors.get_node_anchor(node) 141 | if anchor_name is None: 142 | return AnchorMatches.NO_ANCHOR 143 | 144 | is_alias = True 145 | if anchor_name not in seen_anchors: 146 | is_alias = False 147 | seen_anchors.append(anchor_name) 148 | 149 | search_anchors: bool = kwargs.pop("search_anchors", False) 150 | if not search_anchors: 151 | retval = AnchorMatches.UNSEARCHABLE_ANCHOR 152 | if is_alias: 153 | retval = AnchorMatches.UNSEARCHABLE_ALIAS 154 | return retval 155 | 156 | include_aliases: bool = kwargs.pop("include_aliases", False) 157 | if is_alias and not include_aliases: 158 | return AnchorMatches.ALIAS_EXCLUDED 159 | 160 | retval = AnchorMatches.NO_MATCH 161 | matches = Searches.search_matches( 162 | terms.method, terms.term, anchor_name) 163 | if ((matches and not terms.inverted) 164 | or (terms.inverted and not matches) 165 | ): 166 | retval = AnchorMatches.MATCH 167 | if is_alias: 168 | retval = AnchorMatches.ALIAS_INCLUDED 169 | return retval 170 | 171 | @staticmethod 172 | def create_searchterms_from_pathattributes( 173 | rhs: PathAttributes 174 | ) -> SearchTerms: 175 | """ 176 | Convert a PathAttributes instance to a SearchTerms instance. 177 | 178 | Parameters: 179 | 1. rhs (PathAttributes) PathAttributes instance to convert 180 | 181 | Returns: (SearchTerms) SearchTerms extracted from `rhs` 182 | """ 183 | if isinstance(rhs, SearchTerms): 184 | newinst: SearchTerms = SearchTerms( 185 | rhs.inverted, rhs.method, rhs.attribute, rhs.term 186 | ) 187 | return newinst 188 | raise AttributeError 189 | -------------------------------------------------------------------------------- /yamlpath/commands/yaml_get.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enable users to get data out of YAML/Compatible files using YAML Paths. 3 | 4 | Retrieves one or more values from a YAML file at a specified YAML Path. 5 | Output is printed to STDOUT, one line per match. When a result is a complex 6 | data-type (Array or Hash), a JSON dump is produced to represent each complex 7 | result. EYAML can be employed to decrypt the values. 8 | 9 | Copyright 2018, 2019, 2020, 2021 William W. Kimball, Jr. MBA MSIS 10 | """ 11 | import sys 12 | import argparse 13 | import json 14 | from os import access, R_OK 15 | from os.path import isfile 16 | 17 | from ruamel.yaml.comments import CommentedSet 18 | from yamlpath.patches.timestamp import ( 19 | AnchoredTimeStamp, 20 | AnchoredDate, 21 | ) 22 | 23 | from yamlpath import __version__ as YAMLPATH_VERSION 24 | from yamlpath.common import Parsers, Nodes 25 | from yamlpath import YAMLPath 26 | from yamlpath.exceptions import YAMLPathException 27 | from yamlpath.eyaml.exceptions import EYAMLCommandException 28 | from yamlpath.enums import PathSeparators 29 | from yamlpath.wrappers import NodeCoords 30 | from yamlpath.eyaml import EYAMLProcessor 31 | 32 | from yamlpath.wrappers import ConsolePrinter 33 | # pylint: enable=wrong-import-position,ungrouped-imports 34 | 35 | def processcli(): 36 | """Process command-line arguments.""" 37 | parser = argparse.ArgumentParser( 38 | description=( 39 | "Retrieves one or more values from a YAML/JSON/Compatible" 40 | " file at a specified YAML Path. Output is printed to STDOUT, one" 41 | " line per result. When a result is a complex data-type (Array or" 42 | " Hash), a JSON dump is produced to represent it. EYAML can be" 43 | " employed to decrypt the values."), 44 | epilog=( 45 | "For more information about YAML Paths, please visit" 46 | " https://github.com/wwkimball/yamlpath/wiki. To report issues" 47 | " with this tool or to request enhancements, please visit" 48 | " https://github.com/wwkimball/yamlpath/issues.") 49 | ) 50 | parser.add_argument("-V", "--version", action="version", 51 | version="%(prog)s " + YAMLPATH_VERSION) 52 | 53 | required_group = parser.add_argument_group("required settings") 54 | required_group.add_argument( 55 | "-p", "--query", 56 | required=True, 57 | metavar="YAML_PATH", 58 | help="YAML Path to query" 59 | ) 60 | 61 | parser.add_argument( 62 | "-t", "--pathsep", 63 | default="dot", 64 | choices=PathSeparators, 65 | metavar=PathSeparators.get_choices(), 66 | type=PathSeparators.from_str, 67 | help="indicate which YAML Path separator to use when rendering\ 68 | results; default=dot") 69 | 70 | parser.add_argument( 71 | "-S", "--nostdin", action="store_true", 72 | help=( 73 | "Do not implicitly read from STDIN, even when YAML_FILE is not set" 74 | " and the session is non-TTY")) 75 | 76 | eyaml_group = parser.add_argument_group( 77 | "EYAML options", "Left unset, the EYAML keys will default to your\ 78 | system or user defaults. Both keys must be set either here or in\ 79 | your system or user EYAML configuration file when using EYAML.") 80 | eyaml_group.add_argument( 81 | "-x", "--eyaml", 82 | default="eyaml", 83 | help="the eyaml binary to use when it isn't on the PATH") 84 | eyaml_group.add_argument("-r", "--privatekey", help="EYAML private key") 85 | eyaml_group.add_argument("-u", "--publickey", help="EYAML public key") 86 | 87 | noise_group = parser.add_mutually_exclusive_group() 88 | noise_group.add_argument( 89 | "-d", "--debug", 90 | action="store_true", 91 | help="output debugging details") 92 | noise_group.add_argument( 93 | "-v", "--verbose", 94 | action="store_true", 95 | help="increase output verbosity") 96 | noise_group.add_argument( 97 | "-q", "--quiet", 98 | action="store_true", 99 | help="suppress all output except errors") 100 | 101 | parser.add_argument( 102 | "yaml_file", metavar="YAML_FILE", 103 | nargs="?", 104 | help="the YAML file to query; omit or use - to read from STDIN") 105 | 106 | return parser.parse_args() 107 | 108 | def validateargs(args, log): 109 | """Validate command-line arguments.""" 110 | has_errors = False 111 | in_file = args.yaml_file if args.yaml_file else "" 112 | in_stream_mode = in_file.strip() == "-" or ( 113 | not in_file and not args.nostdin and not sys.stdin.isatty() 114 | ) 115 | 116 | # When there is no YAML_FILE and no STDIN, there is nothing to read 117 | if not (in_file or in_stream_mode): 118 | has_errors = True 119 | log.error("YAML_FILE must be set or be read from STDIN.") 120 | 121 | # When set, --privatekey must be a readable file 122 | if args.privatekey and not ( 123 | isfile(args.privatekey) and access(args.privatekey, R_OK) 124 | ): 125 | has_errors = True 126 | log.error( 127 | "EYAML private key is not a readable file: " + args.privatekey 128 | ) 129 | 130 | # When set, --publickey must be a readable file 131 | if args.publickey and not ( 132 | isfile(args.publickey) and access(args.publickey, R_OK) 133 | ): 134 | has_errors = True 135 | log.error( 136 | "EYAML public key is not a readable file: " + args.publickey 137 | ) 138 | 139 | # When either --publickey or --privatekey are set, the other must also 140 | # be. This is because the `eyaml` command requires them both when 141 | # decrypting values. 142 | if ( 143 | (args.publickey and not args.privatekey) 144 | or (args.privatekey and not args.publickey) 145 | ): 146 | has_errors = True 147 | log.error("Both private and public EYAML keys must be set.") 148 | 149 | # When dumping the document to STDOUT, mute all non-errors 150 | force_verbose = args.verbose 151 | force_debug = args.debug 152 | if in_stream_mode and not (force_verbose or force_debug): 153 | args.quiet = True 154 | args.verbose = False 155 | args.debug = False 156 | 157 | if has_errors: 158 | sys.exit(1) 159 | 160 | def main(): 161 | """Perform the work specified via CLI arguments and exit. 162 | 163 | Main code. 164 | """ 165 | args = processcli() 166 | log = ConsolePrinter(args) 167 | validateargs(args, log) 168 | yaml_path = YAMLPath(args.query, pathsep=args.pathsep) 169 | 170 | # Prep the YAML parser 171 | yaml = Parsers.get_yaml_editor() 172 | 173 | # Attempt to open the YAML file; check for parsing errors 174 | (yaml_data, doc_loaded) = Parsers.get_yaml_data( 175 | yaml, log, 176 | args.yaml_file if args.yaml_file else "-") 177 | if not doc_loaded: 178 | # An error message has already been logged 179 | sys.exit(1) 180 | 181 | # Seek the queried value(s) 182 | discovered_nodes = [] 183 | processor = EYAMLProcessor( 184 | log, yaml_data, binary=args.eyaml, 185 | publickey=args.publickey, privatekey=args.privatekey) 186 | try: 187 | for node in processor.get_eyaml_values(yaml_path, mustexist=True): 188 | log.debug( 189 | "Got node from {}:".format(yaml_path), data=node, 190 | prefix="yaml_get::main: ") 191 | discovered_nodes.append(NodeCoords.unwrap_node_coords(node)) 192 | except YAMLPathException as ex: 193 | log.critical(ex, 1) 194 | except EYAMLCommandException as ex: 195 | log.critical(ex, 2) 196 | 197 | try: 198 | for node in discovered_nodes: 199 | if isinstance(node, (dict, list, CommentedSet)): 200 | print(json.dumps(Parsers.jsonify_yaml_data(node))) 201 | else: 202 | if node is None: 203 | node = "\x00" 204 | elif isinstance(node, AnchoredDate): 205 | node = node.date().isoformat() 206 | elif isinstance(node, AnchoredTimeStamp): 207 | node = Nodes.get_timestamp_with_tzinfo(node).isoformat() 208 | print("{}".format(str(node).replace("\n", r"\n"))) 209 | except RecursionError: 210 | log.critical( 211 | "The YAML data contains an infinitely recursing YAML Alias!", 1) 212 | 213 | if __name__ == "__main__": 214 | main() # pragma: no cover 215 | -------------------------------------------------------------------------------- /yamlpath/commands/eyaml_rotate_keys.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enable users to rotate EYAML encryption keys used for pre-encrypted files. 3 | 4 | Rotates the encryption keys used for all EYAML values within a set of YAML 5 | files, decrypting with old keys and re-encrypting using replacement keys. 6 | 7 | Copyright 2018, 2019 William W. Kimball, Jr. MBA MSIS 8 | """ 9 | import sys 10 | import argparse 11 | from shutil import copy2 12 | from os import remove, access, R_OK 13 | from os.path import isfile, exists 14 | 15 | from ruamel.yaml.scalarstring import FoldedScalarString 16 | 17 | from yamlpath import __version__ as YAMLPATH_VERSION 18 | from yamlpath.common import Anchors, Parsers 19 | from yamlpath.eyaml.exceptions import EYAMLCommandException 20 | from yamlpath.eyaml.enums import EYAMLOutputFormats 21 | from yamlpath.eyaml import EYAMLProcessor 22 | from yamlpath.wrappers import ConsolePrinter 23 | 24 | def processcli(): 25 | """Process command-line arguments.""" 26 | parser = argparse.ArgumentParser( 27 | description=( 28 | "Rotates the encryption keys used for all EYAML values" 29 | " within a set of YAML files, decrypting with old keys and" 30 | " re-encrypting using replacement keys."), 31 | epilog=( 32 | "Any YAML_FILEs lacking EYAML values will not be modified (or" 33 | " backed up, even when -b/--backup is specified). For more" 34 | " information about YAML Paths, please visit" 35 | " https://github.com/wwkimball/yamlpath/wiki. To report issues" 36 | " with this tool or to request enhancements, please visit" 37 | " https://github.com/wwkimball/yamlpath/issues.") 38 | ) 39 | parser.add_argument("-V", "--version", action="version", 40 | version="%(prog)s " + YAMLPATH_VERSION) 41 | 42 | noise_group = parser.add_mutually_exclusive_group() 43 | noise_group.add_argument("-d", "--debug", action="store_true", 44 | help="output debugging details") 45 | noise_group.add_argument("-v", "--verbose", action="store_true", 46 | help="increase output verbosity") 47 | noise_group.add_argument("-q", "--quiet", action="store_true", 48 | help="suppress all output except errors") 49 | 50 | parser.add_argument("-b", "--backup", action="store_true", 51 | help="save a backup of each modified YAML_FILE with an" 52 | + " extra .bak file-extension") 53 | parser.add_argument("-x", "--eyaml", default="eyaml", 54 | help="the eyaml binary to use when it isn't on the" 55 | + " PATH") 56 | 57 | key_group = parser.add_argument_group( 58 | "EYAML_KEYS", "All key arguments are required" 59 | ) 60 | key_group.add_argument("-r", "--newprivatekey", required=True, 61 | help="the new EYAML private key") 62 | key_group.add_argument("-u", "--newpublickey", required=True, 63 | help="the new EYAML public key") 64 | key_group.add_argument("-i", "--oldprivatekey", required=True, 65 | help="the old EYAML private key") 66 | key_group.add_argument("-c", "--oldpublickey", required=True, 67 | help="the old EYAML public key") 68 | 69 | parser.add_argument("yaml_files", metavar="YAML_FILE", nargs="+", 70 | help="one or more YAML files containing EYAML values") 71 | return parser.parse_args() 72 | 73 | def validateargs(args, log): 74 | """Validate command-line arguments.""" 75 | has_errors = False 76 | 77 | # Enforce sanity 78 | # * The new and old EYAML keys must be different 79 | if ((args.newprivatekey == args.oldprivatekey) 80 | or (args.newpublickey == args.oldpublickey)): 81 | has_errors = True 82 | log.error("The new and old EYAML keys must be different.") 83 | 84 | # * All EYAML certs must exist and be readable to the present user 85 | for check_file in [args.newprivatekey, 86 | args.newpublickey, 87 | args.oldprivatekey, 88 | args.oldpublickey]: 89 | if not (isfile(check_file) and access(check_file, R_OK)): 90 | has_errors = True 91 | log.error( 92 | "EYAML key is not a readable file: " + check_file 93 | ) 94 | 95 | if has_errors: 96 | sys.exit(1) 97 | 98 | # pylint: disable=locally-disabled,too-many-locals,too-many-branches,too-many-statements 99 | def main(): 100 | """Perform the work specified via CLI arguments and exit. 101 | 102 | Main code. 103 | """ 104 | # Process any command-line arguments 105 | args = processcli() 106 | log = ConsolePrinter(args) 107 | validateargs(args, log) 108 | processor = EYAMLProcessor(log, None, binary=args.eyaml) 109 | 110 | # Prep the YAML parser 111 | yaml = Parsers.get_yaml_editor() 112 | 113 | # Process the input file(s) 114 | in_file_count = len(args.yaml_files) 115 | exit_state = 0 116 | for yaml_file in args.yaml_files: 117 | file_changed = False 118 | backup_file = yaml_file + ".bak" 119 | seen_anchors = [] 120 | 121 | # Each YAML_FILE must actually be a file 122 | if not isfile(yaml_file): 123 | log.error("Not a file: {}".format(yaml_file)) 124 | exit_state = 2 125 | continue 126 | 127 | # Don't bother with the file change update when there's only one input 128 | # file. 129 | if in_file_count > 1: 130 | log.info("Processing {}...".format(yaml_file)) 131 | 132 | # Try to open the file 133 | (yaml_data, doc_loaded) = Parsers.get_yaml_data(yaml, log, yaml_file) 134 | if not doc_loaded: 135 | # An error message has already been logged 136 | exit_state = 3 137 | continue 138 | 139 | # Process all EYAML values 140 | processor.data = yaml_data 141 | for yaml_path in processor.find_eyaml_paths(): 142 | # Use ::get_nodes() instead of ::get_eyaml_values() here in order 143 | # to ignore values that have already been rotated via their 144 | # Anchors. 145 | for node_coordinate in processor.get_nodes( 146 | yaml_path, mustexist=True 147 | ): 148 | # Ignore values which are Aliases for those already decrypted 149 | node = node_coordinate.node 150 | anchor_name = Anchors.get_node_anchor(node) 151 | if anchor_name is not None: 152 | if anchor_name in seen_anchors: 153 | continue 154 | 155 | seen_anchors.append(anchor_name) 156 | 157 | log.verbose("Decrypting value(s) at {}.".format(yaml_path)) 158 | processor.publickey = args.oldpublickey 159 | processor.privatekey = args.oldprivatekey 160 | 161 | try: 162 | txtval = processor.decrypt_eyaml(node) 163 | except EYAMLCommandException as ex: 164 | log.error(ex) 165 | exit_state = 3 166 | continue 167 | 168 | # Prefer block (folded) values unless the original YAML value 169 | # was already a massivly long (string) line. 170 | output = EYAMLOutputFormats.BLOCK 171 | if not isinstance(node, FoldedScalarString): 172 | output = EYAMLOutputFormats.STRING 173 | 174 | # Re-encrypt the value with new EYAML keys 175 | processor.publickey = args.newpublickey 176 | processor.privatekey = args.newprivatekey 177 | 178 | try: 179 | processor.set_eyaml_value(yaml_path, txtval, output=output) 180 | except EYAMLCommandException as ex: 181 | log.error(ex) 182 | exit_state = 3 183 | continue 184 | 185 | file_changed = True 186 | 187 | # Save the changes 188 | if file_changed: 189 | if args.backup: 190 | log.verbose("Saving a backup of {} to {}." 191 | .format(yaml_file, backup_file)) 192 | if exists(backup_file): 193 | remove(backup_file) 194 | copy2(yaml_file, backup_file) 195 | 196 | log.verbose("Writing changed data to {}.".format(yaml_file)) 197 | with open(yaml_file, 'w', encoding='utf-8') as yaml_dump: 198 | yaml.dump(yaml_data, yaml_dump) 199 | 200 | sys.exit(exit_state) 201 | 202 | if __name__ == "__main__": 203 | main() # pragma: no cover 204 | --------------------------------------------------------------------------------