├── autocompletion.png ├── metadict ├── __init__.py ├── __about__.py └── metadict.py ├── .github └── workflows │ └── publish_to_pypi.yml ├── .circleci └── config.yml ├── setup.py ├── .gitignore ├── README.md ├── tests └── test_metadict.py └── LICENSE /autocompletion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LarsHill/metadict/HEAD/autocompletion.png -------------------------------------------------------------------------------- /metadict/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .__about__ import * 4 | from .metadict import MetaDict 5 | 6 | 7 | logging.getLogger(__name__) 8 | -------------------------------------------------------------------------------- /metadict/__about__.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | _this_year = time.strftime("%Y") 4 | __version__ = '0.1.5' 5 | __author__ = 'Lars Hillebrand' 6 | __author_email__ = 'hokage555@web.de' 7 | __license__ = 'Apache-2.0' 8 | __copyright__ = f'Copyright (c) 2022-{_this_year}, {__author__}.' 9 | __homepage__ = 'https://github.com/LarsHill/metadict/' 10 | __docs__ = ( 11 | "MetaDict is a powerful dict subclass enabling (nested) attribute-style item access/assignment " 12 | "and IDE autocompletion support." 13 | ) 14 | 15 | __all__ = ["__author__", "__author_email__", "__copyright__", "__docs__", "__homepage__", "__license__", "__version__"] 16 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout source 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: 3.11 22 | 23 | - name: Install Dependencies 24 | run: python -m pip install setuptools wheel 25 | 26 | - name: Build package 27 | run: python setup.py sdist bdist_wheel 28 | 29 | - name: Publish package 30 | uses: pypa/gh-action-pypi-publish@master 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | orbs: 3 | codecov: codecov/codecov@1.0.2jobs 4 | jobs: 5 | build: 6 | docker: 7 | - image: circleci/python:3.6 8 | 9 | working_directory: ~/repo 10 | 11 | steps: 12 | - checkout 13 | 14 | - run: 15 | name: install dependencies 16 | command: | 17 | python3 -m venv venv 18 | . venv/bin/activate 19 | pip install pytest 20 | pip install coverage 21 | pip install codecov 22 | pip install . 23 | 24 | - run: 25 | name: run unit tests 26 | command: | 27 | . venv/bin/activate 28 | mkdir test-reports 29 | coverage run --source=./metadict -m pytest --junitxml=test-reports/results.xml tests 30 | coverage report 31 | coverage html --directory=test-reports 32 | coverage xml 33 | mv coverage.xml test-reports 34 | codecov -f test-reports/coverage.xml 35 | 36 | - store_test_results: 37 | path: test-reports 38 | 39 | - store_artifacts: 40 | path: test-reports 41 | destination: test-reports 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib.util import module_from_spec, spec_from_file_location 3 | from setuptools import setup, find_packages 4 | 5 | 6 | _PATH_ROOT = os.path.dirname(__file__) 7 | 8 | 9 | def _load_py_module(file_name: str, pkg="metadict"): 10 | spec = spec_from_file_location(os.path.join(pkg, file_name), os.path.join(_PATH_ROOT, pkg, file_name)) 11 | py = module_from_spec(spec) 12 | spec.loader.exec_module(py) 13 | return py 14 | 15 | 16 | about = _load_py_module("__about__.py") 17 | 18 | with open('README.md', 'r', encoding='utf-8') as fh: 19 | long_description = fh.read() 20 | 21 | test_deps = ['pytest'] 22 | 23 | setup(name='metadict', 24 | version=about.__version__, 25 | description=about.__docs__, 26 | long_description=long_description, 27 | long_description_content_type='text/markdown', 28 | author=about.__author__, 29 | author_email=about.__author_email__, 30 | url=about.__homepage__, 31 | download_url=about.__homepage__, 32 | license=about.__license__, 33 | copyright=about.__copyright__, 34 | keywords=['dict', 'attribute-style syntax', 'nesting', 'auto-completion'], 35 | packages=find_packages(), 36 | python_requires='>=3.6', 37 | extras_require={'tests': test_deps}, 38 | tests_require=test_deps) 39 | -------------------------------------------------------------------------------- /.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 | # Pycharm/Intellij 107 | .idea/* 108 | 109 | # VSCode 110 | .vscode/ 111 | .history/ 112 | 113 | # Mac OS related 114 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

