├── MANIFEST.in ├── setup.cfg ├── setup.py ├── README.rst ├── LICENSE ├── oyaml.py ├── .github └── workflows │ └── tests.yml └── test_oyaml.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="oyaml", 5 | version="1.0", 6 | description="Ordered YAML: drop-in replacement for PyYAML which preserves dict ordering", 7 | long_description=open("README.rst").read(), 8 | author="Wim Glenn", 9 | author_email="hey@wimglenn.com", 10 | url="https://github.com/wimglenn/oyaml", 11 | license="MIT", 12 | py_modules=["oyaml"], 13 | install_requires=["pyyaml"], 14 | options={"bdist_wheel": {"universal": True}}, 15 | ) 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |actions| |codecov| |pypi| |womm| 2 | 3 | .. |actions| image:: https://github.com/wimglenn/oyaml/actions/workflows/tests.yml/badge.svg 4 | .. _actions: https://github.com/wimglenn/oyaml/actions/workflows/tests.yml 5 | 6 | .. |codecov| image:: https://codecov.io/gh/wimglenn/oyaml/branch/master/graph/badge.svg 7 | .. _codecov: https://codecov.io/gh/wimglenn/oyaml 8 | 9 | .. |pypi| image:: https://img.shields.io/pypi/v/oyaml.svg 10 | .. _pypi: https://pypi.org/project/oyaml 11 | 12 | .. |womm| image:: https://cdn.rawgit.com/nikku/works-on-my-machine/v0.2.0/badge.svg 13 | .. _womm: https://github.com/nikku/works-on-my-machine 14 | 15 | 16 | oyaml 17 | ===== 18 | 19 | oyaml is a drop-in replacement for `PyYAML `_ which preserves dict ordering. Both Python 2 and Python 3 are supported. Just ``pip install oyaml``, and import as shown below: 20 | 21 | .. code-block:: python 22 | 23 | import oyaml as yaml 24 | 25 | You'll no longer be annoyed by screwed-up mappings when dumping/loading. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 wim glenn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /oyaml.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | from collections import OrderedDict 4 | 5 | import yaml as pyyaml 6 | 7 | 8 | _items = "viewitems" if sys.version_info < (3,) else "items" 9 | _std_dict_is_order_preserving = sys.version_info >= (3, 7) or ( 10 | sys.version_info >= (3, 6) and platform.python_implementation() == "CPython" 11 | ) 12 | 13 | 14 | def map_representer(dumper, data): 15 | return dumper.represent_dict(getattr(data, _items)()) 16 | 17 | 18 | def map_constructor(loader, node): 19 | loader.flatten_mapping(node) 20 | pairs = loader.construct_pairs(node) 21 | try: 22 | return OrderedDict(pairs) 23 | except TypeError: 24 | loader.construct_mapping(node) # trigger any contextual error 25 | raise 26 | 27 | 28 | _loaders = [getattr(pyyaml.loader, x) for x in pyyaml.loader.__all__] 29 | _dumpers = [getattr(pyyaml.dumper, x) for x in pyyaml.dumper.__all__] 30 | try: 31 | _cyaml = pyyaml.cyaml.__all__ 32 | except AttributeError: 33 | pass 34 | else: 35 | _loaders += [getattr(pyyaml.cyaml, x) for x in _cyaml if x.endswith("Loader")] 36 | _dumpers += [getattr(pyyaml.cyaml, x) for x in _cyaml if x.endswith("Dumper")] 37 | 38 | Dumper = None 39 | for Dumper in _dumpers: 40 | pyyaml.add_representer(dict, map_representer, Dumper=Dumper) 41 | pyyaml.add_representer(OrderedDict, map_representer, Dumper=Dumper) 42 | 43 | Loader = None 44 | if not _std_dict_is_order_preserving: 45 | for Loader in _loaders: 46 | pyyaml.add_constructor("tag:yaml.org,2002:map", map_constructor, Loader=Loader) 47 | 48 | 49 | # Merge PyYAML namespace into ours. 50 | # This allows users a drop-in replacement: 51 | # import oyaml as yaml 52 | del map_constructor, map_representer, Loader, Dumper 53 | from yaml import * 54 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | tests: 12 | name: py${{ matrix.python-version }} / ${{ matrix.os }} / PyYAML~=${{ matrix.pyyaml-version }} 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ubuntu-latest, windows-latest] 19 | python-version: ["3.9"] 20 | pyyaml-version: ["4.2b4", "5.4", "6.0"] 21 | include: 22 | - { os: ubuntu-latest, python-version: "3.7", pyyaml-version: "3.13" } 23 | - { os: windows-latest, python-version: "3.7", pyyaml-version: "3.13" } 24 | - { os: ubuntu-latest, python-version: "3.13", pyyaml-version: "6.0" } 25 | - { os: macos-latest, python-version: "3.13", pyyaml-version: "6.0" } 26 | - { os: windows-latest, python-version: "3.13", pyyaml-version: "6.0" } 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | architecture: x64 34 | - name: Install 35 | run: python -m pip install -q pytest pytest-cov pyyaml~=${{ matrix.pyyaml-version }} -e . 36 | - name: Run tests py${{ matrix.python-version }} / ${{ matrix.os }} / PyYAML~=${{ matrix.pyyaml-version }} 37 | run: python -m pytest --cov-branch --cov=oyaml 38 | - name: Upload coverage to Codecov 39 | uses: codecov/codecov-action@v4 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | 43 | tests-27: 44 | name: Python 2.7 on ubuntu-20.04 45 | runs-on: ubuntu-20.04 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | pyyaml-version: ["3.13", "4.2b4", "5.4"] 50 | 51 | container: 52 | image: python:2.7-buster 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | - name: Install 57 | run: python -m pip install -q pytest pytest-cov pyyaml~=${{ matrix.pyyaml-version }} -e . 58 | - name: Run tests py${{ matrix.python-version }} / ${{ matrix.os }} / PyYAML~=${{ matrix.pyyaml-version }} 59 | run: python -m pytest --cov-branch --cov=oyaml 60 | - name: Upload coverage to Codecov 61 | uses: codecov/codecov-action@v4 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | -------------------------------------------------------------------------------- /test_oyaml.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from types import GeneratorType 3 | 4 | import pytest 5 | from yaml.constructor import ConstructorError 6 | from yaml.representer import RepresenterError 7 | 8 | import oyaml as yaml 9 | from oyaml import _std_dict_is_order_preserving 10 | 11 | 12 | data = OrderedDict([("x", 1), ("z", 3), ("y", 2)]) 13 | 14 | 15 | def test_dump(): 16 | assert yaml.dump(data, default_flow_style=None) == "{x: 1, z: 3, y: 2}\n" 17 | 18 | 19 | def test_safe_dump(): 20 | assert yaml.safe_dump(data, default_flow_style=None) == "{x: 1, z: 3, y: 2}\n" 21 | 22 | 23 | def test_dump_all(): 24 | assert ( 25 | yaml.dump_all(documents=[data, {}], default_flow_style=None) 26 | == "{x: 1, z: 3, y: 2}\n--- {}\n" 27 | ) 28 | 29 | 30 | def test_dump_and_safe_dump_match(): 31 | mydict = {"x": 1, "z": 2, "y": 3} 32 | # don't know if mydict is ordered in the implementation or not (but don't care) 33 | assert yaml.dump(mydict) == yaml.safe_dump(mydict) 34 | 35 | 36 | def test_safe_dump_all(): 37 | assert ( 38 | yaml.safe_dump_all(documents=[data, {}], default_flow_style=None) 39 | == "{x: 1, z: 3, y: 2}\n--- {}\n" 40 | ) 41 | 42 | 43 | def test_load(): 44 | loaded = yaml.safe_load("{x: 1, z: 3, y: 2}") 45 | assert loaded == {"x": 1, "z": 3, "y": 2} 46 | 47 | 48 | def test_safe_load(): 49 | loaded = yaml.safe_load("{x: 1, z: 3, y: 2}") 50 | assert loaded == {"x": 1, "z": 3, "y": 2} 51 | 52 | 53 | def test_load_all(): 54 | gen = yaml.safe_load_all("{x: 1, z: 3, y: 2}\n--- {}\n") 55 | assert isinstance(gen, GeneratorType) 56 | ordered_data, empty_dict = gen 57 | assert empty_dict == {} 58 | assert ordered_data == data 59 | 60 | 61 | @pytest.mark.skipif(_std_dict_is_order_preserving, reason="requires old dict impl") 62 | def test_loads_to_ordered_dict(): 63 | loaded = yaml.safe_load("{x: 1, z: 3, y: 2}") 64 | assert isinstance(loaded, OrderedDict) 65 | 66 | 67 | @pytest.mark.skipif(not _std_dict_is_order_preserving, reason="requires new dict impl") 68 | def test_loads_to_std_dict(): 69 | loaded = yaml.safe_load("{x: 1, z: 3, y: 2}") 70 | assert not isinstance(loaded, OrderedDict) 71 | assert isinstance(loaded, dict) 72 | 73 | 74 | @pytest.mark.skipif(_std_dict_is_order_preserving, reason="requires old dict impl") 75 | def test_safe_loads_to_ordered_dict(): 76 | loaded = yaml.safe_load("{x: 1, z: 3, y: 2}") 77 | assert isinstance(loaded, OrderedDict) 78 | 79 | 80 | @pytest.mark.skipif(not _std_dict_is_order_preserving, reason="requires new dict impl") 81 | def test_safe_loads_to_std_dict(): 82 | loaded = yaml.safe_load("{x: 1, z: 3, y: 2}") 83 | assert not isinstance(loaded, OrderedDict) 84 | assert isinstance(loaded, dict) 85 | 86 | 87 | class MyOrderedDict(OrderedDict): 88 | pass 89 | 90 | 91 | def test_subclass_dump(): 92 | data = MyOrderedDict([("x", 1), ("y", 2)]) 93 | assert "!!python/object/apply:test_oyaml.MyOrderedDict" in yaml.dump(data) 94 | with pytest.raises(RepresenterError, match="cannot represent an object"): 95 | yaml.safe_dump(data) 96 | 97 | 98 | def test_anchors_and_references(): 99 | text = """ 100 | defaults: 101 | all: &all 102 | product: foo 103 | development: &development 104 | <<: *all 105 | profile: bar 106 | 107 | development: 108 | platform: 109 | <<: *development 110 | host: baz 111 | """ 112 | expected_load = { 113 | "defaults": { 114 | "all": {"product": "foo"}, 115 | "development": {"product": "foo", "profile": "bar"}, 116 | }, 117 | "development": { 118 | "platform": {"host": "baz", "product": "foo", "profile": "bar"} 119 | }, 120 | } 121 | assert yaml.safe_load(text) == expected_load 122 | 123 | 124 | def test_omap(): 125 | text = """ 126 | Bestiary: !!omap 127 | - aardvark: African pig-like ant eater. Ugly. 128 | - anteater: South-American ant eater. Two species. 129 | - anaconda: South-American constrictor snake. Scaly. 130 | """ 131 | expected_load = { 132 | "Bestiary": ( 133 | [ 134 | ("aardvark", "African pig-like ant eater. Ugly."), 135 | ("anteater", "South-American ant eater. Two species."), 136 | ("anaconda", "South-American constrictor snake. Scaly."), 137 | ] 138 | ) 139 | } 140 | assert yaml.safe_load(text) == expected_load 141 | 142 | 143 | def test_omap_flow_style(): 144 | text = "Numbers: !!omap [ one: 1, two: 2, three : 3 ]" 145 | expected_load = {"Numbers": ([("one", 1), ("two", 2), ("three", 3)])} 146 | assert yaml.safe_load(text) == expected_load 147 | 148 | 149 | def test_merge(): 150 | text = """ 151 | - &CENTER { x: 1, y: 2 } 152 | - &LEFT { x: 0, y: 2 } 153 | - &BIG { r: 10 } 154 | - &SMALL { r: 1 } 155 | 156 | # All the following maps are equal: 157 | 158 | - # Explicit keys 159 | x: 1 160 | y: 2 161 | r: 10 162 | label: center/big 163 | 164 | - # Merge one map 165 | << : *CENTER 166 | r: 10 167 | label: center/big 168 | 169 | - # Merge multiple maps 170 | << : [ *CENTER, *BIG ] 171 | label: center/big 172 | 173 | - # Override 174 | << : [ *BIG, *LEFT, *SMALL ] 175 | x: 1 176 | label: center/big 177 | """ 178 | data = yaml.safe_load(text) 179 | assert len(data) == 8 180 | center, left, big, small, map1, map2, map3, map4 = data 181 | assert center == {"x": 1, "y": 2} 182 | assert left == {"x": 0, "y": 2} 183 | assert big == {"r": 10} 184 | assert small == {"r": 1} 185 | expected = {"x": 1, "y": 2, "r": 10, "label": "center/big"} 186 | assert map1 == expected 187 | assert map2 == expected 188 | assert map3 == expected 189 | assert map4 == expected 190 | 191 | 192 | def test_unhashable_error_context(): 193 | with pytest.raises(ConstructorError, match=r".*line.*column.*"): 194 | yaml.safe_load("{foo: bar}: baz") 195 | 196 | 197 | @pytest.mark.skipif(not hasattr(yaml, "CSafeLoader"), reason="requires cyaml loaders") 198 | def test_explicit_loader(): 199 | data = yaml.load("{x: 1, z: 3, y: 2}", Loader=yaml.CSafeLoader) 200 | assert data == {"x": 1, "z": 3, "y": 2} 201 | assert list(data) == ["x", "z", "y"] 202 | 203 | 204 | @pytest.mark.skipif(not hasattr(yaml, "CSafeDumper"), reason="requires cyaml dumpers") 205 | def test_explicit_dumper(): 206 | data = OrderedDict([("x", 1), ("z", 3), ("y", 2)]) 207 | text = yaml.dump(data, Dumper=yaml.CSafeDumper, default_flow_style=None) 208 | assert text == "{x: 1, z: 3, y: 2}\n" 209 | --------------------------------------------------------------------------------