├── 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 | [](https://www.python.org/downloads/release/python-360/)
15 | [](https://badge.fury.io/py/metadict)
16 | [](https://circleci.com/gh/LarsHill/metadict/tree/main)
17 | [](https://codecov.io/gh/LarsHill/metadict)
18 | [](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 | 
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 |
--------------------------------------------------------------------------------