MetaDict

3 | 4 | _Enabling dot notation and IDE autocompletion_ 5 | 6 |

7 | Installation • 8 | Features • 9 | Documentation • 10 | Competitors • 11 | Citation 12 |

13 | 14 | [![Python Version](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://www.python.org/downloads/release/python-360/) 15 | [![PyPI version](https://badge.fury.io/py/metadict.svg?dummy=unused)](https://badge.fury.io/py/metadict) 16 | [![CircleCI](https://circleci.com/gh/LarsHill/metadict/tree/main.svg?style=shield)](https://circleci.com/gh/LarsHill/metadict/tree/main) 17 | [![codecov](https://codecov.io/gh/LarsHill/metadict/branch/main/graph/badge.svg?token=XG4UDWF8RE)](https://codecov.io/gh/LarsHill/metadict) 18 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 19 | 20 |
21 | 22 | --- 23 | 24 | **MetaDict** is designed to behave exactly like a `dict` while enabling (nested) attribute-style key access/assignment with IDE autocompletion support. 25 | 26 | Many libraries claim to do the same, but fail in different ways (see Competitors). 27 | 28 | ## Installation 29 | 30 | ```bash 31 | $ pip install metadict 32 | ``` 33 | ## Features 34 | 35 | - Attribute-style key access and assignment (dot notation) with IDE autocompletion support 36 | ```python 37 | from metadict import MetaDict 38 | 39 | cfg = MetaDict() 40 | cfg.optimizer = 'Adam' 41 | print(cfg.optimizer) 42 | >> Adam 43 | ``` 44 | ![autocompletion demo](/autocompletion.png?raw=true "Autocompletion demo") 45 | - Nested key assignment similar to `defaultdict` from `collections` 46 | ```python 47 | cfg = MetaDict(nested_assignment=True) 48 | cfg.model.type = 'Transformer' 49 | print(cfg.model.type) 50 | >> Transformer 51 | 52 | # or restrict nested assignment via context manager 53 | cfg = MetaDict() 54 | with cfg.enabling_nested_assignment() as cfg: 55 | cfg.model.type = 'Transformer' 56 | cfg.new_model.type = 'MLP' 57 | >> AttributeError: 'MetaDict' object has no attribute 'new_model' 58 | ``` 59 | - Is a `dict` 60 | ```python 61 | dict_config = {'model': 'Transformer', 62 | 'optimizer': 'Adam'} 63 | cfg = MetaDict(dict_config) 64 | print(isinstance(cfg, dict)) 65 | >> True 66 | print(cfg == dict_config) 67 | >> True 68 | ``` 69 | - Inbuilt `json` support 70 | ```python 71 | import json 72 | 73 | cfg = MetaDict({'model': 'Transformer'}) 74 | print(json.loads(json.dumps(cfg))) 75 | >> {'model': 'Transformer'} 76 | ``` 77 | - Recursive conversion to `dict` 78 | ```python 79 | cfg = MetaDict({'models': [{'name': 'Transformer'}, {'name': 'MLP'}]}) 80 | print(cfg.models[0].name) 81 | >> Transformer 82 | 83 | cfg_dict = cfg.to_dict() 84 | print(type(cfg_dict['models'][0])) 85 | >> 86 | 87 | # Note: Appending a `dict` to a list within a `MetaDict` does not convert the `dict`. 88 | # MetaDict does not overwrite `list` so intercepting `append`. `extend`, etc. is currently not possible. 89 | # Simply wrap the appended or extended `dict` as a `MetaDict`. 90 | cfg.models.append({'name': 'RNN'}) 91 | print(isinstance(cfg.models[-1], MetaDict)) 92 | >> False 93 | 94 | cfg.models.append(MetaDict({'name': 'RNN'})) 95 | print(isinstance(cfg.models[-1], MetaDict)) 96 | >> True 97 | ``` 98 | - No namespace conflicts with inbuilt methods like `items()`, `update()`, etc. 99 | ```python 100 | cfg = MetaDict() 101 | # Key 'items' is assigned as in a normal dict, but a UserWarning is raised 102 | cfg.items = [1, 2, 3] 103 | >> UserWarning: 'MetaDict' object uses 'items' internally. 'items' can only be accessed via `obj['items']`. 104 | print(cfg) 105 | >> {'items': [1, 2, 3]} 106 | print(cfg['items']) 107 | >> [1, 2, 3] 108 | 109 | # But the items method is not overwritten! 110 | print(cfg.items) 111 | >> 112 | print(list(cfg.items())) 113 | >> [('items', [1, 2, 3])] 114 | ``` 115 | - References are preserved 116 | ```python 117 | params = [1, 2, 3] 118 | cfg = MetaDict({'params': params}) 119 | print(cfg.params is params) 120 | >> True 121 | 122 | model_dict = {'params': params} 123 | cfg = MetaDict(model=model_dict) 124 | print(cfg.model.params is params) 125 | >> True 126 | 127 | # Note: dicts are recursively converted to MetaDicts, thus... 128 | print(cfg.model is model_dict) 129 | >> False 130 | print(cfg.model == model_dict) 131 | >> True 132 | ``` 133 | 134 | ## Documentation 135 | 136 | Check the [Test Cases](https://github.com/LarsHill/metadict/blob/main/tests/test_metadict.py) for a complete overview of all **MetaDict** features. 137 | 138 | 139 | ## Competitors 140 | - [Addict](https://github.com/mewwts/addict) 141 | - No key autocompletion in IDE 142 | - Nested key assignment cannot be turned off 143 | - Newly assigned `dict` objects are not converted to support attribute-style key access 144 | - Shadows inbuilt type `Dict` 145 | - [Prodict](https://github.com/ramazanpolat/prodict) 146 | - No key autocompletion in IDE without defining a static schema (similar to `dataclass`) 147 | - No recursive conversion of `dict` objects when embedded in `list` or other inbuilt iterables 148 | - [AttrDict](https://github.com/bcj/AttrDict) 149 | - No key autocompletion in IDE 150 | - Converts `list` objects to `tuple` behind the scenes 151 | - [Munch](https://github.com/Infinidat/munch) 152 | - Inbuilt methods like `items()`, `update()`, etc. can be overwritten with `obj.items = [1, 2, 3]` 153 | - No recursive conversion of `dict` objects when embedded in `list` or other inbuilt iterables 154 | - [EasyDict](https://github.com/makinacorpus/easydict) 155 | - Only strings are valid keys, but `dict` accepts all hashable objects as keys 156 | - Inbuilt methods like `items()`, `update()`, etc. can be overwritten with `obj.items = [1, 2, 3]` 157 | - Inbuilt methods don't behave as expected: `obj.pop('unknown_key', None)` raises an `AttributeError` 158 | 159 | 160 | ## Citation 161 | 162 | ``` 163 | @article{metadict, 164 | title = {MetaDict - Enabling dot notation and IDE autocompletion}, 165 | author = {Hillebrand, Lars}, 166 | year = {2022}, 167 | publisher = {GitHub}, 168 | journal = {GitHub repository}, 169 | howpublished = {\url{https://github.com/LarsHill/metadict}}, 170 | } 171 | ``` 172 | -------------------------------------------------------------------------------- /tests/test_metadict.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pickle 3 | from collections import namedtuple 4 | from typing import Dict, List, Tuple, Any 5 | 6 | import pytest 7 | 8 | from metadict import MetaDict 9 | from metadict.metadict import complies_variable_syntax 10 | 11 | 12 | @pytest.fixture 13 | def config() -> Dict: 14 | dropout = [[0.1, 0.2]] 15 | special_tokens = [['', '']] 16 | tokenizer_params = {'special_tokens': special_tokens} 17 | return {'epochs': 10, 18 | 'model': [{'type_': 'mlp', 19 | 'hidden_dims': [128, 256], 20 | 'dropout': dropout}, 21 | {'type_': 'lstm', 22 | 'hidden_dim': 128, 23 | 'dropout': dropout}], 24 | 'optimizer': {'type_': 'adam', 25 | 'lr': 1.e-3, 26 | 'weight_decay': True}, 27 | 'tokenizer_1': tokenizer_params, 28 | 'tokenizer_2': tokenizer_params} 29 | 30 | 31 | @pytest.fixture 32 | def list_of_tuples() -> List[Tuple]: 33 | return [('a', [1, 2, 3]), ('b', {'c': 6})] 34 | 35 | 36 | def test_init_from_dict(config: Dict): 37 | cfg = MetaDict(config) 38 | assert cfg == config 39 | 40 | 41 | def test_init_from_kwargs(config: Dict): 42 | cfg = MetaDict(**config) 43 | assert cfg == config 44 | 45 | 46 | def test_init_from_list_of_tuples(list_of_tuples: List[Tuple]): 47 | cfg = MetaDict(list_of_tuples) 48 | assert cfg == dict(list_of_tuples) 49 | 50 | 51 | def test_from_keys(config: Dict): 52 | cfg = MetaDict.fromkeys(config.keys()) 53 | config = dict.fromkeys(config.keys()) 54 | assert cfg == config 55 | 56 | 57 | def test_get_item(config: Dict): 58 | cfg = MetaDict(config) 59 | assert cfg['model'][0]['type_'] == config['model'][0]['type_'] 60 | with pytest.raises(KeyError) as _: 61 | _ = cfg['some_missing_key'] 62 | 63 | 64 | def test_get_attr(config: Dict): 65 | cfg = MetaDict(config) 66 | assert cfg.model[0].type_ == config['model'][0]['type_'] 67 | with pytest.raises(AttributeError) as _: 68 | _ = cfg.some_missing_attr 69 | 70 | 71 | def test_set_item(config: Dict): 72 | cfg = MetaDict(config) 73 | cfg[('new', 'key')] = 'new_value' 74 | assert cfg[('new', 'key')] == 'new_value' 75 | 76 | 77 | def test_set_attr(config: Dict): 78 | cfg = MetaDict(config) 79 | cfg.new_attr = {'new_key': 'new_value'} 80 | assert cfg.new_attr.new_key == 'new_value' 81 | 82 | 83 | def test_del_item(config: Dict): 84 | cfg = MetaDict(config) 85 | del cfg['model'][0]['type_'] 86 | del config['model'][0]['type_'] 87 | assert cfg == config 88 | with pytest.raises(KeyError) as _: 89 | del cfg['some_missing_key'] 90 | 91 | 92 | def test_del_attr(config: Dict): 93 | cfg = MetaDict(config) 94 | del cfg.model[0].type_ 95 | del config['model'][0]['type_'] 96 | assert cfg == config 97 | with pytest.raises(AttributeError) as _: 98 | del cfg.some_missing_attr 99 | 100 | 101 | def test_get(config: Dict): 102 | cfg = MetaDict(config) 103 | assert cfg.get('model') == config.get('model') 104 | assert cfg.get('some_new_key', 100) == 100 105 | 106 | 107 | def test_pop(config: Dict): 108 | cfg = MetaDict(config) 109 | assert cfg.pop('model') == config.pop('model') 110 | assert cfg.pop('some_new_key', 100) == 100 111 | 112 | 113 | def test_get_nested(config: Dict): 114 | cfg = MetaDict(config) 115 | assert cfg.tokenizer_1.get('special_tokens') == config['tokenizer_1'].get('special_tokens') 116 | 117 | 118 | def test_pop_nested(config: Dict): 119 | cfg = MetaDict(config) 120 | assert cfg.optimizer.pop('lr') == config['optimizer'].pop('lr') 121 | assert cfg == config 122 | 123 | 124 | def test_keys(config: Dict): 125 | cfg = MetaDict(config) 126 | assert cfg.keys() == config.keys() 127 | 128 | 129 | def test_values(config: Dict): 130 | cfg = MetaDict(config) 131 | assert list(cfg.values()) == list(config.values()) 132 | 133 | 134 | def test_items(config: Dict): 135 | cfg = MetaDict(config) 136 | assert cfg.items() == config.items() 137 | 138 | 139 | def test_update(config: Dict): 140 | cfg = MetaDict(config) 141 | cfg.update({'a': 1, 'b': 2}) 142 | config.update({'a': 1, 'b': 2}) 143 | assert cfg == config 144 | 145 | 146 | def test_contains(config: Dict): 147 | cfg = MetaDict(config) 148 | assert 'model' in cfg 149 | 150 | 151 | def test_copy(): 152 | cfg = MetaDict(a=1) 153 | cfg2 = cfg.copy() 154 | cfg2.a = 2 155 | assert cfg.a == 1 156 | assert cfg2.a == 2 157 | 158 | 159 | def test_copy_recursive(): 160 | cfg = MetaDict() 161 | cfg2 = MetaDict(a=cfg) 162 | cfg.a = cfg2 163 | cfg3 = cfg.copy() 164 | assert cfg3.a == cfg2 165 | assert cfg3.a.a == cfg 166 | assert cfg3.a.a.a == cfg2 167 | 168 | 169 | def test_str(config: Dict): 170 | cfg = MetaDict(config) 171 | assert str(cfg) == str(config) 172 | 173 | 174 | def test_repr(config: Dict): 175 | cfg = MetaDict(config) 176 | assert repr(cfg) == repr(config) 177 | 178 | 179 | def test_dir(config: Dict): 180 | cfg = MetaDict(config) 181 | assert set(dir(cfg)) == set(dir(MetaDict) + [key for key in cfg._data.keys() if complies_variable_syntax(key)]) 182 | 183 | 184 | def test_nested_assignment_default(config: Dict): 185 | cfg = MetaDict(config) 186 | with pytest.raises(AttributeError) as _: 187 | cfg.x.y.z = 100 188 | assert cfg.nested_assignment is False 189 | 190 | 191 | def test_nested_assignment(config: Dict): 192 | cfg = MetaDict(config) 193 | cfg.enable_nested_assignment() 194 | assert cfg.nested_assignment is True 195 | cfg.x.y.z = 100 196 | assert cfg.x.y.z == 100 197 | cfg.disable_nested_assignment() 198 | assert cfg.nested_assignment is False 199 | 200 | 201 | def test_nested_assignment_contextmanager(config: Dict): 202 | cfg = MetaDict(config) 203 | with cfg.enabling_nested_assignment() as cfg: 204 | assert cfg.nested_assignment is True 205 | cfg.x.y.z = 100 206 | assert cfg.x.y.z == 100 207 | assert cfg.nested_assignment is False 208 | 209 | 210 | def test_json(config: Dict): 211 | cfg = MetaDict(config) 212 | cfg_json = json.loads(json.dumps(cfg)) 213 | assert cfg_json == config 214 | assert type(cfg_json) == dict 215 | 216 | 217 | def test_pickle(config: Dict): 218 | cfg = MetaDict(config) 219 | cfg_pickle = pickle.loads(pickle.dumps(cfg)) 220 | assert cfg_pickle == cfg 221 | assert isinstance(cfg_pickle, MetaDict) 222 | 223 | 224 | def test_pickle_with_nested_assignment(config: Dict): 225 | cfg = MetaDict(config) 226 | 227 | with cfg.enabling_nested_assignment() as cfg: 228 | cfg.x.y.z = 100 229 | cfg_pickle = pickle.loads(pickle.dumps(cfg)) 230 | assert cfg_pickle == cfg 231 | assert isinstance(cfg_pickle, MetaDict) 232 | assert cfg_pickle.nested_assignment is True 233 | assert cfg.nested_assignment is False 234 | 235 | 236 | def test_is_instance_dict(config: Dict): 237 | cfg = MetaDict(config) 238 | assert isinstance(cfg, dict) 239 | 240 | 241 | def test_to_dict(config: Dict): 242 | cfg = MetaDict(config) 243 | cfg_dict = cfg.to_dict() 244 | assert cfg_dict == config 245 | assert type(cfg_dict) == dict 246 | assert type(cfg_dict['model'][0]) == dict 247 | 248 | 249 | def test_references(config: Dict): 250 | cfg = MetaDict(config) 251 | assert cfg.tokenizer_1.special_tokens is config['tokenizer_1']['special_tokens'] 252 | 253 | 254 | def test_append_dict_to_list(config: Dict): 255 | cfg = MetaDict(config) 256 | cfg.model.append({'type_': 'gru'}) 257 | assert type(cfg.model[-1]) == dict 258 | 259 | cfg.model.append(MetaDict({'type_': 'gru'})) 260 | assert isinstance(cfg.model[-1], MetaDict) 261 | 262 | 263 | @pytest.mark.parametrize("value", ['wrong_type', 999]) 264 | def test_init_type_checks(value): 265 | with pytest.raises(TypeError) as _: 266 | MetaDict(config, nested_assignment=value) 267 | 268 | 269 | def test_warning_protected_key(): 270 | with pytest.warns(UserWarning) as warn_inf: 271 | MetaDict(items=[1, 2, 3]) 272 | assert str(warn_inf.list[0].message) == "'MetaDict' object uses 'items' internally. " \ 273 | "'items' can only be accessed via `obj['items']`." 274 | 275 | 276 | @pytest.mark.parametrize("name, expected", [('models', True), ('text_100', True), ('1name', False), ('&%?=99', False), 277 | ('100', False), (100, False), ((1, 2, 3), False)]) 278 | def test_complies_variable_syntax(name: Any, expected: bool): 279 | assert complies_variable_syntax(name) == expected 280 | 281 | 282 | def test_namedtuple(): 283 | named_tuple = namedtuple('NT', ['a', 'b']) 284 | 285 | d1 = MetaDict(k=named_tuple(1, 2)) 286 | d2 = MetaDict(k=named_tuple(1, {2: 3})) 287 | d3 = MetaDict(k=(1, [2, 3])) 288 | assert d1.k.a == 1 289 | assert d2.k.b == {2: 3} 290 | assert d3 == {'k': (1, [2, 3])} 291 | -------------------------------------------------------------------------------- /metadict/metadict.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import copy 3 | import keyword 4 | import re 5 | import warnings 6 | from collections.abc import KeysView 7 | from typing import MutableMapping, Dict, Iterator, TypeVar, Iterable, Optional, Mapping, Any, Tuple 8 | 9 | 10 | def _warning(message, category=UserWarning, filename='', lineno=-1, file=None, line=''): 11 | """Monkey patch `warnings` to show UserWarning without the line information of warnings call.""" 12 | msg = warnings.WarningMessage(message, category, filename, lineno, file, line) 13 | print(f'{msg.category.__name__}: {msg.message}') 14 | 15 | 16 | warnings.showwarning = _warning 17 | 18 | KT = TypeVar('KT') 19 | VT = TypeVar('VT') 20 | 21 | # regex to enforce python variable/attribute syntax 22 | ALLOWED_VAR_SYNTAX = re.compile(r'[a-zA-Z_]\w*') 23 | 24 | 25 | def complies_variable_syntax(name: Any) -> bool: 26 | """Checks whether a given object is a string which complies the python variable syntax.""" 27 | if not isinstance(name, str) or keyword.iskeyword(name): 28 | return False 29 | name_cleaned = ''.join(re.findall(ALLOWED_VAR_SYNTAX, name)) 30 | return name_cleaned == name 31 | 32 | 33 | class MetaDict(MutableMapping[KT, VT], dict): 34 | """Class that extends `dict` to access and assign keys via attribute dot notation. 35 | 36 | Example: 37 | d = MetaDict({'foo': {'bar': [{'a': 1}, {'a': 2}]}}) 38 | print(d.foo.bar[1].a) 39 | >> 2 40 | print(d["foo"]["bar"][1]["a"]) 41 | >> 2 42 | 43 | `MetaDict` inherits from MutableMapping to avoid overwriting all `dict` methods. 44 | In addition, it inherits from `dict` to pass the quite common `isinstance(obj, dict) check. 45 | Also, inheriting from `dict` enables json encoding/decoding without a custom encoder. 46 | """ 47 | 48 | def __init__( 49 | self, 50 | *args, 51 | nested_assignment: bool = False, 52 | **kwargs 53 | ) -> None: 54 | 55 | # check that 'nested_assignment' is of type bool 56 | if not isinstance(nested_assignment, bool): 57 | raise TypeError(f"Keyword argument 'nested_assignment' must be an instance of type 'bool'") 58 | 59 | # init internal attributes and data store 60 | self.__dict__['_data']: Dict[KT, VT] = {} 61 | self.__dict__['_nested_assignment'] = nested_assignment 62 | self.__dict__['_parent'] = kwargs.pop('_parent', None) 63 | self.__dict__['_key'] = kwargs.pop('_key', None) 64 | 65 | # update state of data store 66 | self.update(*args, **kwargs) 67 | 68 | # call `dict` constructor with stored data to enable object encoding (e.g. `json.dumps()`) that relies on `dict` 69 | dict.__init__(self, self._data) 70 | 71 | def __len__(self) -> int: 72 | return len(self._data) 73 | 74 | def __iter__(self) -> Iterator[KT]: 75 | return iter(self._data) 76 | 77 | def __setitem__(self, key: KT, value: VT) -> None: 78 | # show a warning if the assigned key or attribute is used internally (e.g `items`, `keys`, etc.) 79 | try: 80 | self.__getattribute__(key) 81 | key_is_protected = True 82 | except (AttributeError, TypeError): 83 | key_is_protected = False 84 | if key_is_protected: 85 | warnings.warn(f"'{self.__class__.__name__}' object uses '{key}' internally. " 86 | f"'{key}' can only be accessed via `obj['{key}']`.") 87 | 88 | # set key recursively 89 | self._data[key] = self._from_object(value) 90 | 91 | # update parent when nested keys or attributes are assigned 92 | parent = self.__dict__.pop('_parent', None) 93 | key = self.__dict__.get('_key', None) 94 | if parent is not None: 95 | parent[key] = self._data 96 | 97 | def __getitem__(self, key: KT) -> VT: 98 | try: 99 | value = self._data[key] 100 | except KeyError: 101 | if self.nested_assignment: 102 | return self.__missing__(key) 103 | raise 104 | 105 | return value 106 | 107 | def __missing__(self, key: KT) -> 'MetaDict': 108 | return self.__class__(_parent=self, _key=key, nested_assignment=self._nested_assignment) 109 | 110 | def __delitem__(self, key: KT) -> None: 111 | del self._data[key] 112 | 113 | def __setattr__(self, attr: str, val: VT) -> None: 114 | self[attr] = val 115 | 116 | def __getattr__(self, key: KT) -> VT: 117 | try: 118 | return self[key] 119 | except KeyError: 120 | raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'") from None 121 | 122 | def __delattr__(self, key: KT) -> None: 123 | try: 124 | del self[key] 125 | except KeyError: 126 | raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'") from None 127 | 128 | def __str__(self) -> str: 129 | return str(self._data) 130 | 131 | def __repr__(self) -> str: 132 | return repr(self._data) 133 | 134 | @staticmethod 135 | def repack_args(cls: type, state: Dict) -> 'MetaDict': 136 | """Repack and rename keyword arguments stored in state before feeding to class constructor""" 137 | _data = state.pop('_data') 138 | _nested_assignment = state.pop('_nested_assignment') 139 | return cls(_data, nested_assignment=_nested_assignment, **state) 140 | 141 | def __reduce__(self) -> Tuple: 142 | """Return state information for pickling.""" 143 | return MetaDict.repack_args, (self.__class__, self.__dict__) 144 | 145 | def __dir__(self) -> Iterable[str]: 146 | """Extend dir list with accessible dict keys (enables autocompletion when using dot notation)""" 147 | dict_keys = [key for key in self._data.keys() if complies_variable_syntax(key)] 148 | return dir(type(self)) + dict_keys 149 | 150 | def copy(self) -> 'MetaDict': 151 | return self.__copy__() 152 | 153 | def __copy__(self) -> 'MetaDict': 154 | cls = self.__class__ 155 | result = cls.__new__(cls) 156 | result.__dict__.update({k: copy.copy(v) for k, v in self.__dict__.items()}) 157 | return result 158 | 159 | @classmethod 160 | def fromkeys(cls, iterable: Iterable[KT], value: Optional[VT] = None) -> 'MetaDict': 161 | return cls({key: value for key in iterable}) 162 | 163 | def to_dict(self) -> Dict: 164 | return MetaDict._to_object(self._data) 165 | 166 | @staticmethod 167 | def _to_object(obj: Any) -> Any: 168 | """Recursively converts all nested MetaDicts to dicts.""" 169 | 170 | if isinstance(obj, (list, tuple, set)): 171 | if MetaDict._contains_mapping(obj): 172 | # Recursively process each item in the iterable 173 | processed_items = (MetaDict._to_object(x) for x in obj) 174 | 175 | # namedtuple and other tuple subclasses need their arguments unpacked, 176 | # whereas list and set constructors take a single iterable. 177 | if isinstance(obj, tuple): 178 | value = type(obj)(*processed_items) 179 | else: 180 | value = type(obj)(processed_items) 181 | else: 182 | value = obj 183 | elif isinstance(obj, Mapping): 184 | value = {k: MetaDict._to_object(v) for k, v in obj.items()} 185 | else: 186 | value = obj 187 | 188 | return value 189 | 190 | def _from_object(self, obj: Any) -> Any: 191 | """Recursively converts all nested dicts to MetaDicts.""" 192 | 193 | if isinstance(obj, (list, tuple, set)): 194 | if MetaDict._contains_mapping(obj): 195 | # Recursively process each item in the iterable 196 | processed_items = (self._from_object(x) for x in obj) 197 | 198 | # namedtuple and other tuple subclasses need their arguments unpacked, 199 | # whereas list and set constructors take a single iterable. 200 | if isinstance(obj, tuple): 201 | value = type(obj)(*processed_items) 202 | else: 203 | value = type(obj)(processed_items) 204 | else: 205 | value = obj 206 | elif isinstance(obj, MetaDict): 207 | value = obj 208 | elif isinstance(obj, Mapping): 209 | value = self.__class__({k: self._from_object(v) for k, v in obj.items()}, 210 | nested_assignment=self._nested_assignment) 211 | else: 212 | value = obj 213 | 214 | return value 215 | 216 | def _set_nested_assignment(self, val: bool): 217 | self.__dict__['_nested_assignment'] = val 218 | for key, value in self.items(): 219 | if isinstance(value, (list, tuple, set)): 220 | for elem in value: 221 | if isinstance(elem, MetaDict): 222 | elem._set_nested_assignment(val) 223 | elif isinstance(value, MetaDict): 224 | value._set_nested_assignment(val) 225 | 226 | def enable_nested_assignment(self): 227 | self._set_nested_assignment(True) 228 | 229 | def disable_nested_assignment(self): 230 | self._set_nested_assignment(False) 231 | 232 | @contextlib.contextmanager 233 | def enabling_nested_assignment(self): 234 | """Context manager which temporarily enables nested key/attribute assignment.""" 235 | nested_assignment = self.nested_assignment 236 | if not nested_assignment: 237 | self.enable_nested_assignment() 238 | try: 239 | yield self 240 | finally: 241 | if not nested_assignment: 242 | self.disable_nested_assignment() 243 | 244 | @property 245 | def nested_assignment(self): 246 | return self._nested_assignment 247 | 248 | @staticmethod 249 | def _contains_mapping(iterable: Iterable, ignore: Optional[type] = None) -> bool: 250 | """Recursively checks whether an Iterable contains an instance of Mapping.""" 251 | for x in iterable: 252 | if isinstance(x, Mapping): 253 | if ignore is None or not isinstance(x, ignore): 254 | return True 255 | elif isinstance(x, (list, set, tuple)): 256 | return MetaDict._contains_mapping(x, ignore) 257 | return False 258 | 259 | # add the following inherited methods from collections.abc.Mapping directly to make pycharm happy 260 | # (removing an annoying warning for dict unpacking) 261 | def __contains__(self, key): 262 | try: 263 | self[key] 264 | except KeyError: 265 | return False 266 | else: 267 | return True 268 | 269 | def keys(self): 270 | """D.keys() -> a set-like object providing a view on D's keys""" 271 | return KeysView(self) 272 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------