├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── doc.md │ ├── feature_request.md │ └── task.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint.yml │ ├── release.yml │ ├── test.yml │ └── type.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── LICENSE.md ├── Makefile ├── README.md ├── docs ├── index.md └── tutorial │ ├── main.py │ └── test.txt ├── mkdocs.yml ├── mypy.ini ├── pyproject.toml ├── pytype.cfg ├── scripts ├── format-imports.sh ├── format.sh └── lint.sh ├── src └── jdict │ ├── __init__.py │ └── transformer.py └── test └── unit ├── test_jdict.py └── test_typed_jdict.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B,B9 4 | ignore = E203, E501, W503, C812 5 | exclude = __init__.py -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | 14 | **To Reproduce** 15 | Steps to reproduce the behavior: 16 | 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | 26 | **Extra info:** 27 | - OS: [e.g. Amazon Linux 2] 28 | - Affected Version 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Create a documentation issue and assign 4 | title: "[DOC]" 5 | labels: 'documentation' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the documentation issue** 11 | A clear and concise description of what is wrong with our documentation. 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Task 3 | about: Create a task and assign 4 | title: "[TASK]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the task** 11 | A clear and concise description of what the task is. 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Change Summary 2 | 3 | ## Description 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | 17 | ## Checklist: 18 | 19 | - [ ] My code follows the style guidelines of this project 20 | - [ ] I have performed a self-review of my own code 21 | - [ ] I have commented my code, particularly in hard-to-understand areas 22 | - [ ] I have made corresponding changes to the documentation 23 | - [ ] My changes generate no new warnings 24 | - [ ] I have added tests that prove my fix is effective or that my feature works 25 | - [ ] New and existing unit tests pass locally with my changes 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - main 7 | - develop 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.9.6 17 | architecture: x64 18 | - run: pip install flake8==4.0.1 19 | - run: pip install black==21.9b0 20 | - run: pip install isort==5.9.3 21 | - run: scripts/lint.sh 22 | shell: bash 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy-package: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.9.6 16 | architecture: x64 17 | - run: python -m pip install --upgrade pip 18 | - run: pip install flit==3.6.0 19 | - name: Build and publish 20 | env: 21 | FLIT_USERNAME: __token__ 22 | FLIT_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 23 | run: flit publish -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - main 7 | - develop 8 | 9 | jobs: 10 | test: 11 | name: test py${{ matrix.python-version }} on linux 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ['3.8', '3.9.6'] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - run: pip install flit==3.6.0 23 | - run: flit install 24 | - run: cd test/unit; python3 -m unittest test_jdict.py; python3 -m unittest test_typed_jdict.py 25 | -------------------------------------------------------------------------------- /.github/workflows/type.yml: -------------------------------------------------------------------------------- 1 | name: Type 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - main 7 | - develop 8 | 9 | jobs: 10 | type: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.9.6 17 | architecture: x64 18 | - run: pip install pytype==2022.1.31 19 | - run: pytype --config=pytype.cfg src/jdict/*.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .venv 3 | build 4 | dist 5 | opencli.egg-info 6 | dynacli.egg-info 7 | __pycache__/ 8 | *.py[cod] 9 | .dmypy.json 10 | .pytype -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.9 3 | 4 | default_stages: [commit, push] 5 | 6 | repos: 7 | - repo: local 8 | hooks: 9 | - id: formatter 10 | name: formatter 11 | entry: scripts/format-imports.sh 12 | language: script 13 | types: [python] 14 | pass_filenames: false 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestArgs": [ 3 | "-v", 4 | "-s", 5 | "./test/unit", 6 | "-p", 7 | "*test*.py" 8 | ], 9 | "python.testing.pytestEnabled": false, 10 | "python.testing.unittestEnabled": true, 11 | "editor.formatOnSaveMode": "file", 12 | "editor.formatOnSave": true, 13 | "editor.codeActionsOnSave": { 14 | "source.organizeImports": true, 15 | }, 16 | "python.linting.enabled": true, 17 | "python.linting.pylintEnabled": true, 18 | "python.formatting.provider": "black", 19 | "workbench.colorCustomizations": { 20 | "titleBar.activeBackground": "#4406d3" 21 | }, 22 | "python.pythonPath": "/bin/python3", 23 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 BSTLabs 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: test_all release 2 | # 3 | # No practical reason for running separate remote integrated test 4 | # This library does not perform any file i/o and is unlikely candidate for patching 5 | # Remote integrated tests take too much time 6 | # 7 | 8 | release: 9 | cp -R ./src/* ~/$(CAIOS_VERSION)/ 10 | 11 | test_all: 12 | caios test run unit local 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JDICT 2 | 3 | JavaScript-like Python dictionary. 4 | For background and design description look at this [Medium article](https://medium.com/swlh/jdict-javascript-dict-in-python-e7a5383939ab). 5 | 6 | ## Installation 7 | 8 | Use the package manager [pip](https://pip.pypa.io/en/stable/) to install `jdict` from the PyPi site: 9 | 10 | ```bash 11 | pip3 install pyjdict 12 | ``` 13 | 14 | > The package name is `pyjdict`, but the module name is `jdict`. 15 | 16 | ## What is jdict? 17 | 18 | Let's imagine we have some deeply nested json structure as following(this is not really deep, but it's enough for the sake of example): 19 | 20 | ```json 21 | { 22 | "status": True, 23 | "data": { 24 | "file": { 25 | "url": { 26 | "short": "https://anonfiles.com/t3l5S2Gex3", 27 | "full": "https://anonfiles.com/t3l5S2Gex3/test_txt", 28 | }, 29 | "metadata": { 30 | "size": {"bytes": 19, "readable": "19 B"}, 31 | "name": "test_txt", 32 | "id": "t3l5S2Gex3", 33 | }, 34 | } 35 | }, 36 | } 37 | ``` 38 | 39 | Now, you need get the `id` of this data, yes you are going to feel the pain as: `this_awesome_json["data"]["file"]["metadata"]["id"]`. 40 | 41 | But how about accessing this id as: `this_awesome_json.data.file.metadata.id`? Even for loop and assign values directly using .[dot] access? 42 | 43 | That's where the jdict shines. It is lightweight, nearly zero cost library converts your dictionaries to special jdict type and you are ready to go. 44 | 45 | ## Usage 46 | 47 | Now let's build small script to show the jdict. We are going to use anonymous file upload public API. 48 | 49 | ```py 50 | import requests 51 | import json 52 | 53 | from jdict import jdict, set_json_decoder 54 | 55 | 56 | # Send post request and upload the test.txt file - you can create one 57 | def _upload_file() -> str: 58 | file = {"file": open("test.txt", "rb")} 59 | result = requests.post("https://api.anonfiles.com/upload", files=file) 60 | result_dict = result.json() 61 | # Feel the pain here 62 | return result_dict["data"]["file"]["metadata"]["id"] 63 | 64 | # Send get request and get back the json information about the uploaded file 65 | def _get_file_info() -> dict: 66 | url = f"https://api.anonfiles.com/v2/file/{_upload_file()}/info" 67 | return requests.get(url).json() 68 | 69 | # Change codec to use jdict 70 | def _convert_to_jdict() -> jdict: 71 | set_json_decoder(json) 72 | return _get_file_info() 73 | ``` 74 | 75 | The killer point here is to change the codec and then convert our dictionary to jdict. 76 | 77 | If you are using [simplejson](https://pypi.org/project/simplejson/), just pass it as `pyjdict.set_json_decoder(simplejson)`, it will do the same trick. 78 | 79 | Great, now we are ready to use it: 80 | 81 | ```py 82 | if __name__ == "__main__": 83 | """ 84 | Sample data: 85 | { 86 | "status": True, 87 | "data": { 88 | "file": { 89 | "url": { 90 | "short": "https://anonfiles.com/t3l5S2Gex3", 91 | "full": "https://anonfiles.com/t3l5S2Gex3/test_txt", 92 | }, 93 | "metadata": { 94 | "size": {"bytes": 19, "readable": "19 B"}, 95 | "name": "test_txt", 96 | "id": "t3l5S2Gex3", 97 | }, 98 | } 99 | }, 100 | } 101 | """ 102 | data = _convert_to_jdict() 103 | # Get the id: 104 | print("ID: ", data.data.file.metadata.id) 105 | # Use in for loop: 106 | for key, value in data.data.file.metadata.items(): 107 | print(key, value) 108 | # Set the id: 109 | data.data.file.metadata.id = "MYID" 110 | print("ID: ", data.data.file.metadata.id) 111 | 112 | ``` 113 | 114 | Sample output: 115 | 116 | ```sh 117 | $ python3 main.py 118 | 119 | ID: h8p2SbGfx2 120 | size {'bytes': 19, 'readable': '19 B'} 121 | name test_txt 122 | id h8p2SbGfx2 123 | ID: MYID 124 | ``` 125 | 126 | ## Patching 127 | 128 | The next crucial feature is to ability to path core libraries with jdict. 129 | 130 | Just think about the boto3 library, with AWS you may encounter really deeply nested json structures, 131 | with jdict you can access those nested values with `.[dot]` notation as well. 132 | 133 | By patching `botocore.parsers` you gain really powerful tooling to work with: 134 | 135 | ```py 136 | import os 137 | import boto3 138 | import jdict 139 | 140 | jdict.patch_module('botocore.parsers') 141 | 142 | def test_library(): 143 | response = boto3.client('s3').list_buckets() 144 | assert(response.Buckets == response['Buckets']) 145 | return 'OK' 146 | ``` 147 | 148 | > Just keep in mind that you need to have valid AWS credentials to run this code 149 | 150 | 151 | ## License 152 | 153 | MIT License, Copyright (c) 2021-2022 BST LABS. See [LICENSE](LICENSE.md) file. 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Let's start here -------------------------------------------------------------------------------- /docs/tutorial/main.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from jdict import jdict, set_json_decoder 5 | 6 | 7 | 8 | def _upload_file() -> str: 9 | file = {"file": open("test.txt", "rb")} 10 | result = requests.post("https://api.anonfiles.com/upload", files=file) 11 | result_dict = result.json() 12 | # Feel the pain here 13 | return result_dict["data"]["file"]["metadata"]["id"] 14 | 15 | 16 | def _get_file_info() -> dict: 17 | url = f"https://api.anonfiles.com/v2/file/{_upload_file()}/info" 18 | return requests.get(url).json() 19 | 20 | 21 | def _convert_to_jdict() -> jdict: 22 | set_json_decoder(json) 23 | return _get_file_info() 24 | 25 | 26 | if __name__ == "__main__": 27 | """ 28 | Sample data: 29 | { 30 | "status": True, 31 | "data": { 32 | "file": { 33 | "url": { 34 | "short": "https://anonfiles.com/t3l5S2Gex3", 35 | "full": "https://anonfiles.com/t3l5S2Gex3/test_txt", 36 | }, 37 | "metadata": { 38 | "size": {"bytes": 19, "readable": "19 B"}, 39 | "name": "test_txt", 40 | "id": "t3l5S2Gex3", 41 | }, 42 | } 43 | }, 44 | } 45 | """ 46 | data = _convert_to_jdict() 47 | # Get the id: 48 | print("ID: ", data.data.file.metadata.id) 49 | # Use in for loop: 50 | for key, value in data.data.file.metadata.items(): 51 | print(key, value) 52 | # Assign values easily: 53 | data.data.file.metadata.id = "MYID" 54 | print("ID: ", data.data.file.metadata.id) -------------------------------------------------------------------------------- /docs/tutorial/test.txt: -------------------------------------------------------------------------------- 1 | File to be uploaded -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: JDict 2 | site_description: JDict, JavaScript like Python dicionaries. 3 | site_url: "" 4 | theme: 5 | name: material 6 | palette: 7 | primary: "deep blue" 8 | accent: "indigo" 9 | features: 10 | - content.code.annotate 11 | 12 | nav: 13 | - JDict: "index.md" 14 | 15 | markdown_extensions: 16 | - pymdownx.highlight: 17 | anchor_linenums: true 18 | - pymdownx.inlinehilite 19 | - pymdownx.snippets 20 | - pymdownx.superfences 21 | - pymdownx.details 22 | - admonition 23 | - attr_list 24 | - md_in_html 25 | - toc: 26 | toc_depth: "1-1" 27 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | # --strict 4 | disallow_any_generics = True 5 | disallow_subclassing_any = True 6 | disallow_untyped_calls = True 7 | disallow_untyped_defs = True 8 | disallow_incomplete_defs = True 9 | check_untyped_defs = True 10 | disallow_untyped_decorators = True 11 | no_implicit_optional = True 12 | warn_redundant_casts = True 13 | warn_unused_ignores = True 14 | warn_return_any = True 15 | implicit_reexport = False 16 | strict_equality = True 17 | # --strict end -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pyjdict" 7 | authors = [{name = "BST Labs", email = "bstlabs@caios.io"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE.md"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | requires-python = ">=3.8" 13 | 14 | 15 | [project.urls] 16 | Documentation = "https://github.com/bstlabs/py-jdict" 17 | Source = "https://github.com/bstlabs/py-jdict" 18 | Home = "https://github.com/bstlabs/py-jdict" 19 | 20 | [project.optional-dependencies] 21 | doc = ["mkdocs-material >=8.1.2"] 22 | dev = [ 23 | "black >=21.9b0", 24 | "pylint >=2.12.2", 25 | "isort >=5.9.3", 26 | "autoflake >=1.4", 27 | "flake8 >=4.0.1", 28 | "pre-commit >=2.17.0", 29 | "pytype >=2022.1.31" 30 | ] 31 | 32 | [tool.isort] 33 | profile = "black" 34 | py_version = 39 35 | skip = [".gitignore", ".dockerignore"] 36 | extend_skip = [".md", ".json"] 37 | skip_glob = ["docs/*"] 38 | 39 | [tool.black] 40 | line-length = 88 41 | target-version = ['py39'] 42 | include = '\.pyi?$' 43 | 44 | [tool.flit.module] 45 | name = "jdict" -------------------------------------------------------------------------------- /pytype.cfg: -------------------------------------------------------------------------------- 1 | # NOTE: All relative paths are relative to the location of this file. 2 | 3 | [pytype] 4 | 5 | # Space-separated list of files or directories to exclude. 6 | exclude = 7 | **/*_test.py 8 | **/test_*.py 9 | 10 | # Space-separated list of files or directories to process. 11 | inputs = 12 | . 13 | 14 | # Keep going past errors to analyze as many files as possible. 15 | keep_going = False 16 | 17 | # Run N jobs in parallel. When 'auto' is used, this will be equivalent to the 18 | # number of CPUs on the host system. 19 | jobs = 4 20 | 21 | # All pytype output goes here. 22 | output = .pytype 23 | 24 | # Paths to source code directories, separated by ':'. 25 | pythonpath = 26 | . 27 | 28 | # Python version (major.minor) of the target code. 29 | python_version = 3.9 30 | 31 | # Use the enum overlay for more precise enum checking. This flag is temporary 32 | # and will be removed once this behavior is enabled by default. 33 | use_enum_overlay = True 34 | 35 | # Allow recursive type definitions. This flag is temporary and will be removed 36 | # once this behavior is enabled by default. 37 | # allow_recursive_types = True 38 | 39 | # Build dict literals from dict(k=v, ...) calls. This flag is temporary and will 40 | # be removed once this behavior is enabled by default. 41 | build_dict_literals_from_kwargs = True 42 | 43 | # Enable stricter namedtuple checks, such as unpacking and 'typing.Tuple' 44 | # compatibility. This flag is temporary and will be removed once this behavior 45 | # is enabled by default. 46 | strict_namedtuple_checks = True 47 | 48 | # Enable support for TypedDicts. This flag is temporary and will be removed once 49 | # this behavior is enabled by default. 50 | enable_typed_dicts = True 51 | 52 | # Solve unknown types to label with structural types. This flag is temporary and 53 | # will be removed once this behavior is enabled by default. 54 | protocols = True 55 | 56 | # Only load submodules that are explicitly imported. This flag is temporary and 57 | # will be removed once this behavior is enabled by default. 58 | # strict_import = Only load submodules that are explicitly imported. 59 | strict_import = True 60 | 61 | # # Infer precise return types even for invalid function calls. This flag is 62 | # # temporary and will be removed once this behavior is enabled by default. 63 | # precise_return = Infer precise return types even for invalid function calls. 64 | precise_return = True 65 | 66 | # # Comma or space separated list of error names to ignore. 67 | disable = 68 | pyi-error, attribute-error 69 | 70 | # # Don't report errors. 71 | report_errors = True 72 | -------------------------------------------------------------------------------- /scripts/format-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | # Sort imports one per line, so autoflake can remove unused imports 5 | isort src test --force-single-line-imports 6 | sh ./scripts/format.sh -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place src test 5 | black src test 6 | isort src test -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | flake8 src 7 | black src test --check --diff 8 | isort src test --check --diff -------------------------------------------------------------------------------- /src/jdict/__init__.py: -------------------------------------------------------------------------------- 1 | """JavaScript-like Python dictionary""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import sys 7 | from copy import deepcopy 8 | from typing import Any, Final, Tuple, Union, get_args, get_origin, get_type_hints 9 | 10 | from .transformer import transform 11 | 12 | NoneType = type(None) 13 | 14 | __version__ = "1.0.6" 15 | 16 | 17 | class _Field: 18 | def __init__(self, name: str, arg: str, is_optional: bool, is_final: bool) -> None: 19 | """ 20 | Initialize one field descriptor 21 | 22 | :param name: original field name 23 | :param arg: how should it look like in __init__ argument list 24 | :param is_optional: is it optional? 25 | :param is_final: is it Final (could not be changed)? 26 | """ 27 | self.name = name 28 | self.arg = arg 29 | self.is_optional = is_optional 30 | self.is_final = is_final 31 | 32 | @staticmethod 33 | def _strip_final(t: type) -> str: 34 | """ 35 | Strip Final wrapper from the field type hint 36 | 37 | :param t: original field type hint Final[...] 38 | :return: type name for built ins and origin for type hints (e.g. Mapping[...] 39 | """ 40 | return repr(t) if get_origin(t) else t.__name__ 41 | 42 | @staticmethod 43 | def _get_type_hint(t: type) -> Tuple[str, bool, bool]: 44 | """ 45 | Determine correct type hint for __init__ argument corresponding to a field 46 | :param t: original type hint 47 | :return: type hint string, plus two flags indicating optional and final fields 48 | """ 49 | origin = get_origin(t) 50 | if origin: 51 | args = get_args(t) 52 | is_optional = Union == origin and NoneType in args 53 | is_final = Final == origin 54 | name = _Field._strip_final(args[0]) if is_final else repr(t) 55 | return f'{name}{"=None" if is_optional else ""}', is_optional, is_final 56 | return t.__name__, False, False 57 | 58 | @staticmethod 59 | def get_field(n: str, t: type) -> _Field: 60 | """ 61 | Create a field descriptor (factory method) 62 | 63 | :param n: field name 64 | :param t: field type hint 65 | :return: complete field descriptor 66 | """ 67 | type_hint, is_optional, is_final = _Field._get_type_hint(t) 68 | arg = f"{n}: {type_hint}" 69 | return _Field(n, arg, is_optional, is_final) 70 | 71 | 72 | def _build_pairs(fields: Tuple[_Field, ...]) -> str: 73 | """ 74 | Build sequence of name=value pairs to be passed to the base class constructor 75 | :param fields: list of field descriptors 76 | :return: argument list string to format base class __init__ invocation 77 | """ 78 | optionals = ", ".join(f'"{f.name}"' for f in fields if f.is_optional) 79 | if not optionals: 80 | return ", ".join(f"{f.name}={f.name}" for f in fields) 81 | pairs = ", ".join(f'("{f.name}", {f.name})' for f in fields) 82 | return f"tuple((n, v) for n, v in ({pairs}) if v is not None or n not in {{{optionals}}})" 83 | 84 | 85 | def _configure_init(cls, fields: Tuple[_Field, ...]) -> callable: 86 | """ 87 | Configure __init__ method for subclass 88 | 89 | :param cls: subclass 90 | :param fields: list of field descriptors 91 | :return: function performing correct initialization 92 | """ 93 | args = ", ".join(f.arg for f in fields) 94 | pairs = _build_pairs(fields) 95 | name = f"{cls.__name__}__init__" 96 | init_fn = ( 97 | f"def {name}(self, {args}) -> None:" + f"\n\tjdict.__init__(self, {pairs})" 98 | ) 99 | ns = {} 100 | exec(init_fn, globals(), ns) 101 | return ns[name] 102 | 103 | 104 | # noinspection PyPep8Naming 105 | class jdict(dict): 106 | """ 107 | The class gives access to the dictionary through the attribute name. 108 | """ 109 | 110 | def __init_subclass__(cls, /, **kwargs) -> None: 111 | """ 112 | Intercept subclass creation and configure a Record-type access based on field hints. 113 | Perhaps, implementing the same with meta-class would be more efficient and would support class hierarchy 114 | but at this stage, I'm not certain it's worth extra complexity 115 | For more details, look at https://www.python.org/dev/peps/pep-0487/ 116 | 117 | :param kwargs: ignored in this case and just passed to the base class 118 | """ 119 | super().__init_subclass__(**kwargs) 120 | hints = get_type_hints(cls) 121 | if not hints: 122 | raise ValueError("Empty field list") 123 | fields = tuple(_Field.get_field(n, t) for n, t in hints.items()) 124 | setattr(cls, "__init__", _configure_init(cls, fields)) 125 | 126 | def __getattr__(self, name: str) -> Any: 127 | """ 128 | Method returns the value of the named attribute of an object. If not found, it returns null object. 129 | :param name: str 130 | :return: Any 131 | """ 132 | try: 133 | return self.__getitem__(name) 134 | except KeyError: 135 | raise AttributeError(name + " not in dict") 136 | 137 | def __setattr__(self, key: str, value: Any) -> None: 138 | """ 139 | Method sets the value of given attribute of an object. 140 | :param key: str 141 | :param value: Any 142 | :return: None 143 | """ 144 | self.__setitem__(key, value) 145 | 146 | def __deepcopy__(self, memo) -> jdict: 147 | # Do not know why PyCharm complains about it 148 | # noinspection PyArgumentList 149 | copy = jdict((k, deepcopy(v, memo)) for k, v in self.items()) 150 | memo[id(self)] = copy 151 | return copy 152 | 153 | 154 | def patch_module(module: str) -> None: 155 | parsers: Final = sys.modules[module] 156 | filename: Final[str] = parsers.__dict__["__file__"] 157 | src: Final[str] = open(filename).read() 158 | inlined: Final = transform(src) 159 | code: Final = compile(inlined, filename, "exec") 160 | exec(code, vars(parsers)) 161 | 162 | 163 | def set_json_decoder(codec: json) -> None: 164 | codec._default_decoder = codec.JSONDecoder(object_pairs_hook=jdict) 165 | -------------------------------------------------------------------------------- /src/jdict/transformer.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import Any, Final 3 | 4 | JDICT: Final[str] = "jdict" # WPS226 Found string literal over-use 5 | 6 | 7 | class JdictTransformer(ast.NodeTransformer): 8 | """ 9 | The visitor class of the node that traverses, 10 | the abstract syntax tree and calls the visitor function 11 | for each node found. Inherits from class NodeTransformer 12 | """ 13 | 14 | def visit_Module(self, node: ast.AST) -> Any: 15 | """ 16 | Method imports jdict module into ast 17 | :param node: CodeType 18 | :return: node 19 | """ 20 | visited_node = self.generic_visit(node) 21 | import_node = ast.ImportFrom( 22 | module=JDICT, names=[ast.alias(name=JDICT)], level=0 23 | ) 24 | visited_node.body.insert(0, import_node) 25 | return visited_node 26 | 27 | def visit_Name(self, node: ast.Name) -> Any: 28 | """ 29 | Method checks if the id dict and modifies it to jdict 30 | :param node: 31 | :return: node 32 | """ 33 | if node.id == "dict": 34 | node.id = JDICT 35 | return self.generic_visit(node) 36 | 37 | def visit_Dict(self, node: ast.AST) -> Any: 38 | """ 39 | Method goes into the dict node and modifies it to jdict 40 | :param node: 41 | :return: 42 | """ 43 | node = self.generic_visit(node) 44 | name_node = ast.Name(id="jdict", ctx=ast.Load()) 45 | return ast.Call(func=name_node, args=[node], keywords=[]) 46 | 47 | 48 | def transform(src: str) -> Any: 49 | """ 50 | Transforms the given source to use jdict to replace built in structures. 51 | :param src: str 52 | :return: new_tree 53 | """ 54 | tree: Final = ast.parse(src) 55 | transformer: Final = JdictTransformer() 56 | new_tree = transformer.visit(tree) 57 | ast.fix_missing_locations(new_tree) 58 | return new_tree 59 | -------------------------------------------------------------------------------- /test/unit/test_jdict.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from copy import deepcopy 4 | from unittest import TestCase, main 5 | 6 | from jdict import jdict, set_json_decoder 7 | from jdict.transformer import transform 8 | 9 | 10 | class TestJdict(TestCase): 11 | def setUp(self): 12 | self.data = { 13 | "location": "DEF", 14 | "voyageId": "XYZ", 15 | "eventType": "UNLOAD", 16 | "completionTime": 1526897537633, 17 | } 18 | set_json_decoder(json) 19 | self.jdict = jdict(self.data) 20 | 21 | def test_jdict(self): 22 | self.assertEqual(self.data["location"], self.jdict.location) 23 | 24 | def test_transformer(self): 25 | self.new_tree = transform( 26 | 'self.data = {"location": "DEF", "voyageId": "XYZ", ' 27 | '"eventType": "UNLOAD", "completionTime": 1526897537633,}' 28 | '\nself.assertEqual(self.data.location, "DEF")' 29 | ) 30 | 31 | exec(compile(self.new_tree, "file", "exec")) 32 | 33 | def test_deepcopy(self): 34 | d = deepcopy(self.jdict) 35 | self.assertEqual(d, self.jdict) 36 | self.jdict.time = 12.25 37 | self.assertNotEqual(self.jdict, d) 38 | self.assertEqual(type(d), jdict) 39 | 40 | def test_datetime_serializer(self): 41 | self.assertRaises( 42 | TypeError, 43 | json.dumps, 44 | jdict(timestamp=datetime.datetime(2020, 10, 26)), 45 | indent=2, 46 | ) 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /test/unit/test_typed_jdict.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Final, Optional 3 | from unittest import TestCase, main 4 | 5 | from jdict import jdict, set_json_decoder 6 | 7 | 8 | class Point2D(jdict): 9 | x: Final[int] 10 | y: int 11 | label: Optional[str] 12 | 13 | 14 | class TestTypedJdict(TestCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | set_json_decoder(json) 18 | cls.point = Point2D(x=10, y=25, label="first point") 19 | 20 | def _validate_point(self, p) -> None: 21 | self.assertEqual(10, p.x) 22 | if issubclass(int, type(p.y)): 23 | self.assertEqual(25, p.y) 24 | else: 25 | self.assertNotEqual(25, p.y) 26 | if hasattr(p, "label"): 27 | self.assertEqual("first point", p.label) 28 | 29 | def test_normal_initialization(self): 30 | self._validate_point(self.point) 31 | 32 | def test_no_optional_initialization(self): 33 | self._validate_point(Point2D(x=10, y=25)) 34 | 35 | def test_wrong_type(self): 36 | p = Point2D(x=10, y="25", label="first point") # PyCharm will not complain :-( 37 | self._validate_point(p) 38 | 39 | def test_missing_argument(self): 40 | self.assertRaises( 41 | TypeError, Point2D, x=10, label="first point" 42 | ) # PyCharm will not complain, but it fails in run-time 43 | 44 | def test_update_existing(self): 45 | self.point.y = 45 46 | self.assertEqual(45, self.point.y) 47 | 48 | def test_update_optional(self): 49 | p = Point2D(x=10, y=25) 50 | p.label = "missing label" 51 | self.assertEqual("missing label", p.label) 52 | 53 | def test_update_final(self): 54 | self.point.x = 45 # static type checkers and linters will complain about Final 55 | self.assertEqual(45, self.point.x) 56 | 57 | def test_set_non_existent(self): 58 | self.point.z = 45 59 | self.assertEqual(45, self.point.z) 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | --------------------------------------------------------------------------------