├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── workflows │ └── publish_to_pypi.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bumpytrack.toml ├── docs-requirements.txt ├── docs ├── Makefile ├── conf.py ├── contrib │ ├── code_of_conduct.md │ ├── code_ref.md │ ├── coding.md │ ├── contributing.md │ ├── index.md │ └── setup.md ├── index.md ├── license.md ├── make.bat ├── objrefs │ ├── index.md │ ├── methods.md │ └── types.md ├── quickstart.md ├── readthedocs.yaml ├── snippets │ ├── seealso-zoho-api.markdown │ └── todo-future-release.markdown └── userguide │ ├── apiobject.md │ ├── exceptions.md │ ├── index.md │ ├── logging.md │ └── zohoobject.md ├── poetry.lock ├── pyproject.toml ├── pyzohoapi ├── __init__.py ├── books.py ├── core │ ├── __init__.py │ ├── collection.py │ └── utils.py ├── exceptions │ └── __init__.py ├── inventory.py └── objecttypes │ ├── __init__.py │ └── mixins │ ├── CompositeItemOps.py │ ├── CustomFields.py │ └── __init__.py ├── tests ├── __init__.py ├── inventory │ ├── test_inventory.py │ ├── test_inventory_compositeitems.py │ ├── test_inventory_currency.py │ ├── test_inventory_itemadjustments.py │ ├── test_inventory_itemtransfer.py │ ├── test_inventory_pricelist.py │ ├── test_inventory_purchasereceive.py │ └── test_inventory_setcustomfield.py ├── test_00_pyzohoapi.py └── test_core_first.py └── tools ├── interactive-test-server.py ├── serverfiles ├── interactive-test-server.css ├── interactive-test-server.html └── interactive-test-server.js └── test-shell.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Proposed changes 2 | 3 | Describe the big picture of your changes here to communicate to the maintainers 4 | why we should accept this pull request. If it fixes a bug or resolves a feature 5 | request, be sure to link to that issue. 6 | 7 | ## Types of changes 8 | 9 | What types of change(s) does your code introduce to pyZohoAPI? While we will 10 | take multi-faceted pull requests, we prefer each pull request address only a 11 | SINGLE one of the below. Any of the first three SHOULD ALSO include 12 | corresponding testing and possibly documentation updates. 13 | _Put an `x` in the box which applies_ 14 | 15 | - [ ] Bugfix (non-breaking change which fixes an issue) 16 | - [ ] New feature (non-breaking change which adds functionality) 17 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 18 | - [ ] Documentation Update (updates ONLY to the Sphinx or inline documentation) 19 | - [ ] Testing Update (updates ONLY to the tests) 20 | - [ ] Other - _Please describe here_ 21 | 22 | ## Checklist 23 | Before you submit a pull request, please make sure you have to following: 24 | 25 | _Put an `x` in the boxes that apply. You can also fill these out after creating 26 | the PR. If you're unsure about any of them, don't hesitate to ask. We're here to 27 | help! This is simply a reminder of what we are going to look for before merging 28 | your code._ 29 | 30 | - [ ] I have read the [Contributing](https://pyzohoapi.readthedocs.io/en/latest/contrib/) doc 31 | - [ ] I have read the [Code Of Conduct](https://pyzohoapi.readthedocs.io/en/latest/contrib/code_of_conduct.html) doc 32 | - [ ] Lint and unit tests pass locally with my changes 33 | - [ ] I have added tests that prove my fix is effective or that my feature works 34 | - [ ] I have added necessary documentation (if appropriate) 35 | 36 | ## Further comments 37 | 38 | If this is a relatively large or complex change, kick off the discussion by 39 | explaining why you chose the solution you did and what alternatives you 40 | considered, etc... 41 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | concurrency: 9 | group: publish 10 | 11 | jobs: 12 | build-and-publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out source repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install Poetry 24 | run: | 25 | curl -sSL https://install.python-poetry.org | python3 - 26 | 27 | - name: Build and Publish 28 | env: 29 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 30 | run: | 31 | poetry build 32 | poetry publish --username __token__ --password $PYPI_TOKEN -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local (my) stuff 2 | private/ 3 | 4 | ### Python ignores cribbed from https://github.com/github/gitignore/blob/master/Python.gitignore 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .nox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | *.py,cover 45 | .hypothesis/ 46 | .pytest_cache/ 47 | cover/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 57 | __pypackages__/ 58 | 59 | # mypy 60 | .mypy_cache/ 61 | .dmypy.json 62 | dmypy.json 63 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.12" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: docs-requirements.txt -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [v1.0.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v1.1.0) - 2024-05-25 8 | 9 | ### Added 10 | - Add publishing workflow ([e911ecd](https://github.com/tdesposito/pyZohoAPI/commit/e911ecd79ecfa4acc1145257e47dfe3a8501266b) by Todd Esposito). 11 | - Add books support ([7fc56a3](https://github.com/tdesposito/pyZohoAPI/commit/7fc56a3a8ae9c481bb962d627daa028a43f76cac) by Todd Esposito). 12 | 13 | ## [v0.10.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.10.0) - 2022-01-17 14 | 15 | [Compare with v0.9.1](https://github.com/tdesposito/pyZohoAPI/compare/v0.9.1...v0.10.0) 16 | 17 | ### Added 18 | - Added automatic decimal.decimal usage in zohoobjects ([d786e40](https://github.com/tdesposito/pyZohoAPI/commit/d786e4005ea888295989d58d73d4198d9e689997) by Todd Esposito). 19 | - Add decimal.decimal note to docs ([45694f7](https://github.com/tdesposito/pyZohoAPI/commit/45694f7eac56bff2cb739b2336c5aeac1faf6bf4) by Todd Esposito). 20 | 21 | ### Fixed 22 | - Fix typo ([93645ee](https://github.com/tdesposito/pyZohoAPI/commit/93645ee805fe5eedf1a1a8cb9ced0d3939fc4ccc) by Todd Esposito). 23 | - Fixed parameter name in __init__.py ([99c02c9](https://github.com/tdesposito/pyZohoAPI/commit/99c02c93a4576815800436cf67eef0b89cc1697c) by Shubham Agawane). 24 | 25 | 26 | ## [v0.9.1](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.9.1) - 2021-09-30 27 | 28 | [Compare with v0.9.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.9.0...v0.9.1) 29 | 30 | ### Changed 31 | - Changelog ([776354a](https://github.com/tdesposito/pyZohoAPI/commit/776354a49e330ef8cefbd610c69ab211408ffeb6) by Todd Esposito). 32 | 33 | ### Fixed 34 | - Fix shipmentorder key ([a52891f](https://github.com/tdesposito/pyZohoAPI/commit/a52891f2866a03f53e67b9056a9f2ee0310a9a63) by Todd Esposito). 35 | - Fix classifiers to include 3.9 ([e8449d7](https://github.com/tdesposito/pyZohoAPI/commit/e8449d760780346f8d352a7ff4949643a7848bab) by Todd Esposito). 36 | 37 | 38 | ## [v0.9.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.9.0) - 2021-09-30 39 | 40 | [Compare with v0.7.1](https://github.com/tdesposito/pyZohoAPI/compare/v0.7.1...v0.9.0) 41 | 42 | ### Added 43 | - Add logging to core ([3a0cb29](https://github.com/tdesposito/pyZohoAPI/commit/3a0cb2938e557c98fd3e33931b8df645fcb9359e) by Todd Esposito). 44 | - Add hasdeliver(), apply to shipmentorder ([4686e24](https://github.com/tdesposito/pyZohoAPI/commit/4686e245651d74991bc306812cd4eea5e8462ed7) by Todd Esposito). 45 | - Add custom fields to contacts ([d0f47ed](https://github.com/tdesposito/pyZohoAPI/commit/d0f47ed1daae94f9eb5149a71b47180089a92131) by Todd Esposito). 46 | - Add brand type ([1d085a4](https://github.com/tdesposito/pyZohoAPI/commit/1d085a45ef274faca6a08d82e770f662ed54ae3b) by Todd Esposito). 47 | 48 | ### Fixed 49 | - Fix: allow first() to filter ([e69451a](https://github.com/tdesposito/pyZohoAPI/commit/e69451aa7636a66b25649d093b086c50b10a775b) by Todd Esposito). 50 | 51 | 52 | ## [v0.7.1](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.7.1) - 2021-06-08 53 | 54 | [Compare with v0.7.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.7.0...v0.7.1) 55 | 56 | ### Fixed 57 | - Fix: missed removal of _req_fields reference after refactor ([f80e2c0](https://github.com/tdesposito/pyZohoAPI/commit/f80e2c0540f920aebb47c96eb86c26c76ddd0cea) by Todd Esposito). 58 | 59 | 60 | ## [v0.7.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.7.0) - 2021-05-30 61 | 62 | [Compare with v0.6.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.6.0...v0.7.0) 63 | 64 | ### Added 65 | - Add: warehouse type ([18c3530](https://github.com/tdesposito/pyZohoAPI/commit/18c3530e6d48fcdc11b761fb22506c51e59f449a) by Todd Esposito). 66 | - Add: tax (and related) types ([4705a96](https://github.com/tdesposito/pyZohoAPI/commit/4705a96996494c9ebb64d10d458b32150b38e6ef) by Todd Esposito). 67 | - Add: salesreturn type -- untested/incomplete ([d69da72](https://github.com/tdesposito/pyZohoAPI/commit/d69da722f8093583a0374cbd955d439c5df9bf0a) by Todd Esposito). 68 | - Add: retainerinvoice type -- untested/incomplete ([a98f3d7](https://github.com/tdesposito/pyZohoAPI/commit/a98f3d76fb5bee189e95be5fdc809d47f2a401f3) by Todd Esposito). 69 | - Add: purchasereceive type ([fbb6038](https://github.com/tdesposito/pyZohoAPI/commit/fbb603832c0f5783a547c90da3e91d6b8823530e) by Todd Esposito). 70 | - Add: pricelist type ([4aa6cf1](https://github.com/tdesposito/pyZohoAPI/commit/4aa6cf1269f10e4e5e41a12086c40d988df42a24) by Todd Esposito). 71 | - Add: currency type ([23c2a05](https://github.com/tdesposito/pyZohoAPI/commit/23c2a0581699412c29c2fd476024e581bc0c9980) by Todd Esposito). 72 | - Add: transferorder type ([d38199e](https://github.com/tdesposito/pyZohoAPI/commit/d38199e51c5ae135f9d209f18928a4fe815bd53a) by Todd Esposito). 73 | - Add: itemadjustment type ([2851bb0](https://github.com/tdesposito/pyZohoAPI/commit/2851bb0968424086dae57f63411f6cabfe244441) by Todd Esposito). 74 | 75 | ### Fixed 76 | - Fix: allow non-computed plural response keys ([ba11b8c](https://github.com/tdesposito/pyZohoAPI/commit/ba11b8cc67c208085077015d6db84d076820e616) by Todd Esposito). 77 | - Fix: include request url in response panel in the test-server ([9db661a](https://github.com/tdesposito/pyZohoAPI/commit/9db661a124eee85ee68f4786f239c03ab6f2fd81) by Todd Esposito). 78 | - Fix: clarify and make object factory params more flexible ([f3d3093](https://github.com/tdesposito/pyZohoAPI/commit/f3d3093237f3143768af7785575e08c156eae908) by Todd Esposito). 79 | 80 | 81 | ## [v0.6.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.6.0) - 2021-05-26 82 | 83 | [Compare with v0.5.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.5.0...v0.6.0) 84 | 85 | ### Added 86 | - Add makebundle() function for compositeitem object ([c8bbf95](https://github.com/tdesposito/pyZohoAPI/commit/c8bbf952259880771ca8c3f01f3c4408549d0203) by Todd Esposito). 87 | - Add "salesperson" object ([9f397d2](https://github.com/tdesposito/pyZohoAPI/commit/9f397d297237bb92591840eb5c8023f170c50cc1) by Todd Esposito). 88 | 89 | ### Fixed 90 | - Fix issue with test server url quoting "type" params ([265cef1](https://github.com/tdesposito/pyZohoAPI/commit/265cef1fe273713e42169af5759f9023d8a6ea88) by Todd Esposito). 91 | - Fix published debug-code! yikes! ([18be5b8](https://github.com/tdesposito/pyZohoAPI/commit/18be5b8fd622c855d77e05582385dd3040e8435a) by Todd Esposito). 92 | - Fix behavior on extending a list-of when paging ([65d564e](https://github.com/tdesposito/pyZohoAPI/commit/65d564eb75917b01038021dc627b59a717d50c71) by Todd Esposito). 93 | 94 | ### Removed 95 | - Remove superfluous file ([e2694b9](https://github.com/tdesposito/pyZohoAPI/commit/e2694b959d14a51f6bbd8c68dafca652389db5f4) by Todd Esposito). 96 | 97 | 98 | ## [v0.5.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.5.0) - 2021-03-13 99 | 100 | [Compare with v0.4.1](https://github.com/tdesposito/pyZohoAPI/compare/v0.4.1...v0.5.0) 101 | 102 | ### Added 103 | - Add lots of documentation ([8c2afd6](https://github.com/tdesposito/pyZohoAPI/commit/8c2afd6e26230b97da9b38363bef6746a6249bea) by Todd Esposito). 104 | - Add setcustomfield ([53de008](https://github.com/tdesposito/pyZohoAPI/commit/53de0085348e66efd047f59d1f91f44842aa16a4) by Todd Esposito). 105 | 106 | 107 | ## [v0.4.1](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.4.1) - 2021-03-03 108 | 109 | [Compare with v0.4.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.4.0...v0.4.1) 110 | 111 | ### Fixed 112 | - Fix retry around an unexpectedly invalid access_token ([c70df33](https://github.com/tdesposito/pyZohoAPI/commit/c70df33607a1c491f456d0c9508a600e8a4bfc1d) by Todd Esposito). 113 | 114 | 115 | ## [v0.4.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.4.0) - 2021-03-01 116 | 117 | [Compare with v0.3.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.3.0...v0.4.0) 118 | 119 | ### Added 120 | - Add intercall_delay ([0e2a5fa](https://github.com/tdesposito/pyZohoAPI/commit/0e2a5fa6618531add2f53daacc454c7ccce2b690) by Todd D. Esposito). 121 | 122 | ### Fixed 123 | - Fix possible access token expiry problem ([ba6db9e](https://github.com/tdesposito/pyZohoAPI/commit/ba6db9e33758a30295ec3fc54bf830f2f89a0dcf) by Todd Esposito). 124 | 125 | 126 | ## [v0.3.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.3.0) - 2021-02-27 127 | 128 | [Compare with v0.2.6](https://github.com/tdesposito/pyZohoAPI/compare/v0.2.6...v0.3.0) 129 | 130 | ### Added 131 | - Add getimage() and deleteimage() ([62f0bbf](https://github.com/tdesposito/pyZohoAPI/commit/62f0bbf62e081b4b0e507d3676bb05e876bb2d4a) by Todd Esposito). 132 | 133 | ### Fixed 134 | - Fix bumptrack config to include pyproject.toml ([610f6ec](https://github.com/tdesposito/pyZohoAPI/commit/610f6ece02b1b5ac283741eaad3b4af0f26e7d51) by Todd Esposito). 135 | - Fix testdata references in test-shell ([cf9e851](https://github.com/tdesposito/pyZohoAPI/commit/cf9e851bfd6ab481f7f8f6c564f8abb2690aaf60) by Todd Esposito). 136 | 137 | 138 | ## [v0.2.6](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.2.6) - 2021-02-26 139 | 140 | [Compare with v0.2.0](https://github.com/tdesposito/pyZohoAPI/compare/v0.2.0...v0.2.6) 141 | 142 | ### Added 143 | - Add test for confirm() ([dab9453](https://github.com/tdesposito/pyZohoAPI/commit/dab9453eb26d336516119e7a46bf14d053abc428) by Todd Esposito). 144 | - Add test for number property ([5c6e670](https://github.com/tdesposito/pyZohoAPI/commit/5c6e670eb331501a4c9e832fd87fcb7c7e3d0997) by Todd Esposito). 145 | - Add test for maprelatedlist() ([7528621](https://github.com/tdesposito/pyZohoAPI/commit/752862120900c9a5ba095fd6ec405164a04d1085) by Todd Esposito). 146 | - Add changelog ([8d3e6da](https://github.com/tdesposito/pyZohoAPI/commit/8d3e6da2474fc5d780c482263c3cccaf88d3e2f0) by Todd Esposito). 147 | - Add pypi badges ([aa2d593](https://github.com/tdesposito/pyZohoAPI/commit/aa2d593c28c3d64a128eedebcf10f777714aef95) by Todd Esposito). 148 | 149 | ### Changed 150 | - Changed confirm() (et al) to return true/false ([4173407](https://github.com/tdesposito/pyZohoAPI/commit/41734079837e9fa90abfddc32da490d06ae81ed3) by Todd Esposito). 151 | 152 | ### Fixed 153 | - Fix typo ([809c25c](https://github.com/tdesposito/pyZohoAPI/commit/809c25cb2635fe3d581e6c7c7cdcf9d4451a7d55) by Todd Esposito). 154 | - Fix number prop for after-delete() ([ddc5567](https://github.com/tdesposito/pyZohoAPI/commit/ddc5567982f07c235ebff3a253b04264bc4d96cd) by Todd Esposito). 155 | 156 | ### Removed 157 | - Remove parameter position markers for 3.6-compatibility ([1a09f04](https://github.com/tdesposito/pyZohoAPI/commit/1a09f04ac6769f093615b70f1dd528be2c641669) by Todd Esposito). 158 | - Remove obsoleted file ([934ead7](https://github.com/tdesposito/pyZohoAPI/commit/934ead766a55623c642c3da5a8827486e60e2b2d) by Todd Esposito). 159 | 160 | 161 | ## [v0.2.0](https://github.com/tdesposito/pyZohoAPI/releases/tag/v0.2.0) - 2021-02-25 162 | 163 | [Compare with first commit](https://github.com/tdesposito/pyZohoAPI/compare/4617c8f940003a6cc57dc94b8e42b1feab851d11...v0.2.0) 164 | 165 | ### Added 166 | - Add install-from-source section ([e2515fd](https://github.com/tdesposito/pyZohoAPI/commit/e2515fddaea03e63bb48150c61fe4631c6c2869c) by Todd Esposito). 167 | - Add books to test shell ([c620d87](https://github.com/tdesposito/pyZohoAPI/commit/c620d87f5c9a6975835df793e341fdf259425c0a) by Todd Esposito). 168 | - Add confirm(), void() to sos ([72752d2](https://github.com/tdesposito/pyZohoAPI/commit/72752d27970ad1184a02eff4d641754b4f346348) by Todd Esposito). 169 | - Add docs for more better exceptions; more xrefs. ([440bd88](https://github.com/tdesposito/pyZohoAPI/commit/440bd8845448f63839e2bf04d748590b5b099d9c) by Todd Esposito). 170 | - Add extra path params to test server ([07a5580](https://github.com/tdesposito/pyZohoAPI/commit/07a5580efe1d35cc43ad781739037dd405ac4c8a) by Todd Esposito). 171 | - Add query params to test server ([c761f13](https://github.com/tdesposito/pyZohoAPI/commit/c761f13b82f099d120247e1856794741ad42896a) by Todd Esposito). 172 | - Add start of interactive testing server ([be99df4](https://github.com/tdesposito/pyZohoAPI/commit/be99df4241b066443a0724b29e7408b7daf06246) by Todd Esposito). 173 | - Add maprelatedlist, fix bug in iterrelatedlist ([6c73730](https://github.com/tdesposito/pyZohoAPI/commit/6c737306bedeec9c7f6a0879101e51d920dd560a) by Todd Esposito). 174 | - Add issue templates ([460a5b3](https://github.com/tdesposito/pyZohoAPI/commit/460a5b35324e6ca2a13882c4e64c8399b1f741f1) by Todd D. Esposito). 175 | - Add version to readme ([895e2ba](https://github.com/tdesposito/pyZohoAPI/commit/895e2bad171ac6e2defde006cae4cae59ecdf6e4) by Todd Esposito). 176 | - Add rtd link and badge ([b407232](https://github.com/tdesposito/pyZohoAPI/commit/b4072322b3410ee191546255d984ffa99c782000) by Todd Esposito). 177 | - Add docs and some mixins. ([0eaeb98](https://github.com/tdesposito/pyZohoAPI/commit/0eaeb981d0974d2710ba5868e8c11a1b335ab3a0) by Todd Esposito). 178 | 179 | ### Fixed 180 | - Fix typo test-shell ([61ddc5d](https://github.com/tdesposito/pyZohoAPI/commit/61ddc5d45dad6918c848d018817ba32a6b329dbe) by Todd Esposito). 181 | - Fix bug in first() with failed search ([199b4bf](https://github.com/tdesposito/pyZohoAPI/commit/199b4bfad81ad208347ee94e36b7997cf7a606c0) by Todd Esposito). 182 | - Fix scroll on response text in test server ([50736cb](https://github.com/tdesposito/pyZohoAPI/commit/50736cb7b4e7c11f2650f82e29f1275530fe32a2) by Todd Esposito). 183 | - Fix getrelated bug ([f232830](https://github.com/tdesposito/pyZohoAPI/commit/f232830bdae2812b60be1310c173dbd8215a15bf) by Todd Esposito). 184 | - Fix response-not-json in do_request ([c52269e](https://github.com/tdesposito/pyZohoAPI/commit/c52269edd8f283a6eff502ec4d4d2c7acddd233a) by Todd Esposito). 185 | - Fix first() error on non-loaded list ([cf6c296](https://github.com/tdesposito/pyZohoAPI/commit/cf6c2969082b163358764fd617cf78ffc01725c2) by Todd Esposito). 186 | - Fix paging error ([b3407dc](https://github.com/tdesposito/pyZohoAPI/commit/b3407dcf07e148dbf603f84f23d326ce645a5fa2) by Todd Esposito). 187 | 188 | ### Removed 189 | - Remove dotted - obsolete ([bfa2d5b](https://github.com/tdesposito/pyZohoAPI/commit/bfa2d5b7ef89c7da48d1f453017e2ee6825fe544) by Todd Esposito). 190 | 191 | 192 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | projects@espositoholdings.com. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), 119 | [version 2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | For answers to common questions about this code of conduct, see the [FAQ]( 125 | https://www.contributor-covenant.org/faq). [Translations are available](https://www.contributor-covenant.org/translations). 126 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pyZohoAPI 2 | 3 | First, thanks for taking the time to contribute! I always hope my work will be 4 | high quality and useful to others, but I realize this project will always need 5 | improvement, and I'm very glad you're willing to help. 6 | 7 | You will find complete contributing guidelines in the [online 8 | Documentation](https://pyzohoapi.readthedocs.io/en/latest/contrib/). 9 | 10 | But if you just want to get started, fork this repository, munge some code and 11 | issue a Pull Request. 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Todd D. Esposito 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyZohoAPI (v1.0.0) 2 | 3 | **pyZohoAPI** provides Pythonic access to Zoho APIs in the Finance Plus suite: 4 | 5 | * **Books** 6 | * *Checkout** 7 | * *Expense** 8 | * **Inventory** 9 | * *Invoice** 10 | * *Subscriptions** 11 | 12 | * Support is planned, but not yet available. 13 | 14 | [](https://pypi.org/project/pyzohoapi/) 15 | [](https://pypi.org/project/pyzohoapi/) 16 |  17 | [](https://pyzohoapi.readthedocs.io/en/latest/?badge=latest) 18 | 19 | ## Installing pyZohoAPI 20 | 21 | 22 | 23 | You'll need at least **Python 3.6** to install pyZohoAPI. 24 | 25 | ### Via PyPI 26 | 27 | ```console 28 | $ python -m pip install pyzohoapi 29 | ``` 30 | 31 | ### From Source 32 | 33 | We use [Poetry](https://python-poetry.org/) for virtual environment and 34 | dependency management. 35 | 36 | ```console 37 | $ git clone https://github.com/tdesposito/pyZohoAPI.git 38 | $ cd pyZohoAPI 39 | $ poetry install 40 | $ poetry build 41 | $ pip install dist/*.whl 42 | ``` 43 | 44 | 45 | ## Basic Usage 46 | 47 | 48 | 49 | ```python 50 | >>> from pyzohoapi import ZohoInventory 51 | >>> api = ZohoInventory("{your-orginization-id}", "{your-region}", 52 | ... client_id="{your-client-id}", 53 | ... client_secret="{your-client-secret}", 54 | ... refresh_token="{your-refresh-token}" 55 | ... ) 56 | >>> contact = api.Contact(email="test@example.com").First() 57 | >>> contact.IsLoaded 58 | True 59 | >>> contact.first_name 60 | 'test' 61 | >>> contact.first_name = "Changed" 62 | >>> contact.Update() 63 | ``` 64 | 65 | 66 | See the [full documentation on ReadTheDocs](https://pyzohoapi.readthedocs.io/en/latest/). 67 | 68 | ## Contributing 69 | 70 | [Pull Requests](https://github.com/tdesposito/pyZohoAPI/pulls) gladly 71 | considered! Please use our pull request template when submitting your pull 72 | request. 73 | 74 | | Thanks Contributors! | 75 | | :------------------: | 76 | |  77 | [Shubham Agawane](https://github.com/s-agawane) | 78 | -------------------------------------------------------------------------------- /bumpytrack.toml: -------------------------------------------------------------------------------- 1 | [tool.bumpytrack] 2 | current_version = "1.0.0" 3 | git_commit = true 4 | git_tag = true 5 | 6 | [[tool.bumpytrack.file_replaces]] 7 | path = "pyproject.toml" 8 | search_template = "version = \"{version}\"" 9 | 10 | [[tool.bumpytrack.file_replaces]] 11 | path = "docs/conf.py" 12 | search_template = "release = '{version}'" 13 | 14 | [[tool.bumpytrack.file_replaces]] 15 | path = "pyzohoapi/__init__.py" 16 | search_template = "__version__ = '{version}'" 17 | 18 | [[tool.bumpytrack.file_replaces]] 19 | path = "README.md" 20 | search_template = "# pyZohoAPI (v{version})" 21 | -------------------------------------------------------------------------------- /docs-requirements.txt: -------------------------------------------------------------------------------- 1 | furo 2 | sphinx 3 | myst-parser 4 | sphinx-copybutton 5 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'pyZohoAPI' 21 | copyright = '2021, Todd D. Esposito' 22 | author = 'Todd D. Esposito' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '1.0.0' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinx.ext.todo", 35 | "sphinx.ext.autodoc", 36 | "sphinx.ext.viewcode", 37 | "myst_parser", 38 | "sphinx_copybutton", 39 | ] 40 | todo_include_todos = True 41 | copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " 42 | copybutton_prompt_is_regexp = True 43 | myst_heading_anchors = 3 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 52 | 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | 56 | # The theme to use for HTML and HTML Help pages. See the documentation for 57 | # a list of builtin themes. 58 | # 59 | html_theme = 'furo' 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | html_static_path = ['_static'] 65 | -------------------------------------------------------------------------------- /docs/contrib/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../CODE_OF_CONDUCT.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/contrib/code_ref.md: -------------------------------------------------------------------------------- 1 | # Hacking the Internals 2 | 3 | ```{todo} 4 | Lots to do here. Most of these docs are autodoc'ed from the code. More 5 | docstrings there would be of great use. 6 | ``` 7 | 8 | This section documents the internals and public methods of pyZohoAPI. 9 | 10 | ## pyzohoapi.core 11 | 12 | ### ZohoAPIBase 13 | 14 | ```{eval-rst} 15 | .. autoclass:: pyzohoapi.core.ZohoAPIBase 16 | :members: 17 | ``` 18 | 19 | ### ZohoObjectBase 20 | 21 | ```{eval-rst} 22 | .. autoclass:: pyzohoapi.core.ZohoObjectBase 23 | :members: 24 | ``` 25 | 26 | ## pyzohoapi.core.collection 27 | 28 | Internally, a ZohoObject's `_data` member is either a DottedList or a DottedDict. 29 | 30 | This module is a modified version of 31 | [DottedDict](https://github.com/carlosescri/DottedDict). I've stripped out all 32 | the Python2 compatibility stuff, and added some fixes from the upstream open 33 | pull requests. 34 | 35 | ```{eval-rst} 36 | .. automodule:: pyzohoapi.core.collection 37 | :members: 38 | ``` 39 | 40 | ## pyzohoapi.core.utils 41 | 42 | ```{eval-rst} 43 | .. automodule:: pyzohoapi.core.utils 44 | :members: 45 | ``` 46 | 47 | ## pyzohoapi.exceptions 48 | 49 | ```{eval-rst} 50 | .. automodule:: pyzohoapi.exceptions 51 | :members: 52 | ``` 53 | 54 | ## pyzohoapi.objecttypes 55 | 56 | ```{eval-rst} 57 | .. automodule:: pyzohoapi.objecttypes 58 | :members: 59 | ``` 60 | 61 | ## pyzohoapi.objecttypes.mixins 62 | 63 | ```{eval-rst} 64 | .. automodule:: pyzohoapi.objecttypes.mixins 65 | :members: 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/contrib/coding.md: -------------------------------------------------------------------------------- 1 | # Modifying The Code 2 | 3 | ## Repository Organization 4 | 5 | The Repository has four sub-directories of note: 6 | 7 | * `docs` - Contains the project documentation (you know, this) 8 | * `pyzohoapi` - Contains the module code 9 | * `tests` - Contains the unit and functional tests 10 | * `tools` - Contains [tools to help in development](setup.md#helpful-dev-and-debug-tools) 11 | * `interactive-test-server.py` is a simple web interface for interrogating the Zoho APIs 12 | * `test-shell.py` starts up a python REPL shell with the API Objects preloaded 13 | 14 | ## Code Style 15 | 16 | Generally, we adhere to the [PEP8 Code 17 | Style](https://www.python.org/dev/peps/pep-0008/). However, as noted elsewhere 18 | in these docs, our public interface (properties and methods) for "ZohoObjects" 19 | use CamelCase rather than underscore_separated_words, to avoid possible conflict 20 | with Zoho-surfaced object fields. This should only apply to ZohoObjects; all 21 | others should follow PEP8. 22 | 23 | In short: 24 | 25 | * Indention is 4 spaces, not tabs 26 | * method_names_look_like_this _except for in ZohoObject classes_ 27 | * ClassNamesLookLikeThis 28 | 29 | ## Classes 30 | 31 | _ZohoObject_ classes represent particular objects exposed by the API. 32 | 33 | _ZohoAPI_ classes connect to particular API endpoints. 34 | 35 | ## Adding new ZohoObject Classes 36 | 37 | We'll use [Zoho Inventory API](https://www.zoho.com/inventory/api/v1/) as an 38 | example in this section. 39 | 40 | ```{note} 41 | Unfortunately, the official Zoho API Docs aren't always accurate. When in doubt, 42 | use any of the various API client tools available to test the API directly. See 43 | [our interactive tools](setup.md#helpful-dev-and-debug-tools). 44 | ``` 45 | 46 | ### Examine the API Request and Response 47 | 48 | Let's consider Zoho **Composite Items**: 49 | 50 | We see that to retrieve a list of **Composite Items**, we call: 51 | 52 | `get /compositeitems` 53 | 54 | The JSON we get back looks like: 55 | 56 | ```{code-block} json 57 | :emphasize-lines: 4, 6 58 | { 59 | "code": 0, 60 | "message": "success", 61 | "composite_items": [ 62 | { 63 | "composite_item_id": "9999999000001049029", 64 | ``` 65 | 66 | If we retrive a particular **Composite Item** with: 67 | 68 | `get /compositeitems/9999999000001049029` 69 | 70 | we'll get: 71 | 72 | ```{code-block} json 73 | :emphasize-lines: 4, 5 74 | { 75 | "code": 0, 76 | "message": "success", 77 | "composite_item": { 78 | "composite_item_id": "9999999000001049029", 79 | ``` 80 | 81 | So we see that: 82 | 83 | * The object type (as defined by Zoho) is "Composite Items". 84 | * The Python class name should be **CompositeItem** (singular). 85 | * The URL fragment is `compositeitems` (plural). This is the pluralized, lowercase version of the class name. 86 | * The "list of **Composite Items**" key is `composite_items` (plural). This is different from the class name. 87 | * The "single **Composite Item**" key is `composite_item` (singular). This is different from the class name. 88 | * The key for the unique ID of each **Composite Item** is `composite_item_id`. This is different from the class name. 89 | 90 | Further inspection of the API docs indicate that, in addition the usual 91 | _Create_, _Retrieve_, _Update_, _Delete_ and _List All_ operations, we can also 92 | perform _Mark as Active_ and _Mark as Inactive_ operations. 93 | 94 | ### Add the class 95 | 96 | ZohoObject classes are created by the `ZohoObjectFactory()` function 97 | in `pyzohoapi/objecttypes/__init__.py`. In this case, we use: 98 | 99 | ```{code-block} python 100 | CompositeItem = ZohoObjectFactory("CompositeItem", 101 | responseKey="composite_item", idKey="composite_item_id", 102 | mixins=[HasActivate, HasBundle, HasCustomFields]) 103 | ``` 104 | 105 | * _"CompositeItem"_ is the Python class name exposed by `pyzohoapi.objecttypes`. 106 | * _"CompositeItem"_ will be pluralized and lowercased to create the URL fragment. If we needed to use a different URL, we'd use the `urlPath` parameter to override the default. 107 | * _responseKey="composite_item"..._ defines the keys (singular and plural) of the objects in the JSON response data. This is needed because the corresponding JSON key is not the same as the class name. 108 | * _idKey="composite_item_id"_ defines the root of the key used to determine the ID of the object. This is needed because the corresponding JSON key is not the same as the class name. 109 | * _mixins=[HasActivate]_ adds the `Activate()` and `Deactivate()` operations by way of a mixin in `pyzohoapi.objecttypes.mixins`; keep reading for how that works. 110 | 111 | ## Extending ZohoObject Classes 112 | 113 | The `pyzohoapi.objecttypes.mixins` module contains classes which expose one 114 | or more additional methods to add to particular object types. In the example 115 | above, we've mixed in `HasActivate` class, which adds the `Activate()` and 116 | `Deactive()` methods to the `CompositeItem` class. See [Type-Specific 117 | Methods](/objrefs/methods.md) for the breakdown of the existing methods. 118 | 119 | ### Examine the API Docs 120 | 121 | We'll look at the existing mixin `HasActivate` as an example. 122 | 123 | Looking at the API Docs, we see that there are several different Zoho objects 124 | which support the "Mark as Active" and "Mark as Inactive" operations. Every 125 | object either supports both or neither of these operations. 126 | 127 | We also see that the method for performing these operations is of the form: 128 | 129 | `post /{object-url-fragment}/{object-id}/{active|inactive}` 130 | 131 | These factors suggest we create a mixin class which implements a method handling 132 | the `post` to Zoho, and methods to expose both operations. This class, then, can 133 | be applied to the appropriate object types. 134 | 135 | ### Create a Mixin Class 136 | 137 | ```{code-block} python 138 | class HasActivate: 139 | ... 140 | ``` 141 | 142 | The pattern for the class name is `Has{Feature}`. 143 | 144 | ### Create Internal Method(s) 145 | 146 | ```{code-block} python 147 | :emphasize-lines: 2 148 | class HasActivate: 149 | def _do_operation(self, status, funcname): 150 | ... 151 | ``` 152 | 153 | This is optional, but since in this case there are two, basically identical, 154 | operations we want to expose, we'll build an internal method to actually perform 155 | the operation. 156 | 157 | Internal method names should begin with `_`, both to indicate they are "private" 158 | and to avoid collision with Zoho object field names. 159 | 160 | In this case, we need the new status ('active' or 'inactive', per the API docs), 161 | and the name of the function being called (for exceptions). 162 | 163 | ### Ensure the Operation is Valid 164 | 165 | ```{code-block} python 166 | :emphasize-lines: 3, 5 167 | class HasActivate: 168 | def _do_operation(self, status, funcname): 169 | if self._id and self._data: 170 | ... 171 | raise ZohoInvalidOpError(funcname, self) 172 | ``` 173 | 174 | This operation is only valid on single objects (they have an `_id`) and which 175 | already exist (they have `_data`). If those conditions aren't met, we'll raise a 176 | `ZohoInvalidOpError`. 177 | 178 | ### Perform the API Call 179 | 180 | ```{code-block} python 181 | :emphasize-lines: 4-9 182 | class HasActivate: 183 | def _do_operation(self, status, funcname): 184 | if self._id and self._data: 185 | try: 186 | self._api.post(self._url_fragment(extraPath=[status])) 187 | self.status = status 188 | return True 189 | except ZohoException as e: 190 | return False 191 | raise ZohoInvalidOpError(funcname, self) 192 | ``` 193 | 194 | In order to `post` to the API, we use the API object's `post()` method. We have 195 | to tell `post()` where to post to, which is what our `_url_fragment()` functions 196 | does. It constructs the object-specific portion of the eventual URL with our 197 | object type (i.e. `/compositeitems`), our ID (if appropriate), and adds anything 198 | in the `extraPath` parameter. The API object takes care of the 199 | `https://{whatever}.zoho.{whatever}`. 200 | 201 | In this case, if `post()` raises an exception, we suppress it and return `False`. 202 | 203 | ### Create Public Method(s) 204 | 205 | ```{code-block} python 206 | :emphasize-lines: 3-7 207 | class HasActivate: 208 | ... 209 | def Activate(self): 210 | return self._do_operation('active', "Activate") 211 | 212 | def Deactivate(self): 213 | return self._do_operation('inactive', "Deactivate") 214 | ``` 215 | 216 | Public Method names should be CamelCase, for reasons noted elsewhere. Parameters 217 | are operation-specific. 218 | 219 | ## Adding a New API Object 220 | 221 | ### Create a Module for the API 222 | 223 | See `pyzohoapi/inventory.py` as an example. 224 | 225 | ### Define the Class 226 | 227 | Inherit from `ZohoAPIBase`. 228 | 229 | ```{code-block} python 230 | from .core import ZohoAPIBase 231 | ... 232 | class ZohoInventory(ZohoAPIBase): 233 | ``` 234 | 235 | ### Set the OAuth Scope 236 | 237 | ```{code-block} python 238 | :emphasize-lines: 4 239 | from .core import ZohoAPIBase 240 | ... 241 | class ZohoInventory(ZohoAPIBase): 242 | _scope = "ZohoInventory.FullAccess.all" 243 | ``` 244 | 245 | ### (Optional) Determine Available Regions 246 | 247 | Override the `_regionmap` member if the API isn't available in every Zoho data 248 | center. See the code for `ZohoAPIBase` for a guidance. 249 | 250 | ### Write `get_endpoint()` 251 | 252 | The `get_endpoint()` method returns the endpoint of the api. 253 | 254 | ```{code-block} python 255 | :emphasize-lines: 6-7 256 | from .core import ZohoAPIBase 257 | ... 258 | class ZohoInventory(ZohoAPIBase): 259 | _scope = "ZohoInventory.FullAccess.all" 260 | 261 | def get_endpoint(self, region): 262 | return f"https://inventory.zoho.{self._regionmap[region]}/api/v1" 263 | ``` 264 | 265 | ### Expose Available ZohoObjects 266 | 267 | ```{code-block} python 268 | :emphasize-lines: 2, 10-12 269 | from .core import ZohoAPIBase 270 | from . import objecttypes 271 | ... 272 | class ZohoInventory(ZohoAPIBase): 273 | _scope = "ZohoInventory.FullAccess.all" 274 | 275 | def get_endpoint(self, region): 276 | return f"https://inventory.zoho.{self._regionmap[region]}/api/v1" 277 | ... 278 | def Account(self, *args, **kwargs): return objecttypes.Account(self, *args, **kwargs) 279 | def Bundle(self, *args, **kwargs): return objecttypes.Bundle(self, *args, **kwargs) 280 | ... 281 | ``` 282 | 283 | ### Expose the module 284 | 285 | In `pyzohoapi.__init__.py`, import the module and add it to the `__all__` list. 286 | 287 | ```{code-block} python 288 | from .inventory import ZohoInventory 289 | __all__ = ["ZohoInventory", ...] 290 | ``` 291 | -------------------------------------------------------------------------------- /docs/contrib/contributing.md: -------------------------------------------------------------------------------- 1 | # Ways To Contribute 2 | 3 | These guidelines (not rules, per se; use your best judgment) were loosely 4 | adapted from those for [Atom](https://github.com/atom). And these guidelines, 5 | like the source code itself, is open to suggestions and pull requests. 6 | 7 | We want to make contributing easy and fulfilling, and will endeavor to answer 8 | any questions you may have quickly. However, it may take a day to respond to 9 | queries; please be patient. 10 | 11 | This document is intended to give you an overview of the infrastructure of the 12 | project. There is a separate document about the [coding process](coding.md) 13 | itself. 14 | 15 | We gladly accept contributions in any (constructive) form, including bug 16 | reports, bug fixes, enhancements, and testing and documentation updates. 17 | 18 | ## Reporting Bugs 19 | 20 | If you think you've found a bug, but aren't sure, drop on by the [Discussions 21 | Board](https://github.com/tdesposito/pyZohoAPI/discussions) and look for a 22 | similar issue, or start a new discussion and let us know what you're seeing. 23 | We'll do our best to help. 24 | 25 | If you KNOW it's a bug, please open a [Bug 26 | Report](https://github.com/tdesposito/pyZohoAPI/issues/new?template=bug_report.md) 27 | and fill it out to the best of your ability. 28 | 29 | If you feel able, fork the repository and see if you can correct the bug! Then 30 | create a [Pull Request](#creating-a-pull-request). 31 | 32 | ## Suggesting Enhancements 33 | 34 | Something missing? Or feel something could work better? 35 | 36 | Feel free to add a [Feature Request](https://github.com/tdesposito/pyZohoAPI/issues/new?template=feature_request.md). 37 | 38 | Or better still, fork the repository, add or update the feature, and create a 39 | [Pull Request](#creating-a-pull-request). 40 | 41 | ## Creating a Pull Request 42 | 43 | We're thrilled to accept Pull Requests. 44 | 45 | Before you submit a Pull Request, please be sure to: 46 | * Read the [Code Of Conduct](code_of_conduct.md) 47 | * Add or update test(s) which verify your new code works as it should (if applicable) 48 | * Add or update the documentation (if applicable) 49 | -------------------------------------------------------------------------------- /docs/contrib/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | First, thanks for taking the time to contribute! I hope my work will be of 4 | high quality and useful to others, but I realize there's always room for 5 | improvement, and I'm very glad you're willing to help. 6 | 7 | ```{toctree} 8 | contributing 9 | setup 10 | coding 11 | code_of_conduct 12 | code_ref 13 | 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/contrib/setup.md: -------------------------------------------------------------------------------- 1 | # Project Setup 2 | 3 | ## Set up the Tooling 4 | 5 | First, of course, fork and clone the repo on GitHub. 6 | 7 | We use [Poetry](https://python-poetry.org/) to manage our development virtual 8 | environment. This must be installed before starting development on pyZohoAPI. 9 | 10 | Once installed, you can set up the virtual environment: 11 | 12 | ```{code-block} console 13 | $ git clone https://github.com/{your-name}/pyZohoAPI.git 14 | ...snip... 15 | $ cd pyZohoAPI 16 | $ poetry install 17 | ...snip... 18 | ``` 19 | 20 | Poetry will install all the packages needed, but they're also mentioned below 21 | for completeness. 22 | 23 | See the [Poetry docs](https://python-poetry.org/) for more. 24 | 25 | ## Tooling for Testing 26 | 27 | We use [pytest](https://docs.pytest.org/en/stable/) for testing. 28 | 29 | Tests rely on a python module **NOT INCLUDED IN THIS REPOSITORY** called 30 | `private` which exports a dictionary called `testdata`, something like: 31 | 32 | ```{code-block} python 33 | from pyzohoapi.core.collection import DottedDict 34 | testdata = DottedDict({ 35 | 'orgid': "your-org-id", 36 | 'region': "your-region", 37 | 'api': { 38 | "access_token": None, 39 | "refresh_token": "your-refresh-token", 40 | "expires_in": 0, 41 | "client_id": "your-client-id", 42 | "client_secret": "your-client-secret", 43 | "redirect_url": None, 44 | }, 45 | 'books': { ... }, 46 | 'inventory': { ... }, 47 | ... 48 | }) 49 | ``` 50 | 51 | ```{danger} 52 | The tests rely on actual secrets data to interact with the live Zoho APIs, and 53 | it's a REALLY BAD IDEA to publish those, so our `.gitignore` specifically 54 | ignores the `private/` directory, and that's where we look for these secrets. 55 | ``` 56 | 57 | In order to run the tests, you must create `private/__init__.py` with at least 58 | the data indicated above, and add those key/value pairs the tests themselves 59 | require. See the tests codes themselves for the details. 60 | 61 | To run the tests: 62 | 63 | ```{code-block} console 64 | poetry run pytest 65 | ``` 66 | 67 | Or, a particular test, such as one you added: 68 | 69 | ```{code-block} console 70 | poetry run pytest -k my_test_name 71 | ``` 72 | 73 | All the tests are under the `tests` directory, naturally. Contained therein are: 74 | 75 | * `__init__.py` - lets Python consider the tests part of a module. 76 | * `test_00_pyzohoapi.py` - tests for the primitives in the ZohoAPI base class. 77 | * `test_inventory{_.*}.py` - tests specifically for ZohoInventory. 78 | 79 | Tests you add should follow the same pattern; Additional tests for 80 | the already-present APIs should either be added into the existing `test_...` 81 | file, or a new one of the form `test_{api}_{seq}.py` 82 | 83 | ## Tooling for Documentation 84 | 85 | All documentation is written in Markdown. 86 | 87 | We use: 88 | 89 | * [Sphinx](https://www.sphinx-doc.org/en/master/) - the documentation engine 90 | * [Myst](https://github.com/executablebooks/MyST-Parser) - adds Markdown support to Sphinx 91 | * [Furo](https://github.com/pradyunsg/furo) - a lovely simple theme 92 | * [sphinx-autobuild](https://pypi.org/project/sphinx-autobuild/) - builds docs and gives us a live-reloading web server 93 | * [sphinx-copybutton](https://sphinx-copybutton.readthedocs.io/en/latest/) - add copy-to-clipboard to code blocks 94 | 95 | To use sphinx-autobuild: 96 | 97 | ```{code-block} console 98 | poetry run sphinx-autobuild docs docs/build/html --open-browser --watch . 99 | ``` 100 | 101 | ## Commits, Versioning and ChangeLog 102 | 103 | We adhere to [Semantic Versioning](https://semver.org) and [Conventional 104 | Commits](https://www.conventionalcommits.org/)* and apply the 105 | principles espoused on [Keep A Changelog](https://keepachangelog.com). 106 | 107 | We use [BumpyTrack](https://github.com/nandilugio/bumpytrack) for versioning, 108 | thus: 109 | 110 | ```{code-block} console 111 | bumpytrack {aspect} --config-path bumpytrack.toml 112 | ``` 113 | 114 | Of course, `{aspect}` should be `major`, `minor` or `patch` depending on the 115 | actual aspect we are bumping. 116 | 117 | We use [Git-Changelog](https://github.com/pawamoy/git-changelog) to generate our 118 | CHANGELOG.md, thus: 119 | 120 | ```{code-block} console 121 | git-changelog . -o CHANGELOG.md 122 | ``` 123 | 124 | Naturally, pull-requests will be bumped/changelogged by the maintainer(s), but 125 | if you're going to issue a pull request, please ensure your commit messages will 126 | be parsed properly. 127 | 128 | _*Going forward. Previous commits may not._ 129 | 130 | ## Helpful Dev and Debug Tools 131 | 132 | There are two tools in the `tools` directory to make development and testing 133 | (hopefully) easier. 134 | 135 | ```{note} 136 | Both of these tools rely on the `private` module mentioned above. 137 | ``` 138 | 139 | ### Interactive Test Server 140 | 141 | The Interactive Test Server spins up a simple web server on 142 | http://localhost:8080. This allows you to send queries to the Zoho APIs and see 143 | the JSON response data. 144 | 145 | ```{code-block} console 146 | poetry run python tools/interactive-test-server.py 147 | ``` 148 | 149 | You can add `--port` and a port number to the above to override the 150 | default port (8080). 151 | 152 | If you want to see the log entries the library is making, add `--log` to the 153 | above. 154 | 155 | ### Interactive Shell 156 | 157 | The Interactive Shell launches a Python REPL with pyzohoapi pre-loaded and 158 | confgured (see the section on `private`, above). 159 | 160 | ```{code-block} console 161 | poetry run python -i tools/test-shell.py 162 | ``` 163 | 164 | ```{note} 165 | Don't forget the `-i` in the command above to get your interactive shell. 166 | ``` 167 | 168 | ```{code-block} console 169 | testshell() 170 | Test shell loaded. Here's what you have: 171 | 172 | Modules: 173 | decimal.Decimal (as Decimal); 174 | json (simplejson, aliased to json); 175 | 176 | Functions: 177 | pprint(); 178 | show(object, key=None) -> shows a Zoho Object (or optional attribute); 179 | 180 | Objects: 181 | private.testdata -> dict, aliased to td; 182 | books -> ZohoBooks object : configured via testdata; 183 | inv -> ZohoInventory object : configured via testdata; 184 | 185 | Type: help(testshell) to get this help again. 186 | 187 | Enjoy your testing! 188 | 189 | >>> inv.Item('9876543210987654321') 190 | Item #9876543210987654321 191 | >>> 192 | ``` 193 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide-toc: true 3 | --- 4 | You are reading pyZohoAPI's documentation 5 | ========================================= 6 | 7 | ## About pyZohoAPI 8 | 9 | **pyZohoAPI** provides Pythonic access to Zoho APIs in the Finance Plus suite: 10 | _Books, Checkout, Expense, Inventory, Invoice, and Subscriptions_. 11 | 12 | ```{note} 13 | As of this release, we only have support for _Inventory_ and _Books_, and not every 14 | object therein. Full support will be built out incrementally over future releases. 15 | ``` 16 | 17 | This package provides Python objects which correlate to the objects exposed by 18 | the Zoho API. The Python objects provide a simple way to access, update, create, 19 | delete and modify Zoho objects. We don't strive to encapsulate operations Zoho 20 | performs in its clients; that's up to your code. 21 | 22 | ```{toctree} 23 | :caption: Documentation 24 | :hidden: 25 | quickstart 26 | userguide/index 27 | objrefs/index 28 | ``` 29 | 30 | ```{toctree} 31 | :caption: Contributing 32 | :hidden: 33 | contrib/index 34 | license 35 | GitHub Repository 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ```{eval-rst} 4 | .. include:: ../LICENSE 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/objrefs/index.md: -------------------------------------------------------------------------------- 1 | # Object Reference 2 | 3 | Objects the Zoho APIs exposes are mirrored in Python objects. These tend to be 4 | named as a singular version of the (normally) plural name in the Zoho API 5 | documentation. 6 | 7 | All share the methods and properties documented in [Zoho 8 | Objects](/userguide/zohoobject.md). Some have additional methods to implement 9 | features and functions of the Zoho API. These additional methods are documented 10 | under "Type-Specific Methods," below. 11 | 12 | ```{note} 13 | This package is BETA software. Not all objects are represented, and those which 14 | are may not implement all Zoho API function. 15 | ``` 16 | 17 | ```{toctree} 18 | methods 19 | types 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/objrefs/methods.md: -------------------------------------------------------------------------------- 1 | # Type-Specific Methods 2 | 3 | These methods are added to objects which support the corresponding semantics. 4 | 5 | Not every object has every one, or indeed any, of the below. 6 | 7 | ```{warning} 8 | Due to how we implement the Zoho objects, calling one of these methods on an 9 | object without support for it will raise a `KeyError` rather than the more 10 | typical `AttributeError`. Please code appropriately. 11 | ``` 12 | 13 | ```{include} /snippets/seealso-zoho-api.markdown 14 | ``` 15 | 16 | ## Alphabetically 17 | 18 | ### `Activate()` 19 | 20 | Makes the object "Active". True on success. Raises `ZohoInvalidOpError` if 21 | called on a New or List-of object. 22 | 23 | ```{admonition} Applies To 24 | :class: tip 25 | 26 | * [CompositeItem](types.md#compositeitem) 27 | * [Contact](types.md#contact) 28 | * [Item](types.md#item) 29 | * [ItemGroup](types.md#itemgroup) 30 | * [User](types.md#user) 31 | ``` 32 | 33 | ### `AddImage()` 34 | 35 | Add an image to an object. 36 | 37 | ```{code-block} python 38 | >>> imgname = "imagename.jpg" 39 | >>> mimetype = "image/jpeg" 40 | >>> with open(imgname, "rb") as image: 41 | ... obj.AddImage(imgname, image.read(), mimetype) 42 | ``` 43 | 44 | ```{admonition} Applies To 45 | :class: tip 46 | 47 | * [Item](types.md#item) 48 | ``` 49 | 50 | ### `Confirm()` 51 | 52 | Marks an object as "Confirmed". Returns True on success. Raises 53 | `ZohoInvalidOpError` if called on a New or List-of object. 54 | 55 | ```{admonition} Applies To 56 | :class: tip 57 | 58 | * [SalesOrder](types.md#salesorder) 59 | ``` 60 | 61 | ### `Deactivate()` 62 | 63 | Makes the object "Inactive". True on success. Raises `ZohoInvalidOpError` if 64 | called on a New or List-of object. 65 | 66 | ```{admonition} Applies To 67 | :class: tip 68 | 69 | * [CompositeItem](types.md#compositeitem) 70 | * [Contact](types.md#contact) 71 | * [Item](types.md#item) 72 | * [ItemGroup](types.md#itemgroup) 73 | * [User](types.md#user) 74 | ``` 75 | 76 | ### `DeleteImage()` 77 | 78 | Deletes the image associated with an object. 79 | 80 | ```{admonition} Applies To 81 | :class: tip 82 | 83 | * [Item](types.md#item) 84 | ``` 85 | 86 | ### `Delivered()` 87 | 88 | Marks an object as "Delivered". Returns True on success. Raises `ZohoInvalidOpError` 89 | if called on a New or List-of object. 90 | 91 | ```{admonition} Applies To 92 | :class: tip 93 | 94 | * [ShipmentOrder](types.md#shipmentorder) 95 | ``` 96 | 97 | ### `Draft()` 98 | 99 | Marks an object as "Draft". Returns True on success. Raises `ZohoInvalidOpError` 100 | if called on a New or List-of object. 101 | 102 | ### `GetCustomField()` 103 | 104 | Returns the value of a Custom Field. Let's say you have a Custom Field on your 105 | Items labeled "Bin Location" and named "cf_bin_location" (the Zoho field 106 | `placeholder` name). Then you can: 107 | 108 | ```{code-block} python 109 | >>> item.GetCustomField('cf_bin_location') 110 | A234 111 | >>> item.GetCustomField('Bin Location') 112 | A234 113 | ``` 114 | 115 | If the Custom Field isn't part of the object, `GetCustomField()` returns None. 116 | You can alter that behavior: 117 | 118 | ```{code-block} python 119 | >>> item.GetCustomField('Invalid Label') 120 | None 121 | >>> item.GetCustomField('Invalid Label', default="Sup?") 122 | 'Sup?' 123 | ``` 124 | 125 | If the Custom Field list of the object isn't "custom_fields", or there's more 126 | than one such list: 127 | 128 | ```{code-block} python 129 | >>> item.GetCustomField('cf_bin_location', listkey='item_custom_fields') 130 | A234 131 | ``` 132 | 133 | ```{admonition} Applies To 134 | :class: tip 135 | 136 | * [CompositeItem](types.md#compositeitem) 137 | * [Item](types.md#item) 138 | * [SalesOrder](types.md#salesorder) 139 | ``` 140 | 141 | ### `MakeBundle()` 142 | 143 | Bundles a CompositeItem n-Many times. Provide the number of bundles to make as the first parameter, and optionally these keyword parameters: 144 | 145 | * `bundle_date` (string): the date, in "YYYY-MM-DD" format, for the bundles. Defaults to today. 146 | * `purchase_account_id` (string): the ID of the corresponding purchase account. 147 | * `warehouse_id` (string): the ID of the corresponding warehouse (required if warehouses are enabled). 148 | 149 | ```{admonition} Applies To 150 | :class: tip 151 | 152 | * [CompositeItem](types.md#compositeitem) 153 | ``` 154 | 155 | ```{code-block} python 156 | >>> composite.MakeBundle(10, purchase_account_id="9878765432165432109") 157 | Bundle #9876543210987654321 158 | ``` 159 | 160 | ### `SetCustomField()` 161 | 162 | **_Updated in v0.6.0_**: Moved out of Common Methods 163 | 164 | **_New in v0.5.0_** 165 | 166 | Sets the value of a custom field in the object, and returns the object. You pass 167 | either the custom field `placeholder` or `label` as the first parameter, and the 168 | new value as the second. `SetCustomField()` returns the object, so it can be 169 | chained. 170 | 171 | If the custom field doesn't exist in the object, we'll try to add it. However, 172 | sometimes the Zoho API wants `placeholder` and sometimes it wants `label`. so 173 | you have to provide the correct one. 174 | 175 | ```{admonition} Applies To 176 | :class: tip 177 | 178 | * [CompositeItem](types.md#compositeitem) 179 | * [Item](types.md#item) 180 | * [SalesOrder](types.md#salesorder) 181 | ``` 182 | 183 | ### `UpdateBilling()` 184 | 185 | Updates the related Billing Address of the object. 186 | 187 | ```{code-block} python 188 | >>> contact.billing_address.address = "123 New Street" 189 | >>> contact.UpdateBilling() 190 | Contact #9876543210987654321 191 | ``` 192 | 193 | ```{admonition} Applies To 194 | :class: tip 195 | 196 | * [Contact](types.md#contact) 197 | ``` 198 | 199 | ### `UpdateShipping()` 200 | 201 | Updates the related Shipping Address of the object. 202 | 203 | ```{code-block} python 204 | >>> contact.shipping_address.address = "123 New Street" 205 | >>> contact.UpdateShipping() 206 | Contact #9876543210987654321 207 | ``` 208 | 209 | ```{admonition} Applies To 210 | :class: tip 211 | 212 | * [Contact](types.md#contact) 213 | ``` 214 | 215 | ### `Void()` 216 | 217 | Marks an object as "Void". Returns True on success. Raises `ZohoInvalidOpError` 218 | if called on a New or List-of object. 219 | 220 | ```{admonition} Applies To 221 | :class: tip 222 | 223 | * [SalesOrder](types.md#salesorder) 224 | ``` 225 | -------------------------------------------------------------------------------- /docs/objrefs/types.md: -------------------------------------------------------------------------------- 1 | # Type Reference 2 | 3 | ```{include} /snippets/seealso-zoho-api.markdown 4 | ``` 5 | 6 | ## Alphabetic 7 | 8 | This is an alphabetic list of all the Zoho Object types we support, and the APIs 9 | which include them. 10 | 11 | We note which [additional methods](/objrefs/methods.md) each object type 12 | includes, if any. 13 | 14 | ### Account 15 | 16 | ```{admonition} Available In 17 | Books, Inventory 18 | ``` 19 | 20 | ### Bill 21 | 22 | ```{admonition} Available In 23 | Inventory 24 | ``` 25 | 26 | ```{admonition} Additional methods 27 | :class: tip 28 | 29 | * [GetCustomField()](methods.md#getcustomfield) 30 | * [SetCustomField()](methods.md#setcustomfield) 31 | ``` 32 | 33 | ### Brand 34 | 35 | ```{admonition} Available In 36 | Inventory 37 | ``` 38 | 39 | ### Bundle 40 | 41 | ```{admonition} Available In 42 | Inventory 43 | ``` 44 | 45 | ### CreditNote 46 | 47 | ```{admonition} Available In 48 | Inventory 49 | ``` 50 | 51 | ```{admonition} Additional methods 52 | :class: tip 53 | 54 | * [GetCustomField()](methods.md#getcustomfield) 55 | * [SetCustomField()](methods.md#setcustomfield) 56 | ``` 57 | 58 | ### CompositeItem 59 | 60 | ```{admonition} Available In 61 | Inventory 62 | ``` 63 | 64 | ```{admonition} Additional methods 65 | :class: tip 66 | 67 | * [Activate()](methods.md#activate) 68 | * [Deactivate()](methods.md#deactivate) 69 | * [GetCustomField()](methods.md#getcustomfield) 70 | * [MakeBundle()](methods.md#makebundle) 71 | * [SetCustomField()](methods.md#setcustomfield) 72 | ``` 73 | 74 | ### Contact 75 | 76 | ```{admonition} Available In 77 | Inventory 78 | ``` 79 | 80 | ```{admonition} Additional methods 81 | :class: tip 82 | 83 | * [Activate()](methods.md#activate) 84 | * [Deactivate()](methods.md#deactivate) 85 | * [GetCustomField()](methods.md#getcustomfield) 86 | * [SetCustomField()](methods.md#setcustomfield) 87 | * [UpdateBilling()](methods.md#updatebilling) 88 | * [UpdateShipping()](methods.md#updateshipping) 89 | ``` 90 | 91 | ### CustomerPayment 92 | 93 | ```{admonition} Available In 94 | Inventory 95 | ``` 96 | 97 | ### Currency 98 | 99 | ```{admonition} Available In 100 | Inventory 101 | ``` 102 | 103 | ### Document 104 | 105 | ```{admonition} Available In 106 | Books, Inventory 107 | ``` 108 | 109 | Retrieving a List-of Document objects will get your the typical List-of. Getting 110 | a particular Document (by ID, or iterating over the list) will get you object(s) 111 | with two fields: 112 | 113 | * `content` is the raw, binary version of the document; 114 | * `content_type` is the MIME-type of the content; 115 | 116 | ```{warning} 117 | The document content could be very large, and will be loaded into memory. Use 118 | with care. 119 | ``` 120 | 121 | ### Invoice 122 | 123 | ```{admonition} Available In 124 | Books, Inventory 125 | ``` 126 | 127 | ### Item 128 | 129 | ```{admonition} Available In 130 | Books, Inventory 131 | ``` 132 | 133 | ```{admonition} Additional methods 134 | :class: tip 135 | 136 | * [Activate()](methods.md#activate) 137 | * [AddImage()](methods.md#addimage) 138 | * [Deactivate()](methods.md#deactivate) 139 | * [DeleteImage()](methods.md#deleteimage) 140 | * [GetCustomField()](methods.md#getcustomfield) 141 | * [SetCustomField()](methods.md#setcustomfield) 142 | ``` 143 | 144 | ### ItemAdjustment 145 | 146 | ```{admonition} Available In 147 | Inventory 148 | ``` 149 | 150 | ### ItemGroup 151 | 152 | ```{admonition} Available In 153 | Inventory 154 | ``` 155 | 156 | ```{admonition} Additional methods 157 | :class: tip 158 | 159 | * [Activate()](methods.md#activate) 160 | * [Deactivate()](methods.md#deactivate) 161 | ``` 162 | 163 | ### Organization 164 | 165 | ```{admonition} Available In 166 | Books, Inventory 167 | ``` 168 | 169 | ### Package 170 | 171 | ```{admonition} Available In 172 | Inventory 173 | ``` 174 | 175 | ### PriceList 176 | 177 | ```{admonition} Available In 178 | Inventory 179 | ``` 180 | 181 | ### PurchaseOrder 182 | 183 | ```{admonition} Available In 184 | Books, Inventory 185 | ``` 186 | 187 | ### PurchaseReceive 188 | 189 | ```{admonition} Available In 190 | Inventory 191 | ``` 192 | 193 | ### RetainerInvoice 194 | 195 | ```{admonition} Available In 196 | Books, Inventory 197 | ``` 198 | 199 | ### SalesOrder 200 | 201 | ```{admonition} Available In 202 | Books, Inventory 203 | ``` 204 | 205 | ```{admonition} Additional methods 206 | :class: tip 207 | 208 | * [Confirm()](methods.md#confirm) 209 | * [GetCustomField()](methods.md#getcustomfield) 210 | * [SetCustomField()](methods.md#setcustomfield) 211 | * [Void()](methods.md#void) 212 | ``` 213 | 214 | ### SalesPerson 215 | 216 | ```{admonition} Available In 217 | Books, Inventory 218 | ``` 219 | 220 | ```{admonition} Additional methods 221 | :class: note 222 | You can only get the list of all Sales Persons from the API. No other operations 223 | work. 224 | ``` 225 | 226 | ### SalesReturn 227 | 228 | ```{admonition} Available In 229 | Inventory 230 | ``` 231 | 232 | ### ShipmentOrder 233 | 234 | ```{admonition} Available In 235 | Inventory 236 | ``` 237 | 238 | ```{admonition} Additional methods 239 | :class: tip 240 | 241 | * [Delivered()](methods.md#delivered) 242 | * [GetCustomField()](methods.md#getcustomfield) 243 | * [SetCustomField()](methods.md#setcustomfield) 244 | ``` 245 | 246 | ### Tax 247 | 248 | ```{admonition} Available In 249 | Inventory 250 | ``` 251 | 252 | ### TaxAuthority 253 | 254 | ```{admonition} Available In 255 | Inventory (US Edition Only) 256 | ``` 257 | 258 | ### TaxExemption 259 | 260 | ```{admonition} Available In 261 | Inventory (US Edition Only) 262 | ``` 263 | 264 | ### TaxGroup 265 | 266 | ```{admonition} Available In 267 | Inventory (Non-US Edition Only) 268 | ``` 269 | 270 | ### TransferOrder 271 | 272 | ```{admonition} Available In 273 | Inventory 274 | ``` 275 | 276 | ### User 277 | 278 | ```{admonition} Available In 279 | Books, Inventory 280 | ``` 281 | 282 | ```{admonition} Additional methods 283 | :class: tip 284 | 285 | * [Activate()](methods.md#activate) 286 | * [Deactivate()](methods.md#deactivate) 287 | * [GetCustomField()](methods.md#getcustomfield) 288 | * [SetCustomField()](methods.md#setcustomfield) 289 | ``` 290 | 291 | ### Warehouse 292 | 293 | ```{admonition} Available In 294 | Inventory 295 | ``` 296 | 297 | ```{warning} 298 | Warehouse does not support Get-By-ID semantics. Always use `.Iter(raw=True)` 299 | when walking the list of warehouses. 300 | 301 | This also means **ANY** operation which returns or acts on an existing warehouse 302 | is not currently supported. 303 | ``` 304 | 305 | ## By Product 306 | 307 | ### Zoho Books 308 | 309 | * [Account](#account) 310 | * [CompositeItem](#compositeitem) 311 | * [Contact](#contact) 312 | * [CustomerPayment](#customerpayment) 313 | * [Document](#document) 314 | * [Invoice](#invoice) 315 | * [Item](#item) 316 | * [ItemGroup](#itemgroup) 317 | * [Organization](#organization) 318 | * [PurchaseOrder](#purchaseorder) 319 | * [SalesOrder](#salesorder) 320 | * [SalesPerson](#salesperson) 321 | * [User](#user) 322 | 323 | ### Zoho Checkout 324 | 325 | ```{include} ../snippets/todo-future-release.markdown 326 | ``` 327 | 328 | ### Zoho Expense 329 | 330 | ```{include} ../snippets/todo-future-release.markdown 331 | ``` 332 | 333 | ### Zoho Inventory 334 | 335 | * [Account](#account) 336 | * [Bundle](#bundle) 337 | * [CompositeItem](#compositeitem) 338 | * [Contact](#contact) 339 | * ContactPerson (TODO) 340 | * [CustomerPayment](#customerpayment) 341 | * [Currency](#currency) 342 | * [Document](#document) 343 | * [Invoice](#invoice) 344 | * [Item](#item) 345 | * [ItemAdjustment](#itemadjustment) 346 | * [ItemGroup](#itemgroup) 347 | * [Organization](#organization) 348 | * [Package](#package) 349 | * [PriceList](#pricelist) 350 | * [PurchaseOrder](#purchaseorder) 351 | * [PurchaseReceive](#purchasereceive) 352 | * [RetainerInvoice](#retainerinvoice) 353 | * [SalesOrder](#salesorder) 354 | * [SalesPerson](#salesperson) 355 | * [SalesReturn](#salesreturn) 356 | * [ShipmentOrder](#shipmentorder) 357 | * [Tax](#tax) 358 | * [TaxAuthority](#taxauthority) 359 | * [TaxExemption](#taxexemption) 360 | * [TaxGroup](#taxgroup) 361 | * [TransferOrder](#transferorder) 362 | * [User](#user) 363 | * VendorCredit (TODO) 364 | * [Warehouse](#warehouse) 365 | 366 | ### Zoho Invoice 367 | 368 | ```{include} ../snippets/todo-future-release.markdown 369 | ``` 370 | 371 | ### Zoho Subscriptions 372 | 373 | ```{include} ../snippets/todo-future-release.markdown 374 | ``` 375 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## Installation 4 | ```{include} ../README.md 5 | :start-after: 6 | :end-before: 7 | ``` 8 | 9 | ## Basic Usage 10 | ```{include} ../README.md 11 | :start-after: 12 | :end-before: 13 | ``` 14 | 15 | ## API Objects 16 | All operations are, one way or another, performed via one of the several 17 | product-specific "API" objects, each named for the API they target. In the 18 | example above, we are using the "Inventory" API via the _ZohoInventory_ object. 19 | 20 | Each API object follows this pattern: 21 | ``` 22 | api = ZohoProduct(organization_id, region="us", **apiArgs) 23 | ``` 24 | where _ZohoProduct_ is one of _ZohoBooks, ZohoCheckout, ZohoExpense, 25 | ZohoInventory, ZohoInvoice, and ZohoSubscriptions_. 26 | 27 | ```{note} 28 | As of this release, we only include support for _Inventory_. 29 | ``` 30 | `region` should be either the Top-Level Domain name of the region you're in, or 31 | the region's "friendly" name (case insensitive), one of _Australia, Europe, 32 | India, US_. 33 | 34 | Generally, provide client_id, client_secret, refresh_token and redirect_url as 35 | keyword arguments. 36 | 37 | ```{note} 38 | You won't need redirect_url for Self-Client or Non-Browser Client access. 39 | ``` 40 | ```{warning} 41 | Client ID and Secrets are just that: SECRET. Please don't include them directly 42 | in any code you might share. 43 | ``` 44 | ```{seealso} 45 | Zoho uses OAuth procedures to provide your Access Tokens. Please see [Zoho's 46 | API Documentation on OAuth](https://www.zoho.com/inventory/api/v1/#oauth) for 47 | details. 48 | ``` 49 | 50 | ## Zoho Objects 51 | You access Zoho Objects via the API Object. Let's assume you have an 52 | _inventory_ object defined as: 53 | ```{code-block} python 54 | inventory = ZohoInventory(...) 55 | ``` 56 | Now you can get various Zoho objects by using their type name, thus: 57 | ```{code-block} python 58 | user = inventory.User(...) 59 | item = inventory.Item(...) 60 | composite = inventory.CompositeItem(...) 61 | ``` 62 | 63 | ### Object Properties 64 | We surface a small number of properties, to wit: 65 | | Property | Purpose | 66 | | :--- | ---: | 67 | | ID | The Zoho id number of the underlying object. | 68 | | IsDeleted | True if the object was deleted from Zoho. | 69 | | IsList | True if the object is a list of Zoho objects | 70 | | IsLoaded | True if we've fetched the corresponding object or list from Zoho | 71 | | Number | The Zoho document number of the underlying object | 72 | 73 | ### Get an Existing Object 74 | If you know the object ID: 75 | ```{code-block} python 76 | customer = inventory.Contact(id) 77 | ``` 78 | 79 | If you know some other unique identifier, take the first (only) object from a 80 | search for that identifier. 81 | ```{code-block} python 82 | customer = inventory.Contact(email="test@example.com").First() 83 | ``` 84 | 85 | ### Create a new Object 86 | Instantiate a new object, set its fields, and Create() it. If it now has an ID, 87 | it was created. 88 | ```{code-block} python 89 | invoice = inventory.Invoice() 90 | invoice.customer_id = customer.ID 91 | ... # more fields should be set here. 92 | invoice.Create() 93 | if invoice.ID: 94 | print("Invoice created!") 95 | ``` 96 | 97 | ### Update an Existing Object 98 | Given an object, change or add whichever fields you want, then Update() it. 99 | ```{code-block} python 100 | customer.first_name = "Ford" 101 | customer.last_name = "Prefect" 102 | customer.Update() 103 | ``` 104 | 105 | ### Delete an Existing Object 106 | Deleting an object in Zoho doesn't zap the data in the Python object, so it's 107 | available, if needed. 108 | ```{code-block} python 109 | if invoice.Delete().IsDeleted: 110 | print(f"Deleted invoice {invoice.Number}") 111 | ``` 112 | 113 | ### Get a list of Objects 114 | You can iterate over EVERY object of a particular type. 115 | ```{code-block} python 116 | for user in inventory.User(): 117 | print(user.name) 118 | ``` 119 | ```{warning} 120 | Use this with form with care. The above example, User, is likely not going to 121 | result in a huge list, but if you were to iterate over every, say, Invoice or 122 | Item in your organization, you could very well run yourself out of API calls. 123 | ``` 124 | 125 | You can search and filter lists of objects. 126 | ```{code-block} python 127 | items = inventory.Item(category_id="9876543210987654321") 128 | for item in items: 129 | print(item.name) 130 | for item in items.Iter(lambda item: item.sku.startswith('S123')) 131 | print(item.name) 132 | ``` 133 | The first loop gathers all Item objects which match the criteria specified. The 134 | second loop walks **the same list as the first**, but only prints those which 135 | pass the filter function. 136 | 137 | ```{warning} 138 | The following is a Bad Idea, because it will request EVERY Item in your 139 | inventory prior to filtering. 140 | ```{code-block} python 141 | items = inventory.Item() 142 | for item in items.Iter(lambda item: item.sku.startswith('S123')) 143 | print(item.name) 144 | ``` 145 | ``` 146 | -------------------------------------------------------------------------------- /docs/readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | install: 5 | - requirements: docs/requirements.txt 6 | -------------------------------------------------------------------------------- /docs/snippets/seealso-zoho-api.markdown: -------------------------------------------------------------------------------- 1 | ```{seealso} 2 | Please see the official Zoho API References for the purpose and semantics of 3 | each object. 4 | 5 | * [Books API]() 6 | * [Inventory API]() 7 | * [Invoice API](https://www.zoho.com/invoice/api/v3/) 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/snippets/todo-future-release.markdown: -------------------------------------------------------------------------------- 1 | ```{todo} 2 | Planned for a future release. 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/userguide/apiobject.md: -------------------------------------------------------------------------------- 1 | # API Objects 2 | 3 | All operations start with one of the several product-specific API objects, each 4 | named for the API they target. 5 | 6 | Ideally, you create a single instance of any given API object, and use it for 7 | the lifetime of your application. 8 | 9 | ## Creating an API Instance 10 | 11 | Creating an API object follows this pattern: 12 | 13 | ``` 14 | api = ZohoProduct(organization_id, region="us", **apiArgs) 15 | ``` 16 | 17 | where _ZohoProduct_ is one of _ZohoBooks, ZohoCheckout, ZohoExpense, 18 | ZohoInventory, ZohoInvoice, and ZohoSubscriptions_. 19 | 20 | ### Organization ID 21 | 22 | You must pass your Organization ID as the first parameter to any API object's 23 | constructor. 24 | 25 | ```{admonition} How to find your Organization ID 26 | :class: seealso 27 | From within the Zoho product WebUI, click on either the organization name or 28 | your user avatar within the product header (far right). The Organization ID is 29 | listed under each Organization you are a member of. 30 | ``` 31 | 32 | ### API Region 33 | 34 | Zoho operates in [several different data 35 | centers](https://www.zoho.com/books/api/v3/#multidc), and which you use for your 36 | API calls depends on where your product is running. You can pass in either the 37 | Top-Level Domain name (ok, Australia's isn't _technically_ a TLD) or the 38 | region's "friendly" name (case insensitive). These are: 39 | 40 | * Australia 41 | * Europe 42 | * India 43 | * US 44 | * Japan 45 | * Canada 46 | 47 | ## Other API Parameters 48 | 49 | There are no mandatory parameters in this list, but the API won't function 50 | without at least some of these. 51 | 52 | The bare minimum are _access_token_ and _expires_in_. But this only works if you 53 | have a recent - less than an hour old - Access Token available to you from some 54 | other channel. 55 | 56 | In real usage, you're going to need client_id, client_secret, and refresh_token, 57 | at a minimum. 58 | 59 | In addition, there are several parameters which tune the package around Zoho's 60 | API limits. 61 | 62 | ### client_id and client_secret 63 | 64 | The Zoho OAuth client credentials. 65 | 66 | ```{admonition} How to find your Client ID and Secret 67 | :class: seealso 68 | 69 | From the [Zoho API Console](https://api-console.zoho.com), create or edit the 70 | appropriate client type (depending on your application requirements). The Client 71 | ID and Client Secret are available there. 72 | ``` 73 | 74 | ```{warning} 75 | Client ID and Secrets are just that: SECRET. Please don't include them directly 76 | in any code you might share. 77 | ``` 78 | 79 | ### intercall_delay 80 | 81 | Determines the minimum amount of time, in seconds, between each API call. 82 | 83 | **Default: 0** 84 | 85 | ### max_retries 86 | 87 | Determines how many times, at most, we will retry a throttled call. 88 | 89 | **Default: 10** 90 | 91 | ### max_retry_after 92 | 93 | The longest time, in seconds, we are willing to wait to retry a throttled call. 94 | If Zoho sends a **Retry-After** header of more than this amount, we'll abort the 95 | call with an exception. 96 | 97 | **Default: 180** 98 | 99 | ### min_calls_remaining 100 | 101 | For Orgs/APIs with a per-day call limit, this is the minimum number of calls 102 | that have to remain in our allotment for us to attempt a call. If there are 103 | fewer than this, we raise an exception. 104 | 105 | **Default: 1** 106 | 107 | ```{note} 108 | Since we have to make at least one call to be informed how many calls remain, we 109 | could, under certain circumstances, run you below this minimum, or even out of 110 | calls. 111 | ``` 112 | 113 | ### redirect_url 114 | 115 | One of the Redirect URLs registered with the Zoho OAuth Client. 116 | 117 | ### refresh_token 118 | 119 | One of your current, active Refresh Tokens, for use in creating Access Tokens. 120 | 121 | ```{admonition} How to get a Refresh Token. 122 | :class: seealso 123 | 124 | Zoho uses OAuth procedures to provide you Access and Refresh Tokens. Please see 125 | [Zoho' API Documentation on OAuth](https://www.zoho.com/invtory/api/v1/#oauth) for 126 | details. 127 | ``` 128 | 129 | ### retry_backoff_seconds 130 | 131 | The amount of time, in seconds, to wait between throttled calls, if Zoho doesn't 132 | provide a **Retry-After** header. 133 | 134 | **Default: 0.5** 135 | 136 | ```{todo} 137 | Add Self-Client and Non-Browser Client instructions. 138 | ``` 139 | -------------------------------------------------------------------------------- /docs/userguide/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions 2 | 3 | All exceptions we raise, other than basic Python exceptions like `KeyError`, are 4 | subclasses of `ZohoException`. 5 | 6 | ## Base class 7 | 8 | ### `ZohoException` 9 | 10 | ```{todo} 11 | More docs. 12 | ``` 13 | 14 | ## Exceptions the API may raise 15 | 16 | ### `ZohoAPICallsExceeded` 17 | 18 | Raised if the operation on the object exceeded our allotment of API calls. 19 | 20 | ### `ZohoAPIThrottled` 21 | 22 | Raised if we are throttled for too many concurrent requests or calling the API 23 | too rapidly, and our back-off limits were exceeded. 24 | 25 | ### `ZohoAuthRefreshFailure` 26 | 27 | Raised if an attempt to refresh our Access Token fails. 28 | 29 | ### `ZohoBadRequest` 30 | 31 | ```{todo} 32 | More docs. 33 | ``` 34 | 35 | ### `ZohoInsufficientAuthKeys` 36 | 37 | Raised if we don't have all the OAuth parameters we need to authenticate. 38 | 39 | ### `ZohoInvalidOpError` 40 | 41 | Raised if an operation on an object is invalid, for example calling `Create()` 42 | on an existing object. 43 | 44 | ### `ZohoMethodNotAllowed` 45 | 46 | ```{todo} 47 | More docs. 48 | ``` 49 | 50 | ### `ZohoNotFound` 51 | 52 | ```{todo} 53 | More docs. 54 | ``` 55 | 56 | ### `ZohoUnauthorized` 57 | 58 | ```{todo} 59 | More docs. 60 | ``` 61 | 62 | ### `ZohoUnknownRegionException` 63 | 64 | Raised if you try to create an API object with either an invalid region, or a 65 | region not supported by the API. 66 | -------------------------------------------------------------------------------- /docs/userguide/index.md: -------------------------------------------------------------------------------- 1 | # User's Guide 2 | 3 | ## Short Version 4 | 5 | Create an API Object for the product(s) you want to access: 6 | 7 | ```{code-block} python 8 | from pyzohoapi import * 9 | from my_private_data import org_id, my_api_tokens_dict 10 | 11 | inventory = ZohoInventory(org_id, "US", **my_api_tokens_dict) 12 | ``` 13 | 14 | Find existing things in Zoho: 15 | 16 | ```{code-block} python 17 | customer = inventory.Contact(email="customer@example.com").First() 18 | items = inventory.Item(search_text="product type") 19 | ``` 20 | 21 | Then make a new thing in Zoho: 22 | 23 | ```{code-block} python 24 | invoice = inventory.Invoice() 25 | invoice.customer_id = customer.ID 26 | invoice.line_items = [] 27 | for item in items: 28 | invoice.line_items.append({ 29 | 'item_id': item.ID, 30 | 'quantity': 1, 31 | }) 32 | invoice.Create() 33 | if invoice.ID: 34 | print(f"Created Invoice {invoice.Number}") 35 | ``` 36 | 37 | Then do a thing with a thing in Zoho: 38 | 39 | ```{code-block} python 40 | invoice.Approve() 41 | ``` 42 | 43 | ## A Note on PEP8-Compliance 44 | 45 | Yes, I know, my method/property names aren't PEP8-compliant. This is 46 | intentional. 47 | 48 | Because each Zoho object field is exposed as an attribute of the corresponding 49 | Python object, and those are (1) lower_case_with_underscore and (2) could be the 50 | same as one of my function names and (3) are subject to add/change/delete by 51 | Zoho, I've decided to CamelCase my method and property names, to avoid 52 | potential collision with any field name Zoho has or will expose in their 53 | JSON responses. 54 | 55 | I thought about changing the mapping from Zoho's lower_case_with_underscore to 56 | CamelCase. That seems like more work than is needed, since the interface surface 57 | of the Python objects is much smaller than the interface surface of the Zoho 58 | objects. 59 | 60 | On the brighter side, the internals are PEP8ie. 61 | 62 | ## Long Version 63 | 64 | ```{toctree} 65 | apiobject 66 | zohoobject 67 | exceptions 68 | logging 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/userguide/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | We register a standard Python `Logger` named `pyzohoapi`. 4 | 5 | ## Configuring Logging 6 | 7 | Our logger only emits **DEBUG**-level LogRecords; in order to get these messages 8 | into your application's logs, use, for example: 9 | 10 | ```{code-block} python 11 | import logging 12 | logging.basicConfig(level=logging.DEBUG) 13 | ``` 14 | 15 | However, since we use the `requests` package internally, the above will get you all the `urllib3` messages, which you may not want. This will filter those out handily: 16 | 17 | ```{code-block} python 18 | logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) 19 | ``` 20 | 21 | ## Activity we log 22 | 23 | We emit `LogRecords` when we: 24 | 25 | * request a new access token 26 | * make an HTTP request (GET, DELETE, POST, PUT) 27 | * pause to implement rate limiting 28 | * exceed our API call limit 29 | * pause before retrying an HTTP request 30 | -------------------------------------------------------------------------------- /docs/userguide/zohoobject.md: -------------------------------------------------------------------------------- 1 | # Zoho Objects 2 | 3 | The Python object which represents a Zoho object can also represent a list of 4 | Zoho objects, depending on the parameters passed during instantiation. 5 | 6 | If you pass in the Zoho ID of the object, the Python object will map to that 7 | particular Zoho object. In all other cases, the Python object is a List-of those 8 | Zoho objects. Except if you pass NO parameters, then you get a "New" object. If 9 | you then iterate on it, it becomes a list. Strange but true. 10 | 11 | ```{note} 12 | **Updated in v0.10.0**: Numeric field(s) in a Zoho object are mapped to a 13 | `decimal.Decimal` to reduce or eliminate floating-point errors when doing math 14 | on currency values. This should be largely transparent, but there may be cases 15 | where you'll need to write your calculative operations with `decimal.Decimal` 16 | objects explicitly. 17 | ``` 18 | 19 | ## Common Methods 20 | 21 | These are the methods available in EVERY Zoho object. Not every method makes 22 | sense in every context, and in those cases the method will generally No-op. 23 | 24 | Many methods return *self*, so they can be chained. 25 | 26 | As the header of this section suggests, there are also object-specific methods. 27 | These are documented in the [Object Type Reference](/objrefs/index.md) section. 28 | 29 | ### Accessor/Constructor 30 | 31 | To get an object of a particular type, you do something of the form: 32 | 33 | ```{code-block} python 34 | >>> obj = api.ObjectType() 35 | >>> print(obj) 36 | New ObjectType 37 | ``` 38 | 39 | If you know the Zoho ID of the object you want, then: 40 | 41 | ```{code-block} python 42 | >>> obj = api.ObjectType(id) 43 | >>> print(obj) 44 | ObjectType #9876543210987654321 45 | ``` 46 | 47 | Or you can specify API search criteria as key/value parameters: 48 | 49 | ```{code-block} python 50 | >>> obj = api.ObjectType(search_criteria="9876543210987654321") 51 | >>> print(obj) 52 | List of ObjectType objects 53 | ``` 54 | 55 | ```{note} 56 | The above are (I hope clearly) just examples and are not directly usable. 57 | There's no Zoho object called (as of this writing) "ObjectType". 58 | ``` 59 | 60 | ### `get()` 61 | 62 | Implements the `get()` semantics of a dictionary. Use this just like the native 63 | function. 64 | 65 | ### `Create()` 66 | 67 | ```{code-block} python 68 | >>> item = inventory.Item() 69 | >>> item.name = "Test Item" 70 | >>> item.rate = 42.00 71 | >>> item.Create() 72 | Item #9876543210987654321 73 | ``` 74 | 75 | There is no checking to ensure all required fields are set, nor that any of the 76 | values make sense. If the object was created in Zoho, *self* gets an `ID` and 77 | is updated with all default fields Zoho provided. 78 | 79 | Calling `Create()` on a not-New object raises an exception. 80 | 81 | ### `Delete()` 82 | 83 | ```{code-block} python 84 | >>> item = inventory.Item(id) 85 | >>> item.ID 86 | 9876543210987654321 87 | >>> item.Delete().IsDeleted 88 | True 89 | >>> item.ID 90 | False 91 | >>> item.item_id 92 | None 93 | ``` 94 | 95 | After deleting the object from Zoho, all the field data is still available in 96 | the Python object, so in theory you could re-create the object by calling 97 | `Create()`, or modify it and then re-create. YMMV. Of course, you'll get a 98 | new `ID` in that case. 99 | 100 | Calling `Delete()` on a List-of or New object raises an exception. 101 | 102 | ### `First()` 103 | 104 | `First()` returns the first object found by a search; if called on a new or 105 | mapped object, it returns *self*, so it's safe to use anywhere. Useful if you 106 | know your search will only return one result. 107 | 108 | ```{code-block} python 109 | >>> user = inventory.User(email="test@example.com").First() 110 | >>> user.IsLoaded: 111 | True 112 | >>> user.email 113 | 'test@example.com' 114 | ``` 115 | 116 | **_Updated in v0.7.2_**: Keyword arguments provided to `First()` will be used as 117 | a filter, in the manner of `Iter()`, to determine which object counts as 118 | "first." The filtered attributes **MUST** exist in the List-of object 119 | attributes. For example: 120 | 121 | ```{code-block} python 122 | >>> user = inventory.User().First(status="inactive") 123 | >>> user.IsLoaded: 124 | True 125 | >>> user.email 126 | 'inactiveuser@example.com' 127 | ``` 128 | 129 | ### `GetRelated()` 130 | 131 | For object types which includes references to other object types, 132 | `GetRelated()` will return that object: 133 | 134 | ```{code-block} python 135 | >>> contact = inventory.SalesOrder(id).GetRelated(inventory.Contact, 'customer_id') 136 | >>> contact.company_name 137 | Acme International 138 | ``` 139 | 140 | `GetRelated()` is a more convenient and safe version of: 141 | 142 | ```{code-block} python 143 | >>> so = inventory.SalesOrder(id) 144 | >>> contact = inventory.Contact(so.customer_id) 145 | >>> contact.company_name 146 | Acme International 147 | ``` 148 | 149 | ### `Iter()` and `__iter__()` 150 | 151 | We handle pagination of List-of objects transparently, so you can treat List-of 152 | objects as iterables: 153 | 154 | ```{code-block} python 155 | >>> for invoice in inventory.Invoice(date="2021-01-01", status="paid"): 156 | ... invoice 157 | Invoice #9876543210987654321 158 | Invoice #9876543210987654322 159 | Invoice #9876543210987654323 160 | ``` 161 | 162 | This has some potential drawbacks. 163 | 164 | If your search criteria is too broad, the list could be much longer than you 165 | really need, and EVERY object which matches your criteria will be individually 166 | retrieved, costing you ***lots*** of API calls. 167 | 168 | If you only need values from fields which are already in the search results, 169 | and you don't need to manipulate each individual object, use `Iter()` 170 | with the `raw` flag: 171 | 172 | ```{code-block} python 173 | >>> paid_today = inventory.Invoice(date="2021-01-01", status="paid") 174 | >>> for invoice in paid_today.Iter(raw=True): 175 | ... print(invoice.invoice_id) 176 | '9876543210987654321' 177 | '9876543210987654322' 178 | '9876543210987654323' 179 | ``` 180 | 181 | ```{note} 182 | In the above example, we used the Zoho field name `invoice_id` rather 183 | than the object property `ID` because **we didn't retrieve an object**. Here 184 | `invoice` is a dictionary which supports using '.' in addition to '[]' semantics 185 | for getting values. 186 | ``` 187 | 188 | You can also filter the list using `Iter()`. The caveat is that the field(s) 189 | you're filtering on must exist in the search results: 190 | 191 | ```{code-block} python 192 | >>> paid_today = inventory.Invoice(date="2021-01-01", status="paid") 193 | >>> for invoice in paid_today.Iter(currency_code="USD", due_date="2021-02-28"): 194 | ... invoice 195 | Invoice #9876543210987654321 196 | Invoice #9876543210987654323 197 | ``` 198 | 199 | If you need a more complex filter, pass a function as the first parameter: 200 | 201 | ```{code-block} python 202 | >>> paid_today = inventory.Invoice(date="2021-01-01", status="paid") 203 | >>> for invoice in paid_today.Iter(lambda inv: inv.total > 1000.00): 204 | ... invoice 205 | Invoice #9876543210987654322 206 | Invoice #9876543210987654323 207 | ``` 208 | 209 | ### `IterRelatedList()` 210 | 211 | For objects which include one or more lists of references to other object types, 212 | you can get each of those objects: 213 | 214 | ```{code-block} python 215 | >>> salesorder.line_items 216 | [{'item_id': "123", ...}, {'item_id': "345", ...}] 217 | >>> for item in salesorder.IterRelatedList(inventory.Item, 'line_items', 'item_id'): 218 | ... item 219 | Item #123 220 | Item #345 221 | ``` 222 | 223 | ### `MapRelatedList()` 224 | 225 | For objects which include one or more lists of references to other object types, 226 | `MapRelatedList()` iterates over such a list, returning a composite object 227 | consisting of the data from the target list, and the related object. A (slightly 228 | simplified) example might help: 229 | 230 | ```{code-block} python 231 | >>> salesorder.line_items 232 | [{'item_id': "123", ...}, {'item_id': "345", ...}] 233 | >>> for item in salesorder.IterRelatedList(api.Item, 'line_items', 'item_id'): 234 | ... item 235 | Item #123 236 | Item #345 237 | >>> for item in saleorder.MapRelatedList(api.Item, 'line_items', 'item_id'): 238 | ... item 239 | {'meta': {'item_id': "123", ...}, 'object': Item #123} 240 | {'meta': {'item_id': "345", ...}, 'object': Item #345} 241 | ``` 242 | 243 | ### `Update()` 244 | 245 | Changes you make to the fields in an existing Zoho object are pushed into Zoho 246 | by calling `Update()`. 247 | 248 | ```{code-block} python 249 | >>> salesorder.line_items.append({'item_id':"9876543210987654321", 'quantity': 2}) 250 | >>> salesorder.shipping_charges += 12.50 251 | >>> salesorder.Update() 252 | SalesOrder #9876543210987654321 253 | ``` 254 | 255 | ## Object Properties 256 | 257 | ### `ID` 258 | 259 | Each Zoho object type has a different "id" field, but they are mapped to the 260 | `ID` property of the corresponding Python object. For example: 261 | 262 | ```{code-block} python 263 | >>> customer = inventory.Contact(id) 264 | >>> customer.ID == customer.contact_id 265 | True 266 | 267 | >>> item = inventory.Item(id) 268 | >>> item.ID == item.item_id 269 | True 270 | ``` 271 | 272 | ### `Number` 273 | 274 | Some, but not all, Zoho object types have a "number" field. These are exposed 275 | via the `Number` property. If the underlying object doesn't have a "number" 276 | field, using the `Number` property will raise a `KeyError`. 277 | 278 | ```{code-block} python 279 | >>> invoice = inventory.Invoice(id) 280 | >>> invoice.Number 281 | INV-09472 282 | 283 | >>> item = inventory.Item(id) 284 | >>> item.Number 285 | ...(snip)... 286 | KeyError: 'item_number' 287 | ``` 288 | 289 | ### `IsDeleted` 290 | 291 | *True* if the object has been deleted from Zoho. The data contained within the 292 | object is still available, except the object's native \*\_id field, which will 293 | be *None*. The `ID` property will be *False*. See [`Delete()`](#delete). 294 | 295 | ### `IsList` 296 | 297 | *True* if the object is a List-of Zoho objects. 298 | 299 | ```{code-block} python 300 | >>> inventory.SalesOrder(customer_id="9876543210987654321").IsList 301 | True 302 | ``` 303 | 304 | ### `IsLoaded` 305 | 306 | *True* if we've loaded data from Zoho. This is helpful for testing if an 307 | object was available in Zoho. 308 | 309 | ```{code-block} python 310 | >>> inventory.SalesOrder(id).IsLoaded 311 | True 312 | >>> inventory.SalesOrder(bad_id).IsLoaded 313 | False 314 | >>> inventory.SalesOrder(customer_id="9876543210987654321").IsLoaded 315 | True 316 | >>> inventory.SalesOrder().IsLoaded 317 | False 318 | ``` 319 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "alabaster" 3 | version = "0.7.13" 4 | description = "A configurable sidebar-enabled Sphinx theme" 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.1" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "20.3.0" 20 | description = "Classes Without Boilerplate" 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 27 | docs = ["furo", "sphinx", "zope.interface"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 30 | 31 | [[package]] 32 | name = "babel" 33 | version = "2.11.0" 34 | description = "Internationalization utilities" 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.6" 38 | 39 | [package.dependencies] 40 | pytz = ">=2015.7" 41 | 42 | [[package]] 43 | name = "beautifulsoup4" 44 | version = "4.12.3" 45 | description = "Screen-scraping library" 46 | category = "dev" 47 | optional = false 48 | python-versions = ">=3.6.0" 49 | 50 | [package.dependencies] 51 | soupsieve = ">1.2" 52 | 53 | [package.extras] 54 | cchardet = ["cchardet"] 55 | chardet = ["chardet"] 56 | charset-normalizer = ["charset-normalizer"] 57 | html5lib = ["html5lib"] 58 | lxml = ["lxml"] 59 | 60 | [[package]] 61 | name = "certifi" 62 | version = "2024.2.2" 63 | description = "Python package for providing Mozilla's CA Bundle." 64 | category = "main" 65 | optional = false 66 | python-versions = ">=3.6" 67 | 68 | [[package]] 69 | name = "charset-normalizer" 70 | version = "2.0.12" 71 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 72 | category = "main" 73 | optional = false 74 | python-versions = ">=3.5.0" 75 | 76 | [package.extras] 77 | unicode_backport = ["unicodedata2"] 78 | 79 | [[package]] 80 | name = "colorama" 81 | version = "0.4.5" 82 | description = "Cross-platform colored terminal text." 83 | category = "dev" 84 | optional = false 85 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 86 | 87 | [[package]] 88 | name = "docutils" 89 | version = "0.16" 90 | description = "Docutils -- Python Documentation Utilities" 91 | category = "dev" 92 | optional = false 93 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 94 | 95 | [[package]] 96 | name = "furo" 97 | version = "2020.12.30b24" 98 | description = "A clean customisable Sphinx documentation theme." 99 | category = "dev" 100 | optional = false 101 | python-versions = ">=3.5" 102 | 103 | [package.dependencies] 104 | beautifulsoup4 = "*" 105 | sphinx = ">=3.0,<4.0" 106 | 107 | [package.extras] 108 | doc = ["myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] 109 | test = ["pytest", "pytest-cov", "pytest-xdist"] 110 | 111 | [[package]] 112 | name = "idna" 113 | version = "3.7" 114 | description = "Internationalized Domain Names in Applications (IDNA)" 115 | category = "main" 116 | optional = false 117 | python-versions = ">=3.5" 118 | 119 | [[package]] 120 | name = "imagesize" 121 | version = "1.4.1" 122 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 123 | category = "dev" 124 | optional = false 125 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 126 | 127 | [[package]] 128 | name = "importlib-metadata" 129 | version = "4.8.3" 130 | description = "Read metadata from Python packages" 131 | category = "dev" 132 | optional = false 133 | python-versions = ">=3.6" 134 | 135 | [package.dependencies] 136 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 137 | zipp = ">=0.5" 138 | 139 | [package.extras] 140 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 141 | perf = ["ipython"] 142 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 143 | 144 | [[package]] 145 | name = "iniconfig" 146 | version = "1.1.1" 147 | description = "iniconfig: brain-dead simple config-ini parsing" 148 | category = "dev" 149 | optional = false 150 | python-versions = "*" 151 | 152 | [[package]] 153 | name = "jinja2" 154 | version = "3.0.3" 155 | description = "A very fast and expressive template engine." 156 | category = "dev" 157 | optional = false 158 | python-versions = ">=3.6" 159 | 160 | [package.dependencies] 161 | MarkupSafe = ">=2.0" 162 | 163 | [package.extras] 164 | i18n = ["Babel (>=2.7)"] 165 | 166 | [[package]] 167 | name = "livereload" 168 | version = "2.6.3" 169 | description = "Python LiveReload is an awesome tool for web developers" 170 | category = "dev" 171 | optional = false 172 | python-versions = "*" 173 | 174 | [package.dependencies] 175 | six = "*" 176 | tornado = {version = "*", markers = "python_version > \"2.7\""} 177 | 178 | [[package]] 179 | name = "markdown-it-py" 180 | version = "0.6.2" 181 | description = "Python port of markdown-it. Markdown parsing, done right!" 182 | category = "dev" 183 | optional = false 184 | python-versions = "~=3.6" 185 | 186 | [package.dependencies] 187 | attrs = ">=19,<21" 188 | mdit-py-plugins = ">=0.2.1,<0.3.0" 189 | 190 | [package.extras] 191 | code_style = ["pre-commit (==2.6)"] 192 | compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.2.2,<3.3.0)", "mistune (>=0.8.4,<0.9.0)", "mistletoe-ebp (>=0.10.0,<0.11.0)", "panflute (>=1.12,<2.0)"] 193 | linkify = ["linkify-it-py (>=1.0,<2.0)"] 194 | rtd = ["myst-nb (>=0.11.1,<0.12.0)", "sphinx-book-theme", "sphinx-panels (>=0.4.0,<0.5.0)", "sphinx-copybutton", "sphinx (>=2,<4)", "pyyaml"] 195 | testing = ["coverage", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions", "pytest-benchmark (>=3.2,<4.0)", "psutil"] 196 | 197 | [[package]] 198 | name = "markupsafe" 199 | version = "2.0.1" 200 | description = "Safely add untrusted strings to HTML/XML markup." 201 | category = "dev" 202 | optional = false 203 | python-versions = ">=3.6" 204 | 205 | [[package]] 206 | name = "mdit-py-plugins" 207 | version = "0.2.6" 208 | description = "Collection of plugins for markdown-it-py" 209 | category = "dev" 210 | optional = false 211 | python-versions = "~=3.6" 212 | 213 | [package.dependencies] 214 | markdown-it-py = ">=0.5.8,<2.0.0" 215 | 216 | [package.extras] 217 | code_style = ["pre-commit (==2.6)"] 218 | testing = ["coverage", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] 219 | 220 | [[package]] 221 | name = "myst-parser" 222 | version = "0.13.7" 223 | description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." 224 | category = "dev" 225 | optional = false 226 | python-versions = ">=3.6" 227 | 228 | [package.dependencies] 229 | docutils = ">=0.15,<0.18" 230 | jinja2 = "*" 231 | markdown-it-py = ">=0.6.2,<0.7.0" 232 | mdit-py-plugins = ">=0.2.5,<0.3.0" 233 | pyyaml = "*" 234 | sphinx = ">=2.1,<4" 235 | 236 | [package.extras] 237 | code_style = ["pre-commit (>=2.12,<3.0)"] 238 | linkify = ["linkify-it-py (>=1.0,<2.0)"] 239 | rtd = ["ipython", "sphinx-book-theme (>=0.1.0,<0.2.0)", "sphinx-panels (>=0.5.2,<0.6.0)", "sphinxcontrib-bibtex (>=2.1,<3.0)"] 240 | testing = ["beautifulsoup4", "coverage", "docutils (>=0.17.0,<0.18.0)", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] 241 | 242 | [[package]] 243 | name = "packaging" 244 | version = "21.3" 245 | description = "Core utilities for Python packages" 246 | category = "dev" 247 | optional = false 248 | python-versions = ">=3.6" 249 | 250 | [package.dependencies] 251 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 252 | 253 | [[package]] 254 | name = "pluggy" 255 | version = "1.0.0" 256 | description = "plugin and hook calling mechanisms for python" 257 | category = "dev" 258 | optional = false 259 | python-versions = ">=3.6" 260 | 261 | [package.dependencies] 262 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 263 | 264 | [package.extras] 265 | dev = ["pre-commit", "tox"] 266 | testing = ["pytest", "pytest-benchmark"] 267 | 268 | [[package]] 269 | name = "py" 270 | version = "1.11.0" 271 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 272 | category = "dev" 273 | optional = false 274 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 275 | 276 | [[package]] 277 | name = "pygments" 278 | version = "2.14.0" 279 | description = "Pygments is a syntax highlighting package written in Python." 280 | category = "dev" 281 | optional = false 282 | python-versions = ">=3.6" 283 | 284 | [package.extras] 285 | plugins = ["importlib-metadata"] 286 | 287 | [[package]] 288 | name = "pyparsing" 289 | version = "3.0.7" 290 | description = "Python parsing module" 291 | category = "dev" 292 | optional = false 293 | python-versions = ">=3.6" 294 | 295 | [package.extras] 296 | diagrams = ["jinja2", "railroad-diagrams"] 297 | 298 | [[package]] 299 | name = "pytest" 300 | version = "6.2.5" 301 | description = "pytest: simple powerful testing with Python" 302 | category = "dev" 303 | optional = false 304 | python-versions = ">=3.6" 305 | 306 | [package.dependencies] 307 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 308 | attrs = ">=19.2.0" 309 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 310 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 311 | iniconfig = "*" 312 | packaging = "*" 313 | pluggy = ">=0.12,<2.0" 314 | py = ">=1.8.2" 315 | toml = "*" 316 | 317 | [package.extras] 318 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 319 | 320 | [[package]] 321 | name = "pytz" 322 | version = "2024.1" 323 | description = "World timezone definitions, modern and historical" 324 | category = "dev" 325 | optional = false 326 | python-versions = "*" 327 | 328 | [[package]] 329 | name = "pyyaml" 330 | version = "6.0.1" 331 | description = "YAML parser and emitter for Python" 332 | category = "dev" 333 | optional = false 334 | python-versions = ">=3.6" 335 | 336 | [[package]] 337 | name = "requests" 338 | version = "2.27.1" 339 | description = "Python HTTP for Humans." 340 | category = "main" 341 | optional = false 342 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 343 | 344 | [package.dependencies] 345 | certifi = ">=2017.4.17" 346 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 347 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 348 | urllib3 = ">=1.21.1,<1.27" 349 | 350 | [package.extras] 351 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 352 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 353 | 354 | [[package]] 355 | name = "simplejson" 356 | version = "3.19.2" 357 | description = "Simple, fast, extensible JSON encoder/decoder for Python" 358 | category = "main" 359 | optional = false 360 | python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" 361 | 362 | [[package]] 363 | name = "six" 364 | version = "1.16.0" 365 | description = "Python 2 and 3 compatibility utilities" 366 | category = "dev" 367 | optional = false 368 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 369 | 370 | [[package]] 371 | name = "snowballstemmer" 372 | version = "2.2.0" 373 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 374 | category = "dev" 375 | optional = false 376 | python-versions = "*" 377 | 378 | [[package]] 379 | name = "soupsieve" 380 | version = "2.3.2.post1" 381 | description = "A modern CSS selector implementation for Beautiful Soup." 382 | category = "dev" 383 | optional = false 384 | python-versions = ">=3.6" 385 | 386 | [[package]] 387 | name = "sphinx" 388 | version = "3.5.4" 389 | description = "Python documentation generator" 390 | category = "dev" 391 | optional = false 392 | python-versions = ">=3.5" 393 | 394 | [package.dependencies] 395 | alabaster = ">=0.7,<0.8" 396 | babel = ">=1.3" 397 | colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} 398 | docutils = ">=0.12,<0.17" 399 | imagesize = "*" 400 | Jinja2 = ">=2.3" 401 | packaging = "*" 402 | Pygments = ">=2.0" 403 | requests = ">=2.5.0" 404 | snowballstemmer = ">=1.1" 405 | sphinxcontrib-applehelp = "*" 406 | sphinxcontrib-devhelp = "*" 407 | sphinxcontrib-htmlhelp = "*" 408 | sphinxcontrib-jsmath = "*" 409 | sphinxcontrib-qthelp = "*" 410 | sphinxcontrib-serializinghtml = "*" 411 | 412 | [package.extras] 413 | docs = ["sphinxcontrib-websupport"] 414 | lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"] 415 | test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] 416 | 417 | [[package]] 418 | name = "sphinx-autobuild" 419 | version = "2020.9.1" 420 | description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." 421 | category = "dev" 422 | optional = false 423 | python-versions = ">=3.6" 424 | 425 | [package.dependencies] 426 | livereload = "*" 427 | sphinx = "*" 428 | 429 | [package.extras] 430 | test = ["pytest", "pytest-cov"] 431 | 432 | [[package]] 433 | name = "sphinx-copybutton" 434 | version = "0.3.3" 435 | description = "Add a copy button to each of your code cells." 436 | category = "dev" 437 | optional = false 438 | python-versions = ">=3.6" 439 | 440 | [package.dependencies] 441 | sphinx = ">=1.8" 442 | 443 | [package.extras] 444 | code_style = ["pre-commit (==2.12.1)"] 445 | 446 | [[package]] 447 | name = "sphinxcontrib-applehelp" 448 | version = "1.0.2" 449 | description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" 450 | category = "dev" 451 | optional = false 452 | python-versions = ">=3.5" 453 | 454 | [package.extras] 455 | lint = ["flake8", "mypy", "docutils-stubs"] 456 | test = ["pytest"] 457 | 458 | [[package]] 459 | name = "sphinxcontrib-devhelp" 460 | version = "1.0.2" 461 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." 462 | category = "dev" 463 | optional = false 464 | python-versions = ">=3.5" 465 | 466 | [package.extras] 467 | lint = ["flake8", "mypy", "docutils-stubs"] 468 | test = ["pytest"] 469 | 470 | [[package]] 471 | name = "sphinxcontrib-htmlhelp" 472 | version = "2.0.0" 473 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 474 | category = "dev" 475 | optional = false 476 | python-versions = ">=3.6" 477 | 478 | [package.extras] 479 | lint = ["flake8", "mypy", "docutils-stubs"] 480 | test = ["pytest", "html5lib"] 481 | 482 | [[package]] 483 | name = "sphinxcontrib-jsmath" 484 | version = "1.0.1" 485 | description = "A sphinx extension which renders display math in HTML via JavaScript" 486 | category = "dev" 487 | optional = false 488 | python-versions = ">=3.5" 489 | 490 | [package.extras] 491 | test = ["pytest", "flake8", "mypy"] 492 | 493 | [[package]] 494 | name = "sphinxcontrib-qthelp" 495 | version = "1.0.3" 496 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." 497 | category = "dev" 498 | optional = false 499 | python-versions = ">=3.5" 500 | 501 | [package.extras] 502 | lint = ["flake8", "mypy", "docutils-stubs"] 503 | test = ["pytest"] 504 | 505 | [[package]] 506 | name = "sphinxcontrib-serializinghtml" 507 | version = "1.1.5" 508 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." 509 | category = "dev" 510 | optional = false 511 | python-versions = ">=3.5" 512 | 513 | [package.extras] 514 | lint = ["flake8", "mypy", "docutils-stubs"] 515 | test = ["pytest"] 516 | 517 | [[package]] 518 | name = "toml" 519 | version = "0.10.2" 520 | description = "Python Library for Tom's Obvious, Minimal Language" 521 | category = "dev" 522 | optional = false 523 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 524 | 525 | [[package]] 526 | name = "tornado" 527 | version = "6.1" 528 | description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." 529 | category = "dev" 530 | optional = false 531 | python-versions = ">= 3.5" 532 | 533 | [[package]] 534 | name = "typing-extensions" 535 | version = "4.1.1" 536 | description = "Backported and Experimental Type Hints for Python 3.6+" 537 | category = "dev" 538 | optional = false 539 | python-versions = ">=3.6" 540 | 541 | [[package]] 542 | name = "urllib3" 543 | version = "1.26.18" 544 | description = "HTTP library with thread-safe connection pooling, file post, and more." 545 | category = "main" 546 | optional = false 547 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 548 | 549 | [package.extras] 550 | brotli = ["brotlicffi (>=0.8.0)", "brotli (==1.0.9)", "brotlipy (>=0.6.0)", "brotli (>=1.0.9)"] 551 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] 552 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 553 | 554 | [[package]] 555 | name = "zipp" 556 | version = "3.6.0" 557 | description = "Backport of pathlib-compatible object wrapper for zip files" 558 | category = "dev" 559 | optional = false 560 | python-versions = ">=3.6" 561 | 562 | [package.extras] 563 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 564 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 565 | 566 | [metadata] 567 | lock-version = "1.1" 568 | python-versions = "^3.6" 569 | content-hash = "a4078415238336f1055879b1873e8f4b31caa971a00033d04833ed399537f266" 570 | 571 | [metadata.files] 572 | alabaster = [] 573 | atomicwrites = [] 574 | attrs = [] 575 | babel = [] 576 | beautifulsoup4 = [] 577 | certifi = [] 578 | charset-normalizer = [] 579 | colorama = [] 580 | docutils = [] 581 | furo = [] 582 | idna = [] 583 | imagesize = [] 584 | importlib-metadata = [] 585 | iniconfig = [] 586 | jinja2 = [] 587 | livereload = [] 588 | markdown-it-py = [] 589 | markupsafe = [] 590 | mdit-py-plugins = [] 591 | myst-parser = [] 592 | packaging = [] 593 | pluggy = [] 594 | py = [] 595 | pygments = [] 596 | pyparsing = [] 597 | pytest = [] 598 | pytz = [] 599 | pyyaml = [] 600 | requests = [] 601 | simplejson = [] 602 | six = [] 603 | snowballstemmer = [] 604 | soupsieve = [] 605 | sphinx = [] 606 | sphinx-autobuild = [] 607 | sphinx-copybutton = [] 608 | sphinxcontrib-applehelp = [] 609 | sphinxcontrib-devhelp = [] 610 | sphinxcontrib-htmlhelp = [] 611 | sphinxcontrib-jsmath = [] 612 | sphinxcontrib-qthelp = [] 613 | sphinxcontrib-serializinghtml = [] 614 | toml = [] 615 | tornado = [] 616 | typing-extensions = [] 617 | urllib3 = [] 618 | zipp = [] 619 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyZohoAPI" 3 | version = "1.0.0" 4 | description = "Pythonic access to Zoho APIs in the Finance Plus suite." 5 | authors = ["Todd Esposito "] 6 | license = "MIT" 7 | readme = "README.md" 8 | include = ["LICENSE"] 9 | homepage = "https://github.com/tdesposito/pyZohoAPI" 10 | repository = "https://github.com/tdesposito/pyZohoAPI" 11 | documentation = "https://pyzohoapi.readthedocs.io/en/latest/" 12 | keywords = [ 13 | "api", 14 | "zoho api", 15 | "zoho books", 16 | "zoho inventory", 17 | "zoho", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Intended Audience :: Developers", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Programming Language :: Python :: 3.6", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.6" 31 | requests = "^2.25.1" 32 | simplejson = "^3.17.5" 33 | 34 | [tool.poetry.dev-dependencies] 35 | furo = "^2020.12.30-beta.24" 36 | sphinx = "^3.4.3" 37 | myst-parser = "^0.13.3" 38 | sphinx-autobuild = "^2020.9.1" 39 | sphinx-copybutton = "^0.3.1" 40 | pytest = "^6.2.4" 41 | 42 | [build-system] 43 | requires = ["poetry>=0.12"] 44 | build-backend = "poetry.masonry.api" 45 | -------------------------------------------------------------------------------- /pyzohoapi/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | __version__ = '1.0.0' 5 | __all__ = [ 6 | "ZohoBooks", 7 | "ZohoInventory", 8 | ] 9 | 10 | from .books import ZohoBooks 11 | from .inventory import ZohoInventory 12 | -------------------------------------------------------------------------------- /pyzohoapi/books.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from .core import ZohoAPIBase 5 | from . import objecttypes 6 | 7 | class ZohoBooks(ZohoAPIBase): 8 | _scope = "ZohoBooks.FullAccess.all" 9 | 10 | def get_endpoint(self, region): 11 | return f"https://www.zohoapis.{self._regionmap[region]}/books/v3" 12 | 13 | def Account(self, *args, **kwargs): return objecttypes.Account(self, *args, **kwargs) 14 | def CompositeItem(self, *args, **kwargs): return objecttypes.CompositeItem(self, *args, **kwargs) 15 | def Contact(self, *args, **kwargs): return objecttypes.Contact(self, *args, **kwargs) 16 | def CustomerPayment(self, *args, **kwargs): return objecttypes.CustomerPayment(self, *args, **kwargs) 17 | def Document(self, *args, **kwargs): return objecttypes.Document(self, *args, **kwargs) 18 | def Invoice(self, *args, **kwargs): return objecttypes.Invoice(self, *args, **kwargs) 19 | def Item(self, *args, **kwargs): return objecttypes.Item(self, *args, **kwargs) 20 | def ItemGroup(self, *args, **kwargs): return objecttypes.ItemGroup(self, *args, **kwargs) 21 | def Organization(self, *args, **kwargs): return objecttypes.Organization(self, *args, **kwargs) 22 | def PurchaseOrder(self, *args, **kwargs): return objecttypes.PurchaseOrder(self, *args, **kwargs) 23 | def SalesOrder(self, *args, **kwargs): return objecttypes.SalesOrder(self, *args, **kwargs) 24 | def SalesPerson(self, *args, **kwargs): return objecttypes.SalesPerson(self, *args, **kwargs) 25 | def User(self, *args, **kwargs): return objecttypes.User(self, *args, **kwargs) 26 | -------------------------------------------------------------------------------- /pyzohoapi/core/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | import datetime 5 | import logging 6 | from time import sleep 7 | 8 | import requests 9 | import simplejson 10 | 11 | from .collection import DottedDict, DottedList 12 | from .utils import diff 13 | from ..exceptions import ZohoAPICallsExceeded, ZohoAPIThrottled, ZohoAuthRefreshFailure, ZohoBadRequest, ZohoException, ZohoInsufficientAuthKeys, ZohoInvalidOpError, ZohoMethodNotAllowed, ZohoNotFound, ZohoUnauthorized, ZohoUnknownRegionException 14 | 15 | logging.getLogger('pyzohoapi').addHandler(logging.NullHandler()) 16 | 17 | class ZohoAPIBase: 18 | _regionmap = { 19 | # US 20 | 'us': "com", 21 | 'com': "com", 22 | # India 23 | 'in': "in", 24 | 'india': "in", 25 | # Europe 26 | 'eu': "eu", 27 | 'europe': "eu", 28 | # Japan 29 | "jp": "jp", 30 | "japan": "jp", 31 | # Canada 32 | "cn": "com.cn", 33 | "canada": "com.cn", 34 | # Australia 35 | 'australia': "com.au", 36 | 'com.au': "com.au", 37 | } 38 | def __init__(self, organization_id, region="us", **apiArgs): 39 | """ Constructor 40 | 41 | :param organization_id: Zoho Organization ID to which to connect 42 | :type organization_id: str 43 | :param region: Zoho Data Center Region. Defaults to "us". 44 | :type region: str 45 | :param **apiArgs: additional parameters for API operation. 46 | :raises ZohoUnknownRegionException: if the region is unknown or invalid. 47 | 48 | """ 49 | region = region.lower() 50 | if region not in self._regionmap: 51 | raise ZohoUnknownRegionException(region) 52 | self._org = organization_id 53 | self._endpoint = self.get_endpoint(region) 54 | self._oauth = f"https://accounts.zoho.{self._regionmap[region]}/oauth/v2" 55 | self._ratelimit = { 56 | 'limit': None, 57 | 'NextCall': datetime.datetime.now().timestamp(), 58 | 'remaining': 99999999, 59 | 'reset': None, 60 | } 61 | self._api_keys = { 62 | 'access_token': None, 63 | 'client_id': None, 64 | 'client_secret': None, 65 | 'intercall_delay': 0, 66 | 'max_retries': 10, 67 | 'max_retry_after': 180, 68 | 'min_calls_remaining': 1, 69 | 'redirect_url': None, 70 | 'refresh_token': None, 71 | 'retry_backoff_seconds': 0.5, 72 | } 73 | self.update_tokens(apiArgs) 74 | self._logger = logging.getLogger('pyzohoapi') 75 | 76 | def auth_header(self): 77 | """ Returns the authorization header, refreshing the access_token as needed. 78 | 79 | :return: {'Authorization': '... access token ...'} 80 | :raises ZohoAuthRefreshFailure: if a refresh attempt fails. 81 | :raises ZohoInsufficientAuthKeys: if we don't have enough info to refresh. 82 | """ 83 | if self._api_keys.get('access_token') and self._api_keys['AccessExpiresAt'] > datetime.datetime.now().timestamp(): 84 | return {'Authorization': f"Zoho-oauthtoken {self._api_keys['access_token']}"} 85 | if self._api_keys.get('refresh_token'): 86 | self.log("requesting new access token") 87 | rsp = requests.post(f"{self._oauth}/token", params={ 88 | 'refresh_token': self._api_keys['refresh_token'], 89 | 'client_id': self._api_keys['client_id'], 90 | 'client_secret': self._api_keys['client_secret'], 91 | 'redirect_url': self._api_keys['redirect_url'], 92 | 'grant_type': "refresh_token" 93 | }) 94 | if rsp.ok: 95 | self.update_tokens(rsp.json()) 96 | return {'Authorization': f"Zoho-oauthtoken {self._api_keys['access_token']}"} 97 | raise ZohoAuthRefreshFailure() 98 | raise ZohoInsufficientAuthKeys() 99 | 100 | def do_request(self, requestFunc, url, body=None, files=None): 101 | if self._api_keys['min_calls_remaining'] >= int(self._ratelimit['remaining']): 102 | if self._ratelimit['ResetAt'] > datetime.datetime.now(): 103 | self.log("API call limit exceeded") 104 | raise ZohoAPICallsExceeded() 105 | 106 | now = datetime.datetime.now().timestamp() 107 | if now < self._ratelimit['NextCall']: 108 | self.log("pausing for internal API rate limit") 109 | sleep(self._ratelimit['NextCall'] - now) 110 | 111 | retries = self._api_keys['max_retries'] 112 | while True: 113 | reqparams = { 114 | 'headers': self.auth_header(), 115 | 'files': files, 116 | 'data': simplejson.dumps(body) if body else None, 117 | } 118 | rsp = requestFunc(url, **reqparams) 119 | self._ratelimit['NextCall'] = datetime.datetime.now().timestamp() + self._api_keys['intercall_delay'] 120 | if rsp.status_code == 429 and retries: # Too Many Requests 121 | retries -= 1 122 | sleeptime = int(rsp.headers.get('retry-after', self._api_keys['retry_backoff_seconds'])) 123 | if sleeptime <= self._api_keys['max_retry_after']: 124 | self.log("pausing before retry") 125 | sleep(sleeptime) 126 | else: 127 | raise ZohoAPIThrottled() 128 | elif not rsp.ok: 129 | err_params = { 130 | 'code': rsp.status_code, 131 | 'url': url, 132 | 'msg': f"Encountered #{rsp.status_code} error calling Zoho API", 133 | } 134 | if rsp.headers.get('content-type',"").startswith("application/json"): 135 | d = rsp.json() 136 | err_params.update({'zoho_code': d.get('code'), 'zoho_msg': d.get('message',"")}) 137 | if rsp.status_code == 401: 138 | self._api_keys['access_token'] = None 139 | retries -= 1 140 | if retries: 141 | continue 142 | raise { 143 | '400': ZohoBadRequest, 144 | '401': ZohoUnauthorized, 145 | '404': ZohoNotFound, 146 | '405': ZohoMethodNotAllowed, 147 | }.get(str(rsp.status_code), ZohoException)(**err_params) 148 | else: 149 | if rsp.headers['content-type'].startswith("application/json"): 150 | d = rsp.json() 151 | if d.get('code') == 0: 152 | self.update_rate_limit(rsp.headers) 153 | return rsp 154 | elif d.get('code') == "43" and retries: # Throttled 155 | retries -= 1 156 | sleeptime = int(rsp.headers.get('retry-after', self._api_keys['retry_backoff_seconds'])) 157 | if sleeptime <= self._api_keys['max_retry_after']: 158 | self.log("pausing before retry") 159 | sleep(sleeptime) 160 | else: 161 | raise ZohoAPIThrottled() 162 | else: 163 | return rsp 164 | 165 | def delete(self, urlFragment): 166 | url = f"{self._endpoint}/{urlFragment}?organization_id={self._org}" 167 | self.log(f"DELETE {url}") 168 | rsp = self.do_request(requests.delete, url) 169 | return rsp.ok 170 | 171 | def get(self, urlFragment, queryString): 172 | url = f"{self._endpoint}/{urlFragment}?organization_id={self._org}&{queryString}" 173 | self.log(f"GET {url}") 174 | rsp = self.do_request(requests.get, url) 175 | if rsp.headers['content-type'].startswith("application/json"): 176 | data = simplejson.loads(rsp.text, use_decimal=True) 177 | if data['code'] == 0: 178 | return data 179 | raise ZohoException(f"zoho returned {data['code']}: {data['message']}") 180 | return DottedDict({ 181 | 'content': rsp.content, 182 | 'content_type': rsp.headers['content-type'], 183 | }) 184 | 185 | def get_endpoint(self, region): 186 | # This MUST be overridden in subclasses for the APIs to work. 187 | # It's only here in the base class for testing 188 | return False 189 | 190 | def log(self, message, level=logging.DEBUG): 191 | self._logger.log(level, f"{self.__class__.__name__} (Org# {self._org}): {message}") 192 | 193 | def post(self, urlFragment, data=None, queryString="", files=None): 194 | url = f"{self._endpoint}/{urlFragment}?organization_id={self._org}&{queryString}" 195 | self.log(f"POST {url}") 196 | rsp = self.do_request(requests.post, url, data, files) 197 | if rsp.headers['content-type'].startswith("application/json"): 198 | data = simplejson.loads(rsp.text, use_decimal=True) 199 | if data['code'] == 0: 200 | return data 201 | raise ZohoException(f"zoho returned {data['code']}: {data['message']}") 202 | return { 203 | 'content': rsp.content, 204 | 'content_type': rsp.headers['content-type'], 205 | } 206 | 207 | def put(self, urlFragment, data, queryString): 208 | url = f"{self._endpoint}/{urlFragment}?organization_id={self._org}&{queryString}" 209 | self.log(f"PUT {url}") 210 | rsp = self.do_request(requests.put, url, data) 211 | if rsp.headers['content-type'].startswith("application/json"): 212 | data = simplejson.loads(rsp.text, use_decimal=True) 213 | if data['code'] == 0: 214 | return data 215 | raise ZohoException(f"zoho returned {data['code']}: {data['message']}") 216 | return { 217 | 'content': rsp.content, 218 | 'content_type': rsp.headers['content-type'], 219 | } 220 | 221 | def update_rate_limit(self, headers): 222 | for key in ['limit', 'reset', 'remaining']: 223 | if headers.get(f'x-rate-limit-{key}'): 224 | self._ratelimit[key] = headers[f'x-rate-limit-{key}'] 225 | self._ratelimit['ResetAt'] = datetime.datetime.now() + datetime.timedelta(seconds=int(self._ratelimit.get('reset', 0))) 226 | 227 | def update_tokens(self, apiArgs): 228 | self._api_keys.update(apiArgs) 229 | # we subtract a few ticks from our expiry time, to cushion against drift 230 | self._api_keys['AccessExpiresAt'] = datetime.datetime.now().timestamp() + int(apiArgs.get('expires_in', 0)) - 10 231 | 232 | 233 | class ZohoObjectBase: 234 | ID = property(lambda self: self._id) 235 | IsDeleted = property(lambda self: self._id is False) 236 | IsList = property(lambda self: isinstance(self._data, DottedList)) 237 | IsLoaded = property(lambda self: self._data is not None) 238 | Number = property(lambda self: self._data[self._number_field] if isinstance(self._data, DottedDict) else None) 239 | 240 | def __init__(self, api, id=None, **searchParams): 241 | self._id = id 242 | self._api = api 243 | self._data = None 244 | self._nextpage = None 245 | if id or {k:v for k,v in searchParams.items() if isinstance(v, str)}: 246 | try: 247 | self._load(id=id, **searchParams) 248 | except ZohoNotFound: 249 | pass # not found, but no need to raise this error. 250 | 251 | def __iter__(self): 252 | return self.Iter() 253 | 254 | def __getattr__(self, key): 255 | if self._data: 256 | return self._data[key] 257 | return None 258 | 259 | def __repr__(self): 260 | if self._id: 261 | return f"{self.__class__.__name__} #{self._id}" 262 | if isinstance(self._data, DottedList): 263 | return f"List of {self.__class__.__name__} objects" 264 | return f"New {self.__class__.__name__}" 265 | 266 | def __setattr__(self, key, value): 267 | if key.startswith('_'): 268 | super().__setattr__(key, value) 269 | else: 270 | if not self._data: 271 | self._data = DottedDict() 272 | self._data[key] = value 273 | 274 | def _load(self, page=None, **searchParams): 275 | data = self._api.get(self._url_fragment(), self._query_string(**searchParams)) 276 | if data is None: 277 | self._data = None 278 | elif self._id and self._is_raw: 279 | self._data = DottedDict(data) 280 | else: 281 | if self._id: 282 | self._reload(data) 283 | else: 284 | if page: 285 | self._data.extend(DottedList(data.get(self._plural))) 286 | else: 287 | self._data = DottedList(data.get(self._plural)) 288 | if data.get('page_context',{}).get('has_more_page'): 289 | self._searchParams = searchParams 290 | self._nextpage = data['page_context']['page'] + 1 291 | else: 292 | self._nextpage = None 293 | 294 | @staticmethod 295 | def _passes_filter(obj, filterFunc=None, **filters): 296 | """ Utility function for filtering object lists 297 | """ 298 | if filterFunc and not filterFunc(obj): 299 | return False 300 | for k,v in filters.items(): 301 | if k in obj and obj[k] != v: 302 | return False 303 | return True 304 | 305 | def _query_string(self, **queryArgs): 306 | qs = "&".join([f"{k}={v}" for (k,v) in queryArgs.items() if v]) 307 | if self._nextpage: 308 | qs += f"&page={self._nextpage}" 309 | return qs 310 | 311 | def _reload(self, data): 312 | self._orig = data.get(self._singular) 313 | self._data = DottedDict(self._orig) 314 | self._id = self._data.get(self._id_field) 315 | 316 | def _url_fragment(self, id=None, extraPath=[]): 317 | if self._id or id: 318 | return f"{self._type}/{id if id else self._id}/{'/'.join(extraPath)}" 319 | else: 320 | return self._type 321 | 322 | def get(self, key, default=None): 323 | if self._id: 324 | return self._data.get(key, default) 325 | return default 326 | 327 | def Create(self, **qParams): 328 | """ Create this object in Zoho 329 | 330 | :return: `self` as created by Zoho 331 | :raises ZohoInvalidOpError: if `self` isn't "new" 332 | """ 333 | if not self._id: 334 | newData = self._api.post(self._url_fragment(), self._data.to_python(), self._query_string(**qParams)) 335 | if newData: 336 | self._reload(newData) 337 | return self 338 | raise ZohoInvalidOpError("Create", self) 339 | 340 | def Delete(self): 341 | """ Delete this object from Zoho 342 | 343 | :return: `self` 344 | :raises ZohoInvalidOpError: if `self` isn't "single-object" 345 | """ 346 | if self._id: 347 | if self._api.delete(self._url_fragment()): 348 | self._id = False 349 | self._data[self._id_field] = None 350 | return self 351 | raise ZohoInvalidOpError("Delete", self) 352 | 353 | def First(self, **kwargs): 354 | """ Get the first ZohoObject from the list. 355 | 356 | If kwargs are provided, they are used to filter what counts as "first." 357 | 358 | :return: a ZohoObject 359 | """ 360 | if self._data is None: 361 | # We were a "new" object, but need to become a list-of 362 | self._load() 363 | if isinstance(self._data, DottedList) and len(self._data): 364 | if kwargs: 365 | rval = None 366 | for _ in self.Iter(raw=True, **kwargs): 367 | rval = _ 368 | break 369 | if rval: 370 | return self.__class__(self._api, rval[self._id_field]) 371 | else: 372 | return self.__class__(self._api, self._data[0][self._id_field]) 373 | return self.__class__(self._api) 374 | 375 | def GetRelated(self, targetType, key): 376 | if self._id and self._data: 377 | if isinstance(self._data.get(key), str): 378 | return targetType(self._data[key]) 379 | raise ZohoInvalidOpError("GetRelated", self) 380 | 381 | def Iter(self, filterFunc=None, raw=False, **filter): 382 | """ Iterate over the list of ZohoObjects 383 | 384 | If called on a non-connected ("new") object, we get the list of ALL objects 385 | 386 | :param filterFunc: function which takes the item to test, returning True or False 387 | :param raw: return the raw list data? 388 | :param filter: fields/values to filter the list by 389 | :return: iterable of ZohoObjects (technically, a generator) 390 | """ 391 | if self._id: 392 | return [self] 393 | if not self.IsLoaded: 394 | self._load() 395 | 396 | if self.IsLoaded: 397 | start = 0 398 | while start < len(self._data) or self._nextpage: 399 | if start >= len(self._data): 400 | self._load(page=self._nextpage, **self._searchParams) 401 | 402 | item = self._data[start] 403 | if self._passes_filter(item, filterFunc=filterFunc, **filter): 404 | if raw: 405 | yield item 406 | else: 407 | yield self.__class__(self._api, id=item[self._id_field]) 408 | start += 1 409 | else: 410 | return [] 411 | 412 | def IterRelatedList(self, targetType, listKey, idField): 413 | if self._id and self._data: 414 | for item in self._data.get(listKey, []): 415 | yield targetType(item.get(idField)) 416 | else: 417 | raise ZohoInvalidOpError("IterRelatedList", self) 418 | 419 | def MapRelatedList(self, targetType, listKey, idField): 420 | if self._id and self._data: 421 | for item in self._data.get(listKey, []): 422 | yield DottedDict({'meta': item.to_python(), 'object': targetType(item.get(idField))}) 423 | else: 424 | raise ZohoInvalidOpError("MapRelatedList", self) 425 | 426 | def Update(self): 427 | if self._id and self._data: 428 | updated = diff(self._orig, self._data.to_python()) 429 | if updated: 430 | data = self._api.put(self._url_fragment(), updated, "") 431 | self._reload(data) 432 | return self 433 | raise ZohoInvalidOpError("Update", self) 434 | -------------------------------------------------------------------------------- /pyzohoapi/core/collection.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2014 Carlos Escribano Rey 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | #==============================================================================# 26 | # Modified slighly to include in pyZohoAPI 27 | # We've removed py2 support, and added upstream-but-unmerged pullreq #14 28 | 29 | import collections 30 | import json 31 | import re 32 | 33 | from abc import ABCMeta, abstractmethod 34 | 35 | class KeyOrAttributeError(KeyError, AttributeError): pass 36 | 37 | 38 | SPLIT_REGEX = r"(? 0 44 | 45 | 46 | def split_key(key, max_keys=0): 47 | parts = [x for x in re.split(SPLIT_REGEX, key) if x != "."] 48 | result = [] 49 | while len(parts) > 0: 50 | if max_keys > 0 and len(result) == max_keys: 51 | break 52 | result.append(parts.pop(0)) 53 | 54 | if len(parts) > 0: 55 | result.append(".".join(parts)) 56 | return result 57 | 58 | 59 | class DottedCollection(object, metaclass=ABCMeta): 60 | """Abstract Base Class for DottedDict and DottedDict""" 61 | 62 | @classmethod 63 | def factory(cls, initial=None): 64 | """Returns a DottedDict or a DottedList based on the type of the 65 | initial value, that must be a dict or a list. In other case the same 66 | original value will be returned. 67 | """ 68 | if isinstance(initial, list): 69 | return DottedList(initial) 70 | elif isinstance(initial, dict): 71 | return DottedDict(initial) 72 | else: 73 | return initial 74 | 75 | @classmethod 76 | def load_json(cls, json_value): 77 | """Returns a DottedCollection from a JSON string""" 78 | return cls.factory(json.loads(json_value)) 79 | 80 | @classmethod 81 | def _factory_by_index(cls, dotted_key): 82 | """Returns the proper DottedCollection that best suits the next key in 83 | the dotted_key string. First guesses the next key and then analyzes it. 84 | If the next key is numeric then returns a DottedList. In other case a 85 | DottedDict is returned. 86 | """ 87 | if not isinstance(dotted_key, str): 88 | next_key = str(dotted_key) 89 | elif not is_dotted_key(dotted_key): 90 | next_key = dotted_key 91 | else: 92 | next_key, tmp = split_key(dotted_key, 1) 93 | 94 | return DottedCollection.factory([] if next_key.isdigit() else {}) 95 | 96 | def __init__(self, initial): 97 | """Base constructor. If there are nested dicts or lists they are 98 | transformed into DottedCollection instances. 99 | """ 100 | if not isinstance(initial, list) and not isinstance(initial, dict): 101 | raise ValueError('initial value must be a list or a dict') 102 | 103 | self._validate_initial(initial) 104 | 105 | self.store = initial 106 | 107 | if isinstance(self.store, list): 108 | data = enumerate(self.store) 109 | else: 110 | data = self.store.items() 111 | 112 | for key, value in data: 113 | try: 114 | self.store[key] = DottedCollection.factory(value) 115 | except ValueError: 116 | pass 117 | 118 | def _validate_initial(self, initial): 119 | """Validates data so no unescaped dotted key is present.""" 120 | if isinstance(initial, list): 121 | for item in initial: 122 | self._validate_initial(item) 123 | elif isinstance(initial, dict): 124 | for key, item in initial.items(): 125 | if is_dotted_key(key): 126 | raise ValueError("{0} is not a valid key inside a " 127 | "DottedCollection!".format(key)) 128 | self._validate_initial(item) 129 | 130 | def __len__(self): 131 | return len(self.store) 132 | 133 | def __iter__(self): 134 | return iter(self.store) 135 | 136 | def __repr__(self): 137 | return repr(self.store) 138 | 139 | def to_json(self): 140 | return json.dumps(self, cls=DottedJSONEncoder) 141 | 142 | @abstractmethod 143 | def __getitem__(self, name): 144 | pass 145 | 146 | @abstractmethod 147 | def __setitem__(self, name, value): 148 | pass 149 | 150 | @abstractmethod 151 | def __delitem__(self, name): 152 | pass 153 | 154 | @abstractmethod 155 | def to_python(self): 156 | pass 157 | 158 | 159 | class DottedList(DottedCollection, collections.abc.MutableSequence): 160 | """A list with support for the dotted path syntax""" 161 | 162 | def __init__(self, initial=None): 163 | DottedCollection.__init__( 164 | self, 165 | [] if initial is None else list(initial) 166 | ) 167 | 168 | def __getitem__(self, index): 169 | if isinstance(index, slice): 170 | return self.store[index] 171 | 172 | if isinstance(index, int) \ 173 | or (isinstance(index, str) and index.isdigit()): 174 | return self.store[int(index)] 175 | 176 | elif isinstance(index, str) and is_dotted_key(index): 177 | my_index, alt_index = split_key(index, 1) 178 | target = self.store[int(my_index)] 179 | 180 | # required by the dotted path 181 | if not isinstance(target, DottedCollection): 182 | raise IndexError('cannot get "{0}" in "{1}" ({2})'.format( 183 | alt_index, 184 | my_index, 185 | repr(target) 186 | )) 187 | 188 | return target[alt_index] 189 | 190 | else: 191 | raise IndexError('cannot get %s in %s' % (index, repr(self.store))) 192 | 193 | def __setitem__(self, index, value): 194 | if isinstance(index, int) \ 195 | or (isinstance(index, str) and index.isdigit()): 196 | # If the index does not exist in the list but it's the same index 197 | # we would obtain by appending the value to the list we actually 198 | # append the value. (***) 199 | if int(index) not in self.store and int(index) == len(self.store): 200 | self.store.append(DottedCollection.factory(value)) 201 | else: 202 | self.store[int(index)] = DottedCollection.factory(value) 203 | 204 | elif isinstance(index, str) and is_dotted_key(index): 205 | my_index, alt_index = split_key(index, 1) 206 | 207 | # (***) 208 | if int(my_index) not in self.store \ 209 | and int(my_index) == len(self.store): 210 | self.store.append( 211 | DottedCollection._factory_by_index(alt_index)) 212 | 213 | if not isinstance(self[int(my_index)], DottedCollection): 214 | raise IndexError('cannot set "%s" in "%s" (%s)' % ( 215 | alt_index, my_index, repr(self[int(my_index)]))) 216 | 217 | self[int(my_index)][alt_index] = DottedCollection.factory(value) 218 | 219 | else: 220 | raise IndexError('cannot use %s as index in %s' % ( 221 | index, repr(self.store))) 222 | 223 | def __delitem__(self, index): 224 | if isinstance(index, int) \ 225 | or (isinstance(index, str) and index.isdigit()): 226 | del self.store[int(index)] 227 | 228 | elif isinstance(index, str) and is_dotted_key(index): 229 | my_index, alt_index = split_key(index, 1) 230 | target = self.store[int(my_index)] 231 | 232 | # required by the dotted path 233 | if not isinstance(target, DottedCollection): 234 | raise IndexError('cannot delete "%s" in "%s" (%s)' % ( 235 | alt_index, my_index, repr(target))) 236 | 237 | del target[alt_index] 238 | 239 | else: 240 | raise IndexError('cannot delete %s in %s' % ( 241 | index, repr(self.store))) 242 | 243 | def to_python(self): 244 | """Returns a plain python list and converts to plain python objects all 245 | this object's descendants. 246 | """ 247 | result = list(self) 248 | 249 | for index, value in enumerate(result): 250 | if isinstance(value, DottedCollection): 251 | result[index] = value.to_python() 252 | 253 | return result 254 | 255 | def insert(self, index, value): 256 | self.store.insert(index, value) 257 | 258 | 259 | class DottedDict(DottedCollection, collections.abc.MutableMapping): 260 | """A dict with support for the dotted path syntax""" 261 | def __init__(self, initial=None): 262 | DottedCollection.__init__( 263 | self, 264 | {} if initial is None else dict(initial) 265 | ) 266 | 267 | def __getitem__(self, k): 268 | key = self.__keytransform__(k) 269 | 270 | if not isinstance(k, str) or not is_dotted_key(key): 271 | try: 272 | return self.store[key] 273 | except KeyError as e: 274 | raise KeyOrAttributeError(e) 275 | 276 | my_key, alt_key = split_key(key, 1) 277 | target = self.store[my_key] 278 | 279 | # required by the dotted path 280 | if not isinstance(target, DottedCollection): 281 | raise KeyError('cannot get "{0}" in "{1}" ({2})'.format( 282 | alt_key, 283 | my_key, 284 | repr(target) 285 | )) 286 | 287 | return target[alt_key] 288 | 289 | def __setitem__(self, k, value): 290 | key = self.__keytransform__(k) 291 | 292 | if not isinstance(k, str): 293 | raise KeyError('DottedDict keys must be str or unicode') 294 | elif not is_dotted_key(key): 295 | self.store[key] = DottedCollection.factory(value) 296 | else: 297 | my_key, alt_key = split_key(key, 1) 298 | 299 | if my_key not in self.store: 300 | self.store[my_key] = DottedCollection._factory_by_index(alt_key) 301 | 302 | self.store[my_key][alt_key] = value 303 | 304 | def __delitem__(self, k): 305 | key = self.__keytransform__(k) 306 | 307 | if not isinstance(k, str) or not is_dotted_key(key): 308 | del self.store[key] 309 | 310 | else: 311 | my_key, alt_key = split_key(key, 1) 312 | target = self.store[my_key] 313 | 314 | if not isinstance(target, DottedCollection): 315 | raise KeyError('cannot delete "{0}" in "{1}" ({2})'.format( 316 | alt_key, 317 | my_key, 318 | repr(target) 319 | )) 320 | 321 | del target[alt_key] 322 | 323 | def to_python(self): 324 | """Returns a plain python dict and converts to plain python objects all 325 | this object's descendants. 326 | """ 327 | result = dict(self) 328 | 329 | for key, value in result.items(): 330 | if isinstance(value, DottedCollection): 331 | result[key] = value.to_python() 332 | 333 | return result 334 | 335 | __getattr__ = __getitem__ 336 | 337 | # self.store does not exist before __init__() initializes it 338 | 339 | def __setattr__(self, key, value): 340 | if key in self.__dict__ or key == 'store': 341 | object.__setattr__(self, key, value) 342 | else: 343 | self.__setitem__(key, value) 344 | 345 | def __delattr__(self, key): 346 | if key in self.__dict__ or key == 'store': 347 | object.__delattr__(self, key) 348 | else: 349 | self.__delitem__(key) 350 | 351 | def __contains__(self, k): 352 | key = self.__keytransform__(k) 353 | 354 | if not isinstance(k, str) or not is_dotted_key(key): 355 | return self.store.__contains__(key) 356 | 357 | my_key, alt_key = split_key(key, 1) 358 | target = self.store[my_key] 359 | 360 | if not isinstance(target, DottedCollection): 361 | return False 362 | 363 | return alt_key in target 364 | 365 | def __keytransform__(self, key): 366 | return key 367 | 368 | 369 | # 370 | # JSON stuff 371 | # 372 | 373 | 374 | class DottedJSONEncoder(json.JSONEncoder): 375 | def default(self, obj): 376 | if isinstance(obj, DottedCollection): 377 | return obj.store 378 | else: 379 | return json.JSONEncoder.default(obj) 380 | -------------------------------------------------------------------------------- /pyzohoapi/core/utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | def diff(orig, new): 5 | """ Calculates the difference between two dictionaries. 6 | 7 | Any key with a child list or dict which is, itself, changed will be 8 | considered changed. 9 | 10 | :param orig: the original (unmodified) dictionary 11 | :param new: the modified dictionary 12 | :return: a dictionary contianing only those keys which were changed. 13 | """ 14 | updated = {} 15 | for k, v in new.items(): 16 | if k not in orig: 17 | updated[k] = v 18 | elif isinstance(v, list): 19 | if len(v) != len(orig[k]): 20 | updated[k] = v 21 | else: 22 | has_change = False 23 | for i in range(len(v)): 24 | if isinstance(v[i], dict) and diff(orig[k][i], v[i]): 25 | has_change = True 26 | break 27 | elif v[i] != orig[k][i]: 28 | has_change = True 29 | break 30 | if has_change: 31 | # the update needs to contain the ENTIRE new list, so 32 | # Zoho doesn't zap non-updated elements 33 | updated[k] = v 34 | elif isinstance(v, dict): 35 | if diff(v, orig[k]): 36 | # the update needs to contain the ENTIRE new dict, so 37 | # Zoho doesn't zap non-updated values 38 | updated[k] = v 39 | elif v != orig[k]: 40 | updated[k] = v 41 | return updated 42 | -------------------------------------------------------------------------------- /pyzohoapi/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | class ZohoException(Exception): 5 | def __init__(self, msg="Unspecified Error", **kwargs): 6 | super().__init__(msg) 7 | 8 | #--------------------------------------------------------------------------- 9 | # Exceptions raised by API objects 10 | 11 | class ZohoAuthRefreshFailure(ZohoException): 12 | def __init__(self): 13 | super().__init__("Unable to refresh access token") 14 | 15 | 16 | class ZohoInsufficientAuthKeys(ZohoException): 17 | def __init__(self): 18 | super().__init__("Unable to generate Authorization header") 19 | 20 | 21 | class ZohoUnknownRegionException(ZohoException): 22 | def __init__(self, region): 23 | super().__init__(f'Unknown Region "{region}"') 24 | 25 | 26 | #--------------------------------------------------------------------------- 27 | # Exceptions raised by Zoho objects 28 | 29 | class ZohoAPICallsExceeded(ZohoException): 30 | def __init__(self): 31 | super().__init__("No API Calls remaining") 32 | 33 | 34 | class ZohoAPIThrottled(ZohoException): 35 | def __init__(self): 36 | super().__init__("API Throttled") 37 | 38 | 39 | 40 | class ZohoBadRequest(ZohoException): 41 | def __init__(self, url, zoho_code="-no code-", zoho_msg="-no message-", **kwargs): 42 | super().__init__(f"Bad Request (HTTP-400)\n{zoho_msg} (Zoho-{zoho_code})") 43 | 44 | 45 | class ZohoUnauthorized(ZohoException): 46 | def __init__(self, **kwargs): 47 | super().__init__(f"Unauthorized - Invalid Access Token (HTTP=401)") 48 | 49 | 50 | class ZohoNotFound(ZohoException): 51 | def __init__(self, url, **kwargs): 52 | super().__init__(f"Not Found: '{url}' (HTTP-404)") 53 | 54 | 55 | class ZohoMethodNotAllowed(ZohoException): 56 | def __init__(self, url, **kwargs): 57 | super().__init__(f"Method not allowed on '{url}' (HTTP-405)") 58 | 59 | 60 | class ZohoInvalidOpError(ZohoException): 61 | def __init__(self, op, target): 62 | if target.ID is False: 63 | super().__init__(f"Can't call {op} on a deleted {target.__class__.__name__}") 64 | elif target.IsList: 65 | super().__init__(f"Can't call {op} on a list-of {target.__class__.__name__}") 66 | else: 67 | # TODO: revise this message, provided we find a use for it 68 | super().__init__(f"Can't call {op} on an object of type {target.__class__.__name__}") 69 | -------------------------------------------------------------------------------- /pyzohoapi/inventory.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | from .core import ZohoAPIBase 5 | from . import objecttypes 6 | 7 | class ZohoInventory(ZohoAPIBase): 8 | _scope = "ZohoInventory.FullAccess.all" 9 | 10 | def get_endpoint(self, region): 11 | return f"https://www.zohoapis.{self._regionmap[region]}/inventory/v1" 12 | 13 | def Account(self, *args, **kwargs): return objecttypes.Account(self, *args, **kwargs) 14 | def Bill(self, *args, **kwargs): return objecttypes.Bill(self, *args, **kwargs) 15 | def Brand(self, *args, **kwargs): return objecttypes.Brand(self, *args, **kwargs) 16 | def Bundle(self, *args, **kwargs): return objecttypes.Bundle(self, *args, **kwargs) 17 | def CompositeItem(self, *args, **kwargs): return objecttypes.CompositeItem(self, *args, **kwargs) 18 | def Contact(self, *args, **kwargs): return objecttypes.Contact(self, *args, **kwargs) 19 | def CustomerPayment(self, *args, **kwargs): return objecttypes.CustomerPayment(self, *args, **kwargs) 20 | def Currency(self, *args, **kwargs): return objecttypes.Currency(self, *args, **kwargs) 21 | def Document(self, *args, **kwargs): return objecttypes.Document(self, *args, **kwargs) 22 | def Invoice(self, *args, **kwargs): return objecttypes.Invoice(self, *args, **kwargs) 23 | def Item(self, *args, **kwargs): return objecttypes.Item(self, *args, **kwargs) 24 | def ItemAdjustment(self, *args, **kwargs): return objecttypes.ItemAdjustment(self, *args, **kwargs) 25 | def ItemGroup(self, *args, **kwargs): return objecttypes.ItemGroup(self, *args, **kwargs) 26 | def Organization(self, *args, **kwargs): return objecttypes.Organization(self, *args, **kwargs) 27 | def Package(self, *args, **kwargs): return objecttypes.Package(self, *args, **kwargs) 28 | def PriceList(self, *args, **kwargs): return objecttypes.PriceList(self, *args, **kwargs) 29 | def PurchaseOrder(self, *args, **kwargs): return objecttypes.PurchaseOrder(self, *args, **kwargs) 30 | def PurchaseReceive(self, *args, **kwargs): return objecttypes.PurchaseReceive(self, *args, **kwargs) 31 | def RetainerInvoice(self, *args, **kwargs): return objecttypes.RetainerInvoice(self, *args, **kwargs) 32 | def SalesOrder(self, *args, **kwargs): return objecttypes.SalesOrder(self, *args, **kwargs) 33 | def SalesPerson(self, *args, **kwargs): return objecttypes.SalesPerson(self, *args, **kwargs) 34 | def SalesReturn(self, *args, **kwargs): return objecttypes.SalesReturn(self, *args, **kwargs) 35 | def ShipmentOrder(self, *args, **kwargs): return objecttypes.ShipmentOrder(self, *args, **kwargs) 36 | def Tax(self, *args, **kwargs): return objecttypes.Tax(self, *args, **kwargs) 37 | def TaxAuthority(self, *args, **kwargs): return objecttypes.TaxAuthority(self, *args, **kwargs) 38 | def TaxExemption(self, *args, **kwargs): return objecttypes.TaxExemption(self, *args, **kwargs) 39 | def TaxGroup(self, *args, **kwargs): return objecttypes.TaxGroup(self, *args, **kwargs) 40 | def TransferOrder(self, *args, **kwargs): return objecttypes.TransferOrder(self, *args, **kwargs) 41 | def User(self, *args, **kwargs): return objecttypes.User(self, *args, **kwargs) 42 | def Warehouse(self, *args, **kwargs): return objecttypes.Warehouse(self, *args, **kwargs) 43 | -------------------------------------------------------------------------------- /pyzohoapi/objecttypes/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | from pyzohoapi.objecttypes.mixins.CompositeItemOps import HasBundle 5 | from ..core import ZohoObjectBase 6 | from .mixins import HasActivate, HasAddresses, HasConfirm, HasCustomFields, HasDelivered, HasDraft, HasImage, HasVoid 7 | 8 | 9 | def ZohoObjectFactory(name, 10 | urlPath=None, responseKey=None, pluralResponseKey=None, 11 | idKey=None, numberKey=None, raw=False, 12 | mixins=[]): 13 | """ Factory function to create a Zoho Object type 14 | 15 | Unless overridden by the optional parameters, `name` is used to determine how 16 | Zoho API objects are accessed and parsed. 17 | 18 | * The URL used will be https:/{api-root}/{pluralized `name` -or- `urlPath`} 19 | * A list of objects is under the JSON key `pluralizedResponseKey` -or- `responseKey` pluralized -or- `name` pluralized 20 | * A single object is under the JSON key `responseKey` -or- `name` 21 | * The object ID is under the JSON key `idKey` -or- `{name}_id` 22 | * The object Number is under the JSON key `numberKey` -or- `{name}_number` 23 | 24 | :param name: object-class name; the basis of the API URL path unless overridden. 25 | :param urlPath: sets th API URL path 26 | :param responseKey: sets the singular (and maybe plural) response key(s). 27 | :param pluralResponseKey: sets the plural response key. 28 | :param idKey: sets the id field key. 29 | :param numberKey: sets the number field key. 30 | :param raw: handle the response body as raw data rather than json for non-list responses 31 | :param mixins: list of object-type mixins. Defaults to []. 32 | :return: ZohoObject sub-class 33 | """ 34 | return type(name, tuple([ZohoObjectBase] + mixins), { 35 | '__name__': name, 36 | '_type': urlPath if urlPath else f"{name.lower()}s", 37 | '_singular': responseKey if responseKey else name.lower(), 38 | '_plural': pluralResponseKey if pluralResponseKey else f"{responseKey}s" if responseKey else f"{name.lower()}s", 39 | '_id_field': idKey if idKey else f"{name.lower()}_id", 40 | '_number_field': numberKey if numberKey else f"{name.lower()}_number", 41 | '_is_raw': raw, 42 | }) 43 | 44 | Account = ZohoObjectFactory("ChartOfAccount", idKey="account_id") 45 | Bill = ZohoObjectFactory("Bill", mixins=[HasCustomFields]) 46 | Brand = ZohoObjectFactory("Brand") 47 | Bundle = ZohoObjectFactory("Bundle") 48 | CompositeItem = ZohoObjectFactory("CompositeItem", 49 | responseKey="composite_item", idKey="composite_item_id", 50 | mixins=[HasActivate, HasBundle, HasCustomFields]) 51 | Contact = ZohoObjectFactory("Contact", mixins=[HasActivate, HasAddresses, HasCustomFields]) 52 | CreditNote = ZohoObjectFactory("CreditNode", mixins=[HasCustomFields]) 53 | Currency = ZohoObjectFactory("Currency", urlPath="settings/currencies", 54 | pluralResponseKey="currencies") 55 | CustomerPayment = ZohoObjectFactory("CustomerPayment", 56 | idKey="payment_id", numberKey="payment_number", mixins=[HasCustomFields]) 57 | Document = ZohoObjectFactory("Document", raw=True) 58 | Invoice = ZohoObjectFactory("Invoice", mixins=[HasCustomFields]) 59 | Item = ZohoObjectFactory("Item", mixins=[HasActivate, HasCustomFields, HasImage]) 60 | ItemAdjustment = ZohoObjectFactory("ItemAdjustment", 61 | urlPath="inventoryadjustments", responseKey="inventory_adjustment", 62 | idKey="inventory_adjustment_id") 63 | ItemGroup = ZohoObjectFactory("ItemGroup", idKey="group_id", 64 | responseKey="group", mixins=[HasActivate]) 65 | Organization = ZohoObjectFactory("Organization") 66 | Package = ZohoObjectFactory("Package", mixins=[HasCustomFields]) 67 | PriceList = ZohoObjectFactory("PriceList", urlPath="pricebooks", 68 | responseKey="pricebook", idKey="pricebook_id", 69 | mixins=[HasActivate]) 70 | PurchaseOrder = ZohoObjectFactory("PurchaseOrder", mixins=[HasCustomFields]) 71 | PurchaseReceive = ZohoObjectFactory("PurchaseReceive", mixins=[HasCustomFields]) 72 | RetainerInvoice = ZohoObjectFactory("RetainerInvoice", mixins=[HasDraft, HasVoid]) 73 | SalesOrder = ZohoObjectFactory("SalesOrder", 74 | mixins=[HasConfirm, HasCustomFields, HasVoid]) 75 | SalesPerson = ZohoObjectFactory("SalesPerson") 76 | SalesReturn = ZohoObjectFactory("SalesReturn") 77 | ShipmentOrder = ZohoObjectFactory("ShipmentOrder", 78 | responseKey="shipmentorder", idKey="shipment_id", 79 | numberKey="shipment_number", mixins=[HasCustomFields, HasDelivered]) 80 | Tax = ZohoObjectFactory("Tax", urlPath="settings/taxes", 81 | pluralResponseKey="taxes") 82 | TaxAuthority = ZohoObjectFactory("Tax", urlPath="settings/taxauthorities", 83 | responseKey="tax_authority", pluralResponseKey="tax_authorities", 84 | idKey="tax_authority_id") 85 | TaxExemption = ZohoObjectFactory("Tax", urlPath="settings/taxexemptions", 86 | responseKey="tax_exemption", idKey="tax_exemption_id") 87 | TaxGroup = ZohoObjectFactory("Tax", urlPath="settings/taxgroups", 88 | responseKey="tax_group", idKey="tax_group_id") 89 | TransferOrder = ZohoObjectFactory("TransferOrder", 90 | responseKey="transfer_order", 91 | idKey="transfer_order_id", numberKey="transfer_order_number") 92 | User = ZohoObjectFactory("User", mixins=[HasActivate, HasCustomFields]) 93 | Warehouse = ZohoObjectFactory("Warehouse", urlPath="settings/warehouses", 94 | mixins=[HasActivate]) 95 | -------------------------------------------------------------------------------- /pyzohoapi/objecttypes/mixins/CompositeItemOps.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | import datetime 4 | 5 | from ...exceptions import * 6 | 7 | class HasBundle: 8 | def MakeBundle(self, qty, bundle_date=None, purchase_account_id=None, warehouse_id=None): 9 | """ Create a bundle for a CompositeItem 10 | 11 | :param qty: Number of bundles to make. 12 | :param bundle_date: Date of bundling, in "YYYY-MM-DD" format. Defaults to today. 13 | :param purchase_account_id: ID of the purchase account. Defaults to None. 14 | :param warehouse_id: ID of the warehouse to make the bundle in. Defaults to None. 15 | :return: Bundle ZohoObject 16 | :raises ZohoInvalidOpError: if `self` is new, a list, or not loaded. 17 | """ 18 | if self.IsLoaded and self.ID: 19 | bundle = self._api.Bundle() 20 | bundle.date = bundle_date if bundle_date else datetime.datetime.today().strftime("%Y-%m-%d") 21 | bundle.composite_item_id = self.ID 22 | bundle.composite_item_name = self.name 23 | if self.get('sku'): 24 | bundle.composite_item_sku = self.get('sku') 25 | bundle.quantity_to_bundle = qty 26 | bundle.is_completed = True 27 | bundle.line_items = [] 28 | for line in self.mapped_items: 29 | bundle_line = { 30 | 'item_id': line['item_id'], 31 | 'name': line['name'], 32 | 'unit': line['unit'], 33 | 'rate': line['purchase_rate'], 34 | 'quantity_consumed': line['quantity'], 35 | } 36 | if purchase_account_id: 37 | bundle_line['account_id'] = purchase_account_id 38 | if warehouse_id: 39 | bundle_line['warehouse_id'] = warehouse_id 40 | bundle.line_items.append(bundle_line) 41 | return bundle.Create() 42 | raise ZohoInvalidOpError('MakeBundle', self) 43 | -------------------------------------------------------------------------------- /pyzohoapi/objecttypes/mixins/CustomFields.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | import datetime 4 | 5 | from ...core.collection import DottedList 6 | 7 | from ...exceptions import * 8 | 9 | class HasCustomFields: 10 | def GetCustomField(self, key, listKey="custom_fields", default=None): 11 | if self._id: 12 | if self._data: 13 | for cf in self._data.get(listKey, []): 14 | if key in [cf['label'], cf['placeholder']]: 15 | return cf['value'] 16 | return default 17 | raise ZohoInvalidOpError("GetCustomField", self) 18 | 19 | def SetCustomField(self, key, value, listKey="custom_fields", altkey=None): 20 | """ Sets the value of an existing Custom Field 21 | 22 | :param key: Custom Field `placeholder` or `label` (string) 23 | :param value: new value for the Custom Field. 24 | :param listKey: key to the Custom Fields list. Defaults to "custom_fields". 25 | :param altkey: "alternate" key for adding a custom field. 26 | :return: `self` 27 | :raises ZohoInvalidOpError: If `self` isn't an existing object. 28 | """ 29 | if self._id: 30 | if self._data: 31 | for cf in self._data.get(listKey, []): 32 | if key in [cf['label'], cf['placeholder']]: 33 | cf['value'] = value 34 | return self 35 | # We didn't find the custom field, so we need to add it. 36 | self._data.setdefault(listKey, DottedList()).append({ 37 | 'placeholder': key if key.startswith("cf_") else altkey, 38 | 'label': altkey if key.startswith("cf_") else key, 39 | 'value': value, 40 | }) 41 | return self 42 | raise ZohoInvalidOpError("SetCustomField", self) 43 | -------------------------------------------------------------------------------- /pyzohoapi/objecttypes/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | from ...exceptions import ZohoException, ZohoInvalidOpError 5 | 6 | from .CompositeItemOps import HasBundle 7 | from .CustomFields import HasCustomFields 8 | 9 | __all__ = [ 10 | 'HasActivate', 11 | 'HasAddresses', 12 | 'HasBundle', 13 | 'HasConfirm', 'HasDraft', 'HasVoid', 'HasDelivered', 14 | 'HasCustomFields', 15 | 'HasImage', 16 | ] 17 | 18 | class HasActivate: 19 | """Adds `Activate()` and `Deactivate()`""" 20 | def _do_operation(self, status, funcname): 21 | if self._id and self._data: 22 | try: 23 | self._api.post(self._url_fragment(extraPath=[status])) 24 | self.status = status 25 | return True 26 | except ZohoException as e: 27 | return False 28 | raise ZohoInvalidOpError(funcname, self) 29 | 30 | def Activate(self): 31 | return self._do_operation('active', "Activate") 32 | 33 | def Deactivate(self): 34 | return self._do_operation('inactive', "Deactivate") 35 | 36 | 37 | class HasImage: 38 | """Adds `AddImage()`, `DeleteImage()` and `GetImage()`""" 39 | def AddImage(self, name, image, type="image/jpeg"): 40 | if self._id: 41 | file = {'image': (name, image, type)} 42 | return self._api.post(self._url_fragment(extraPath=['image']), files=file) 43 | raise ZohoInvalidOpError("AddImage", self) 44 | 45 | def DeleteImage(self): 46 | if self._id: 47 | return self._api.delete(self._url_fragment(extraPath=['image'])) 48 | raise ZohoInvalidOpError("AddImage", self) 49 | 50 | def GetImage(self): 51 | if self._id and self._data: 52 | return self._api.get(self._url_fragment(extraPath=['image']), "") 53 | raise ZohoInvalidOpError("GetImage", self) 54 | 55 | 56 | class _HasStatus: 57 | def _mark(self, status): 58 | if self._id: 59 | try: 60 | self._api.post(self._url_fragment(extraPath=['status', status])) 61 | return True 62 | except ZohoException as e: 63 | return False 64 | return None 65 | 66 | 67 | class HasConfirm(_HasStatus): 68 | """Adds `Confirm()`""" 69 | def Confirm(self): 70 | if not self._id: 71 | raise ZohoInvalidOpError("Confirm", self) 72 | return self._mark('confirmed') 73 | 74 | 75 | class HasDelivered(_HasStatus): 76 | """Adds `Delivered()`""" 77 | def Delivered(self): 78 | if not self._id: 79 | raise ZohoInvalidOpError("Delivered", self) 80 | return self._mark('delivered') 81 | 82 | 83 | class HasDraft(_HasStatus): 84 | """Adds `Draft()`""" 85 | def Draft(self): 86 | if not self._id: 87 | raise ZohoInvalidOpError("Draft", self) 88 | return self._mark('draft') 89 | 90 | 91 | class HasVoid(_HasStatus): 92 | """Adds `Void()`""" 93 | def Void(self): 94 | if not self._id: 95 | raise ZohoInvalidOpError("Void", self) 96 | return self._mark('void') 97 | 98 | 99 | class _HasAspect: 100 | def _updateAspect(self, aspect, aspect_id, data): 101 | data = self._api.put(self._url_fragment(extraPath=[aspect, aspect_id]), data, "") 102 | if data['code'] == 0: 103 | return True 104 | return False 105 | 106 | 107 | class HasAddresses(_HasAspect): 108 | """Adds `UpdateBilling()` and `UpdateShipping()`""" 109 | def UpdateBilling(self): 110 | if self._id and self._data: 111 | self._updateAspect('address', self.billing_address.address_id, self.billing_address.to_python()) 112 | return self 113 | raise ZohoInvalidOpError("UpdateBilling", self) 114 | def UpdateShipping(self): 115 | if self._id and self._data: 116 | self._updateAspect('address', self.shipping_address.address_id, self.shipping_address.to_python()) 117 | return self 118 | raise ZohoInvalidOpError("UpdateShipping", self) 119 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | import pytest 7 | 8 | from pyzohoapi.inventory import ZohoInventory 9 | from pyzohoapi.exceptions import ZohoException 10 | 11 | from private import testdata 12 | 13 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 14 | 15 | def test_empty_user(): 16 | c = z.User() 17 | assert not c.IsLoaded 18 | 19 | 20 | def test_get_user(): 21 | c = z.User(testdata.inventory.user.id) 22 | assert c.IsLoaded 23 | assert not c.IsList 24 | assert c.email == testdata.inventory.user.email 25 | 26 | 27 | def test_search_user(): 28 | c = z.User(email=testdata.inventory.user.email) 29 | assert c.IsLoaded 30 | assert c.IsList 31 | assert repr(c) == "List of User objects" 32 | one = c.First() 33 | assert one.IsLoaded 34 | assert one.ID == testdata.inventory.user.id 35 | 36 | 37 | def test_search_notfound(): 38 | c = z.User(email=testdata.inventory.user.invalid_email) 39 | assert c.IsLoaded 40 | assert c.IsList 41 | assert repr(c) == "List of User objects" 42 | assert len(c._data.to_python()) == 0 43 | one = c.First() 44 | assert repr(one) == "New User" 45 | 46 | 47 | def test_list_raw_user(): 48 | c = z.User() 49 | assert not c.IsLoaded 50 | for i in c.Iter(raw=True): 51 | assert hasattr(i, 'user_id') 52 | 53 | 54 | def test_filter_list_raw_user(): 55 | c = z.User() 56 | assert not c.IsLoaded 57 | count = 0 58 | for i in c.Iter(raw=True, email=testdata.inventory.user.email): 59 | count += 1 60 | assert i.get('email') == testdata.inventory.user.email 61 | assert count == 1 62 | 63 | 64 | def test_list_user(): 65 | c = z.User() 66 | assert not c.IsLoaded 67 | for i in c.Iter(): 68 | assert i.__class__.__name__ == "User" 69 | 70 | 71 | def test_filter_list_user(): 72 | c = z.User() 73 | assert not c.IsLoaded 74 | count = 0 75 | for i in c.Iter(email=testdata.inventory.user.email): 76 | count += 1 77 | assert i.__class__.__name__ == "User" 78 | assert i.IsLoaded 79 | assert i.email == testdata.inventory.user.email 80 | assert count == 1 81 | 82 | 83 | def test_so_get_related(): 84 | so = z.SalesOrder(testdata.inventory.salesorder.id) 85 | assert so.IsLoaded 86 | assert so.Number == testdata.inventory.salesorder.number 87 | cust = so.GetRelated(z.Contact, "customer_id") 88 | assert cust.IsLoaded 89 | assert cust.contact_name == testdata.inventory.salesorder.contact_name 90 | 91 | 92 | def test_so_iter_related(): 93 | so = z.SalesOrder(testdata.inventory.salesorder.id) 94 | assert so.IsLoaded 95 | for item in so.IterRelatedList(z.Item, "line_items", "item_id"): 96 | assert item.IsLoaded 97 | assert item.sku == testdata.inventory.salesorder.line_item_sku 98 | break 99 | 100 | 101 | def test_so_map_related(): 102 | so = z.SalesOrder(testdata.inventory.salesorder.id) 103 | assert so.IsLoaded 104 | for item in so.MapRelatedList(z.Item, "line_items", "item_id"): 105 | assert item.object.IsLoaded 106 | assert item.meta.sku == testdata.inventory.salesorder.line_item_sku 107 | assert item.meta.sku == item.object.sku 108 | break 109 | 110 | def test_create_customer(): 111 | c = z.Contact() 112 | c.contact_name = "00 TEST CONTACT - DO NOT USE" 113 | c.contact_type = "customer" 114 | c.customer_sub_type = "individual" 115 | c.contact_persons= [{ 116 | 'first_name': "00 Test", 117 | 'last_name': " Contact", 118 | 'email': "test@example.com", 119 | 'phone': "123-456-7890", 120 | 'is_primary_contact': True, 121 | }] 122 | c.is_taxable = True 123 | c.tax_id = testdata.inventory.newuser.tax_id 124 | c.tax_name = testdata.inventory.newuser.tax_name 125 | c.payment_terms = testdata.inventory.newuser.payment_terms 126 | c.payment_terms_label = testdata.inventory.newuser.payment_terms_label 127 | 128 | c.Create() 129 | assert c.ID is not None 130 | testdata.inventory.salesorder.newuser = {'id': c.ID} 131 | 132 | 133 | def test_update_customer(): 134 | assert testdata['inventory'].get('newuser',{}).get('id', False) 135 | c = z.Contact(testdata.inventory.salesorder.newuser.id) 136 | assert c.IsLoaded 137 | assert c.shipping_address.city == "" 138 | c.shipping_address = testdata.inventory.salesorder.newaddress 139 | c.Update() 140 | del c 141 | c = z.Contact(testdata.inventory.salesorder.newuser.id) 142 | assert c.IsLoaded 143 | assert c.shipping_address.city == testdata.inventory.salesorder.newaddress.city 144 | 145 | 146 | def test_delete_customer(): 147 | assert testdata['inventory'].get('newuser',{}).get('id', False) 148 | c = z.Contact(testdata.inventory.salesorder.newuser.id) 149 | assert c.IsLoaded 150 | c.Delete() 151 | assert c.IsDeleted 152 | 153 | def util_createso(): 154 | so = z.SalesOrder() 155 | so.salesorder_number = "TEST-SO-DO-NOT-USE" 156 | so.customer_id = testdata.inventory.salesorder.customer.id 157 | so.tax_id = testdata.inventory.salesorder.customer.tax_id 158 | so.salesperson_id = testdata.inventory.salesorder.customer.salesperson_id 159 | so.line_items = [] 160 | so.line_items.append(testdata.inventory.salesorder.item) 161 | so.Create(ignore_auto_number_generation="true") 162 | return so 163 | 164 | def test_confirm(): 165 | so = util_createso() 166 | assert so.ID is not None 167 | assert so.status == "draft" 168 | 169 | assert so.Confirm() 170 | 171 | soAfter = z.SalesOrder(so.ID) 172 | assert soAfter.IsLoaded 173 | assert soAfter.status == "confirmed" 174 | 175 | so.Delete() 176 | 177 | def test_set_customfield(): 178 | so = util_createso() 179 | assert so.ID is not None 180 | assert so.GetCustomField('cf_system_was_printed') is False 181 | 182 | so.SetCustomField('cf_system_was_printed', True).Update() 183 | assert so.GetCustomField('cf_system_was_printed') is True 184 | 185 | soAfter = z.SalesOrder(so.ID) 186 | assert soAfter.GetCustomField('cf_system_was_printed') is True 187 | 188 | so.Delete() 189 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_compositeitems.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | from pyzohoapi.inventory import ZohoInventory 7 | from pyzohoapi.exceptions import ZohoException 8 | 9 | from private import testdata 10 | 11 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 12 | 13 | def test_composite_bundle(): 14 | composite = z.CompositeItem(testdata.inventory.composite.composite_item_id) 15 | assert composite.IsLoaded 16 | bundle = composite.MakeBundle(2, 17 | purchase_account_id=testdata.inventory.composite.purchase_account_id, 18 | warehouse_id=testdata.inventory.composite.warehouse_id 19 | ) 20 | assert bundle.ID 21 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_currency.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | from pyzohoapi.inventory import ZohoInventory 7 | from pyzohoapi.exceptions import ZohoException 8 | 9 | from private import testdata 10 | 11 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 12 | 13 | def test_inventory_currency(): 14 | c = z.Currency().First() 15 | assert c.IsLoaded 16 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_itemadjustments.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | import datetime 5 | from pprint import pprint 6 | 7 | from pyzohoapi.inventory import ZohoInventory 8 | from pyzohoapi.exceptions import ZohoException 9 | 10 | from private import testdata 11 | 12 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 13 | 14 | def test_inventory_itemadjustments(): 15 | # create a test item 16 | temp = z.Item() 17 | for key, value in testdata.inventory.item.tempitem.items(): 18 | temp.__setattr__(key, value) 19 | temp.Create() 20 | 21 | try: 22 | # assert temp.available_stock == 0 23 | adj = z.ItemAdjustment() 24 | adj.date = datetime.datetime.today().strftime("%Y-%m-%d") 25 | for key, value in testdata.inventory.item.adjustment.items(): 26 | adj.__setattr__(key, value) 27 | adj.line_items = [{ 28 | 'item_id': temp.ID, 29 | 'quantity_adjusted': 1, 30 | 'warehouse_id': testdata.inventory.warehouse_id 31 | }] 32 | adj.Create() 33 | 34 | temp2 = z.Item(temp.ID) 35 | assert temp2.available_stock == 1 36 | 37 | adj.Delete() 38 | except ZohoException as e: 39 | raise e 40 | finally: 41 | temp.Delete() 42 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_itemtransfer.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | import datetime 5 | from pprint import pprint 6 | 7 | from pyzohoapi.inventory import ZohoInventory 8 | from pyzohoapi.exceptions import ZohoException 9 | 10 | from private import testdata 11 | 12 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 13 | 14 | def test_inventory_transferorder(): 15 | def qty_in_warehouse(item, warehouse_id): 16 | for wh in item.warehouses: 17 | if wh.warehouse_id == warehouse_id: 18 | return wh.warehouse_stock_on_hand 19 | return None 20 | 21 | temp = z.Item() 22 | for key, value in testdata.inventory.item.tempitem.items(): 23 | temp.__setattr__(key, value) 24 | temp.Create() 25 | adj = z.ItemAdjustment() 26 | adj.date = datetime.datetime.today().strftime("%Y-%m-%d") 27 | for key, value in testdata.inventory.item.adjustment.items(): 28 | adj.__setattr__(key, value) 29 | adj.line_items = [{ 30 | 'item_id': temp.ID, 31 | 'quantity_adjusted': 1, 32 | 'warehouse_id': testdata.inventory.warehouse_id 33 | }] 34 | adj.Create() 35 | 36 | temp_reloaded = z.Item(temp.ID) 37 | assert qty_in_warehouse(temp_reloaded, testdata.inventory.warehouse_id) == 1 38 | assert qty_in_warehouse(temp_reloaded, testdata.inventory.aux_warehouse_id) == 0 39 | 40 | try: 41 | xfer = z.TransferOrder() 42 | xfer.date = datetime.datetime.today().strftime("%Y-%m-%d") 43 | xfer.from_warehouse_id = testdata.inventory.warehouse_id 44 | xfer.to_warehouse_id = testdata.inventory.aux_warehouse_id 45 | xfer.is_intransit_code = False 46 | xfer.line_items = [{ 47 | 'item_id': temp.ID, 48 | 'name': temp.name, 49 | 'quantity_transfer': 1, 50 | }] 51 | xfer.Create() 52 | 53 | temp2 = z.Item(temp.ID) 54 | assert qty_in_warehouse(temp2, testdata.inventory.warehouse_id) == 0 55 | assert qty_in_warehouse(temp2, testdata.inventory.aux_warehouse_id) == 1 56 | 57 | xfer.Delete() 58 | except ZohoException as e: 59 | raise e 60 | finally: 61 | adj.Delete() 62 | temp.Delete() 63 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_pricelist.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | from pyzohoapi.inventory import ZohoInventory 7 | from pyzohoapi.exceptions import ZohoException 8 | 9 | from private import testdata 10 | 11 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 12 | 13 | def test_inventory_pricelist(): 14 | print() 15 | for o in z.PriceList().Iter(raw=True): 16 | print(o.name) 17 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_purchasereceive.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | from pyzohoapi.inventory import ZohoInventory 7 | from pyzohoapi.exceptions import ZohoException 8 | 9 | from private import testdata 10 | 11 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 12 | 13 | def test_inventory_purchasereceive(): 14 | o = z.PurchaseReceive(testdata.inventory.purchasereceive.purchasereceive_id) 15 | assert o.IsLoaded 16 | assert o.purchaseorder_number == testdata.inventory.purchasereceive.purchaseorder_number 17 | -------------------------------------------------------------------------------- /tests/inventory/test_inventory_setcustomfield.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | from pyzohoapi.inventory import ZohoInventory 7 | from pyzohoapi.exceptions import ZohoException 8 | 9 | from private import testdata 10 | 11 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 12 | 13 | def test_inventory_item_setcustomfield(): 14 | temp = z.Item() 15 | for key, value in testdata.inventory.item.tempitem.items(): 16 | temp.__setattr__(key, value) 17 | temp.Create() 18 | 19 | assert temp.GetCustomField(testdata.inventory.item.customfield.label) is None 20 | temp.SetCustomField(testdata.inventory.item.customfield.label, testdata.inventory.item.customfield.value).Update() 21 | assert temp.GetCustomField(testdata.inventory.item.customfield.label) == testdata.inventory.item.customfield.value 22 | 23 | # load a fresh copy, to be sure the DB is updated, not just our local copy 24 | fresh = z.Item(temp.ID) 25 | assert fresh.GetCustomField(testdata.inventory.item.customfield.placeholder) == testdata.inventory.item.customfield.value 26 | 27 | temp.Delete() 28 | -------------------------------------------------------------------------------- /tests/test_00_pyzohoapi.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | import copy 5 | import datetime 6 | 7 | import pytest 8 | 9 | from pyzohoapi import ZohoInventory 10 | from pyzohoapi.core import ZohoAPIBase 11 | from pyzohoapi.exceptions import ZohoInsufficientAuthKeys, ZohoUnknownRegionException 12 | 13 | from private import testdata 14 | 15 | def test_api_bad_region(): 16 | with pytest.raises(ZohoUnknownRegionException): 17 | z = ZohoAPIBase(testdata['orgid'], "really-bad-region-name", **testdata['api']) 18 | 19 | def test_api_expired_access_token(): 20 | tokens = copy.copy(testdata['api']) 21 | del tokens['refresh_token'] 22 | del tokens['client_id'] 23 | del tokens['client_secret'] 24 | z = ZohoAPIBase(testdata['orgid'], testdata['region'], **tokens) 25 | with pytest.raises(ZohoInsufficientAuthKeys): 26 | z.auth_header() 27 | 28 | def test_api_invalid_access_token(): 29 | z = ZohoInventory(testdata['orgid'], testdata['region'], **testdata['api']) 30 | # Force a call so we get a valid access_token 31 | u = z.User().First() 32 | assert u.IsLoaded 33 | token = z.auth_header() 34 | z._api_keys['access_token'] = "1000.badtoken" # but not expired 35 | u = z.User().First() 36 | assert u.IsLoaded 37 | 38 | def test_api_refresh_access_token(): 39 | z = ZohoAPIBase(testdata['orgid'], testdata['region'], **testdata['api']) 40 | new_token = z.auth_header() 41 | assert z._api_keys['expires_in'] >= 0 42 | 43 | def test_api_intercall_delay(): 44 | z = ZohoInventory(testdata['orgid'], testdata['region'], intercall_delay=4, **testdata['api']) 45 | start = datetime.datetime.now().timestamp() 46 | # We don't care about the actual results, just the timing. 47 | rsp1 = z.get('users', "email=testing.example.com") 48 | rsp2 = z.get('users', "email=testing.example.com") 49 | assert datetime.datetime.now().timestamp() - start >= 4 50 | -------------------------------------------------------------------------------- /tests/test_core_first.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see http://opensource.org/licenses/MIT). 3 | 4 | from pprint import pprint 5 | 6 | from pyzohoapi.inventory import ZohoInventory 7 | from pyzohoapi.exceptions import ZohoException 8 | 9 | from private import testdata 10 | 11 | z = ZohoInventory(testdata.orgid, testdata.region, **testdata.api) 12 | 13 | def test_core_first(): 14 | # verify that First gets the first in the unfiltered list 15 | c = z.Currency().First() 16 | assert c.IsLoaded 17 | assert c.currency_code == testdata.core.first.first_currency_code 18 | 19 | # verify that First respects filtering 20 | c = z.Currency().First(currency_code=testdata.core.first.filtered_currency_code) 21 | assert c.IsLoaded 22 | assert c.currency_code == testdata.core.first.filtered_currency_code 23 | 24 | # verify that First returns "empty" on filter-failure 25 | c = z.Currency().First(currency_code=testdata.core.first.invalid_currency_code) 26 | assert not c.IsLoaded 27 | -------------------------------------------------------------------------------- /tools/interactive-test-server.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | import argparse 5 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 6 | import logging 7 | import os 8 | import signal 9 | import sys 10 | import traceback 11 | 12 | import simplejson as json 13 | import urllib 14 | 15 | sys.path.insert(0, "..") 16 | 17 | from pyzohoapi import * 18 | from pyzohoapi.exceptions import ZohoException 19 | from private import testdata 20 | 21 | params = { 22 | 'orgid': testdata['orgid'], 23 | 'refresh_token': testdata['api']['refresh_token'], 24 | 'client_id': testdata['api']['client_id'], 25 | 'client_secret': testdata['api']['client_secret'], 26 | } 27 | blank_params = { 28 | 'api': 'inventory', 29 | 'type': "", 30 | 'id': "", 31 | 'xtrapath': "", 32 | 'qparams': "", 33 | 'results': {}, 34 | 'apiinfo': {}, 35 | } 36 | 37 | apiobjs = { 38 | 'books': ZohoBooks(testdata['orgid'], "US", **testdata['api']), 39 | 'inventory': ZohoInventory(testdata['orgid'], "US", **testdata['api']), 40 | } 41 | 42 | class RequestHandler(BaseHTTPRequestHandler): 43 | @staticmethod 44 | def asset_fname(ext): 45 | return os.path.join( 46 | os.path.dirname(__file__), 47 | "serverfiles", 48 | os.path.basename(__file__).replace(".py", f".{ext}") 49 | ) 50 | 51 | def sendFile(self, ext, type): 52 | with open(self.asset_fname(ext), "r") as _: 53 | contents = _.read() 54 | self.send_response(200) 55 | self.send_header("Content-type", f"text/{type}") 56 | self.end_headers() 57 | self.wfile.write(bytes(contents, "utf-8")) 58 | 59 | def sendTemplate(self): 60 | with open(self.asset_fname("html"), "r") as _: 61 | template = _.read() 62 | self.send_response(200) 63 | self.send_header("Content-type", "text/html") 64 | self.end_headers() 65 | self.wfile.write(bytes(template.format(**params), "utf-8")) 66 | 67 | def do_GET(self): 68 | if self.command == "GET": 69 | params.update(blank_params) 70 | if self.path == "/": 71 | self.sendTemplate() 72 | elif self.path == "/favicon.ico": 73 | self.send_response(404) 74 | self.end_headers() 75 | elif self.path == "/local.js": 76 | self.sendFile("js", "javascript") 77 | elif self.path == "/local.css": 78 | self.sendFile("css", "css") 79 | 80 | def do_POST(self): 81 | rawform = self.rfile.read(int(self.headers['Content-Length'])).decode('utf-8') 82 | form = { k:v for k,v in [e.split("=") for e in rawform.split("&")] if v } 83 | for k in ['type', 'qparams', 'xtrapath']: 84 | if k in form: 85 | form[k] = urllib.parse.unquote_plus(form[k]) 86 | params.update(form) 87 | api = apiobjs.get(form['api']) 88 | if form['action'] == "get": 89 | frag = f"{form['type']}{'/' + form['id'] if form.get('id') else ''}{'/' + form['xtrapath'] if form.get('xtrapath') else ''}" 90 | try: 91 | rsp = api.get(frag, params['qparams']) 92 | params['results'] = json.dumps(rsp, sort_keys=True) 93 | params['apiinfo'] = json.dumps({k:str(v) for k,v in api._ratelimit.items()}) 94 | except ZohoException as e: 95 | params['results'] = json.dumps({ 96 | 'Exception': { 97 | 'class': e.__class__.__name__, 98 | 'message': str(e), 99 | 'traceback': [l for l in traceback.format_exc().split('\n')] 100 | } 101 | }) 102 | 103 | return self.do_GET() 104 | 105 | 106 | def run_server(hostName, serverPort): 107 | server = ThreadingHTTPServer((hostName, serverPort), RequestHandler) 108 | server.daemon_threads = True 109 | server.allow_reuse_address = True 110 | def signal_handler(signal, frame): 111 | print("Exiting due to keyboard interrupt...") 112 | try: 113 | if (server): 114 | server.server_close() 115 | finally: 116 | sys.exit(0) 117 | 118 | signal.signal(signal.SIGINT, signal_handler) 119 | 120 | print(f"Server started http://{hostName}:{serverPort}") 121 | print("Press Ctrl-C to exit.") 122 | os.system("title pyZohoAPI Testing Server") 123 | 124 | os.system(f"start http://{hostName}:{serverPort}") 125 | try: 126 | while True: 127 | sys.stdout.flush() 128 | server.serve_forever() 129 | except KeyboardInterrupt: 130 | pass 131 | 132 | server.server_close() 133 | 134 | 135 | if __name__ == "__main__": 136 | parser = argparse.ArgumentParser() 137 | parser.add_argument("-p", "--port", type=int, help="TCP port to serve from") 138 | parser.add_argument("--log", action='store_true', default=False, help="Display log messages from pyZohoAPI") 139 | args = parser.parse_args() 140 | 141 | if args.log: 142 | logger = logging.getLogger() 143 | logging.basicConfig(level=logging.DEBUG) 144 | logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) 145 | 146 | run_server("localhost", args.port or 8080) 147 | -------------------------------------------------------------------------------- /tools/serverfiles/interactive-test-server.css: -------------------------------------------------------------------------------- 1 | /* 2 | This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 3 | Distributed under the MIT License (see https://opensource.org/licenses/MIT). 4 | */ 5 | main { 6 | display: grid; 7 | grid-template-columns: 5fr 1fr; 8 | } 9 | 10 | i { 11 | font-size: .7rem; 12 | vertical-align: text-top; 13 | } 14 | 15 | pre { 16 | overflow-x: hidden; 17 | } 18 | 19 | ul { 20 | list-style: none; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | .hidden { 26 | display: none; 27 | } 28 | 29 | .ace_editor { 30 | font-size: 1rem !important; 31 | } 32 | .ace_fold-widget { 33 | transform: scale(2.5); 34 | } 35 | .ace_fold-widget:hover { 36 | border: none !important; 37 | } 38 | -------------------------------------------------------------------------------- /tools/serverfiles/interactive-test-server.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | Interactive pyZohoAPI Interogation 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 | Interactive pyZohoAPI Interogation 27 | 28 | 29 | 30 | 31 | 32 | Configuration 33 | 34 | 35 | Org ID: 36 | 37 | 38 | 39 | Client ID: 40 | 41 | 42 | 43 | Client Secret: 44 | 45 | 46 | 47 | Refresh Token: 48 | 49 | 50 | 51 | 52 | 53 | API-Level Query 54 | 55 | 56 | API 57 | 58 | 60 | Books 61 | Inventory 62 | 63 | 64 | 65 | 66 | Type 67 | 68 | 73 | 74 | 75 | 76 | ID 77 | 78 | 82 | 83 | 84 | 85 | Addtl Path 86 | 87 | 91 | 92 | 93 | 94 | Query Params 95 | 96 | 100 | 101 | 102 | 103 | 104 | GET 107 | 108 | 109 | https:/// 110 | 111 | 112 | 113 | 114 | 115 | Response 116 | 117 | 118 | 119 | 120 | 121 | API Information 122 | 123 | 124 | 125 | 126 | 146 | 147 | 158 | 159 | 160 | 165 | 166 | -------------------------------------------------------------------------------- /tools/serverfiles/interactive-test-server.js: -------------------------------------------------------------------------------- 1 | // This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | // Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | const param_container = document.getElementById("param_container") 5 | const select_api = document.getElementById("select_api") 6 | const select_type = document.getElementById("select_type") 7 | const input_id = document.getElementById("input_id") 8 | const input_xtrapath = document.getElementById("input_xtrapath") 9 | const input_qparams = document.getElementById("input_qparams") 10 | 11 | const results_container = document.getElementById("results_container") 12 | const results_data = document.getElementById("results") 13 | const apiinfo_container = document.getElementById("apiinfo_container") 14 | const apiinfo_data = document.getElementById("apiinfo") 15 | 16 | const formed_url = document.getElementById("formed_url") 17 | const reflected_url = document.getElementById("reflected_url") 18 | const url_api = document.getElementById("url_api") 19 | const url_type = document.getElementById("url_type") 20 | const url_id = document.getElementById("url_id") 21 | const url_xtrapath = document.getElementById("url_xtrapath") 22 | const url_qparams = document.getElementById("url_qparams") 23 | 24 | const apis = { 25 | 'books': 'books.zoho.com/api/v3', 26 | 'inventory': 'inventory.zoho.com/api/v1', 27 | } 28 | 29 | param_container.addEventListener("click", () => { 30 | if (! param_container.open) { 31 | results_container.open = false 32 | } 33 | }) 34 | 35 | function onURLChange() { 36 | url_api.innerText = apis[select_api.value] 37 | url_type.innerText = select_type.value 38 | url_id.innerText = input_id.value ? "/" + input_id.value : "" 39 | url_xtrapath.innerText = input_xtrapath.value ? "/" + input_xtrapath.value : "" 40 | url_qparams.innerText = input_qparams.value ? "?" + input_qparams.value : "" 41 | reflected_url.innerText = formed_url.textContent 42 | for (let el of document.getElementsByClassName('actionbutton')) { 43 | el.disabled = select_type.value ? false : true 44 | } 45 | } 46 | 47 | function formatResults() { 48 | if (Object.keys(rsp).length !== 0) { 49 | const edopts = { 50 | "indentation": 4, 51 | "mainMenuBar": false, 52 | "mode": "code", 53 | "sortObjectKeys": true, 54 | } 55 | const editor = new JSONEditor(results_data, edopts, rsp) 56 | // results_data.innerText = JSON.stringify(rsp, null, 4) 57 | results_container.classList.remove('hidden') 58 | param_container.open = false 59 | } else { 60 | results_container.classList.add('hidden') 61 | param_container.open = true 62 | } 63 | if (Object.keys(apirsp).length !== 0) { 64 | apiinfo_data.innerText = JSON.stringify(apirsp, null, 4) 65 | apiinfo_container.classList.remove('hidden') 66 | } else { 67 | apiinfo_container.classList.add('hidden') 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tools/test-shell.py: -------------------------------------------------------------------------------- 1 | # This file is part of pyZohoAPI, Copyright (C) Todd D. Esposito 2021. 2 | # Distributed under the MIT License (see https://opensource.org/licenses/MIT). 3 | 4 | from decimal import Decimal 5 | import simplejson as json 6 | from pprint import pprint 7 | import sys 8 | 9 | if sys.flags.interactive: 10 | import pyzohoapi 11 | import private 12 | td = private.testdata 13 | 14 | def testshell(): 15 | """Test shell loaded. Here's what you have: 16 | 17 | Modules: 18 | decimal.Decimal (as Decimal); 19 | json (simplejson, aliased to json); 20 | 21 | Functions: 22 | pprint(); 23 | show(object, key=None) -> shows a Zoho Object (or optional attribute); 24 | 25 | Objects: 26 | private.testdata -> dict, aliased to td; 27 | books -> ZohoBooks object : configured via testdata; 28 | inv -> ZohoInventory object : configured via testdata; 29 | 30 | Type `help(testshell)` to get this help again. 31 | 32 | Enjoy your testing! 33 | """ 34 | ... 35 | 36 | def show(o, key=None): 37 | """ Shows all of a Zoho object's attributes, or the attribute specified. 38 | You can use dotted notation to drill down into attributes. 39 | """ 40 | if key: 41 | pprint(o._data.to_python().get(key), indent=2) 42 | else: 43 | for k, v in o._data.to_python().items(): 44 | if isinstance(v, dict): 45 | print(f"{k}: {{...}}") 46 | elif isinstance(v, list): 47 | print(f"{k}: [...]") 48 | else: 49 | print(f"{k}: {v}") 50 | 51 | books = pyzohoapi.books.ZohoBooks(td['orgid'], td['region'], **td['api']) 52 | inv = pyzohoapi.inventory.ZohoInventory(td['orgid'], td['region'], **td['api']) 53 | 54 | help(testshell) 55 | else: 56 | print("\nThis script bootstraps an interactive test enviroment, but only this way:") 57 | print(f"\t> python -i {__file__}") 58 | print("Otherwise, it's all for naught.\n") 59 | --------------------------------------------------------------------------------
Org ID:
Client ID:
Client Secret:
Refresh Token:
API
Type
ID
Addtl Path
Query Params
https:///
118 |
123 |