diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9410c79 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203,E501 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..cfbcc38 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,34 @@ +name: cd +on: + push: + branches: [main] + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + release-type: python + package-name: netsuite + bump-minor-pre-major: true + cd: + runs-on: ubuntu-latest + needs: [release-please] + if: needs.release-please.outputs.release_created + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: '3.10' + architecture: x64 + - uses: abatilo/actions-poetry@v2.1.4 + with: + poetry-version: '1.1.13' + - run: poetry build + - run: poetry publish + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..183b7bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + unittests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ['3.10', '3.9', '3.8', '3.7'] + extras: ['', all, soap_api, orjson, cli] + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: '${{ matrix.python-version }}' + architecture: x64 + - uses: abatilo/actions-poetry@v2.1.4 + with: + poetry-version: '1.1.13' + - run: poetry install --extras ${{ matrix.extras }} + if: matrix.extras != '' + - run: poetry install + if: matrix.extras == '' + - run: poetry run pytest -v + + style: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10'] + extras: [all] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: '${{ matrix.python-version }}' + architecture: x64 + - uses: abatilo/actions-poetry@v2.1.4 + with: + poetry-version: '1.1.13' + - run: poetry install --extras ${{ matrix.extras }} + - run: poetry run flake8 + - run: poetry run mypy --ignore-missing-imports . + - run: poetry run isort --check --diff . + - run: poetry run black --check --diff . diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..a1f665d --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,24 @@ +name: codecov + +on: + push: + branches: [main] + pull_request: + +jobs: + code_coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: '3.10' + architecture: x64 + - uses: abatilo/actions-poetry@v2.1.4 + with: + poetry-version: '1.1.13' + - run: poetry install --extras all + - run: poetry run pytest --cov=netsuite --cov-report=xml --cov-report=term + - uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..65bb96b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,23 @@ +name: docs +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: '3.9' + architecture: x64 + - uses: abatilo/actions-poetry@v2.1.4 + with: + poetry-version: '1.1.13' + - run: poetry install --extras all + - run: poetry run mkdocs build + - uses: peaceiris/actions-gh-pages@v3.7.3 + with: + github_token: "${{ secrets.GITHUB_TOKEN }}" + publish_dir: ./site diff --git a/.gitignore b/.gitignore index 743eb82..674fd32 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ /Pipfile.lock /.mypy* /.pytest_cache +/poetry.lock +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2009a93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +exclude: '{{project_slug}}' + +default_language_version: + python: python3 + +repos: + - repo: https://gitlab.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.960 + hooks: + - id: mypy diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 96c5cdb..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -sudo: required -dist: xenial -language: python -cache: pip - -matrix: - include: - - python: "3.6" - env: TOX_ENV=py36 - - python: "3.7" - env: TOX_ENV=py37 - - python: "3.6" - env: TOX_ENV=lint - -install: - - pip install tox - -script: tox -e $TOX_ENV - -after_success: - # Submit data from .coverage to coveralls on success - - pip install coveralls - - coveralls diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..83d19f1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.formatting.provider": "black", + "files.exclude": { + "poetry.lock": true + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 index 8d3d837..d2f56b2 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,49 +1,185 @@ # Changelog -## 0.4.0 (2019-04-29) +## [0.9.0](https://github.com/jacobsvante/netsuite/compare/v0.8.0...v0.9.0) (2022-06-02) -* Enhancement: Add support for specifying operation/request timeouts -* Enhancement: Throw an exception if Suitetalk returns a response error -## 0.3.2 (2019-04-11) +### Bug Fixes -* Feature: Add support for `update` and `search` operations +* Async connections sessions were being closed ([28e2b8e](https://github.com/jacobsvante/netsuite/commit/28e2b8e387ae4d30d65540901714f69a0b6248ab)) +* Don't require existing config to check version ([b9982bf](https://github.com/jacobsvante/netsuite/commit/b9982bf23ebf051ba994c0c1f8b0ba3f88b31cbc)) +* Regression in `openapi serve` CLI command ([c0074a0](https://github.com/jacobsvante/netsuite/commit/c0074a08edbef352b8b707ae1a6a7a21a25ce3d1)) +* Update dependencies ([1956b4d](https://github.com/jacobsvante/netsuite/commit/1956b4db748dd321fe310c3542f3e29c0f2161eb)), closes [#39](https://github.com/jacobsvante/netsuite/issues/39) [#40](https://github.com/jacobsvante/netsuite/issues/40) -## 0.3.1 (2019-04-11) -* Enhancement: Decrease restlet request time on subsequent requests by half by re-using the OAuth session +### Documentation -## 0.3.0 (2019-04-10) +* Add example project for soap requests ([352bdd6](https://github.com/jacobsvante/netsuite/commit/352bdd6c9c63d1d2f9b0107921d35ca690dad82d)) -* Feature: Added support for making requests to restlets -* Feature: New command to utilize the new restlet request capability -* Info: Removed `requests-ntlm` dependency which was never used -* Info: Don't specify `lxml` as a dependency. Implicitly take dependency from `zeep` instead. -* Info: Document usage of CLI utils -## 0.2.2 (2018-12-11) +### Continuous Integration -* Feature: Added `get`, `getAll`, `add`, `upsert` and `upsertList` methods. Big thanks go out to @matmunn for the original PR. (#6) +* Fix yaml miss ([9180897](https://github.com/jacobsvante/netsuite/commit/91808971e3ab737ab0cd5bde8eda6a5b04bdd2e3)) -## 0.2.1 (2018-12-11) +## [0.8.0](https://github.com/jacobsvante/netsuite/compare/v0.7.0...v0.8.0) - 2021-04-28 -* Feature: Helper `NetSuite.to_builtin` to convert zeep objects to python builtins -* Feature: Add `lastQtyAvailableChange` filter +### Changed +- Default signing method is now HMAC-SHA256 for REST API and Restlet. The old default of HMAC-SHA1 can be set via the `signature_method` keyword argument. (Thanks to @zerodarkzone, issue #27) -## 0.2.0 (2018-12-11) +### Added +- HMAC-SHA256 signing method when making requests to REST API and Restlets +- Dependency `oauthlib` -* Breaking change: Sandbox is now configured through account ID, `sandbox` flag is now a no-op -* Breaking change: New default version is 2018.1.0 -* Breaking change: Account specific domains are now used when `wsdl_url` is left unspecified -* Feature: Support regular credentials Passport -* Info: Listing Python 3.7 as a supported version +## [0.7.0](https://github.com/jacobsvante/netsuite/compare/v0.6.3...v0.7.0) - 2021-04-27 -## 0.1.1 (2018-04-02) +This release breaks a lot of things. Please read carefully. -* Fix: `getItemAvailability` only read first passed in external/internal ID -* Feature: Allow overriding global NS preferences through SOAP headers +### Changed +- SOAP and Restlet APIs are now async (i.e. this library is no longer useable in a non-async environment) +- The `netsuite.client.NetSuite` class is now just a thin layer around each of the different API types (SOAP, REST, Restlets) +- SOAP Web Services support is no longer included in the default install, please use `netsuite[soap_api]` (unfortunately `zeep` pulls in a lot of other dependencies, so I decided to remove it by default) +- `netsuite.restlet.NetsuiteRestlet` has been renamed to `netsuite.restlet.NetSuiteRestlet` +- Move NetSuite version to 2021.1 +- Upgrade to httpx ~0.18 -## 0.1.0 (2018-03-29) +### Added +- `netsuite.restlet.NetSuiteRestlet` now support all four HTTP verbs GET, POST, PUT & DELETE via dedicated functions `.get`, `.post`, `.put` & `.delete` +- REST API and Restlet are now supported with the default install +- CLI now has a new sub-command `soap-api`, which currently only support `get` and `getList` +- Dependency [pydantic](https://pydantic-docs.helpmanual.io/) has been added to help with config validation -* Initial version. Support for `getList` and `getItemAvailability` -* Please note that there is currently no handling for error responses from the API. TODO! +### Removed +- Authentication via User credentials has been removed (will no longer work from NetSuite 2021.2 release anyway) +- `netsuite.restlet.NetSuiteRestApi.request` and `netsuite.restlet.NetSuiteRestlet.request` no longer exists - use each dedicated "verb method" instead +- Removed dead code for setting SOAP preferences +- CLI sub-command aliases `i` (interact) and `r` (rest-api) have been removed to avoid confusion + +## [0.6.3](https://github.com/jacobsvante/netsuite/compare/v0.6.2...v0.6.3) - 2021-04-26 + +### Added +- Ability to supply custom headers to REST API requests made from CLI via "-H/--header" flag +- Support custom payload, headers and params in suiteql REST API method + +## [0.6.2](https://github.com/jacobsvante/netsuite/compare/v0.6.1...v0.6.2) - 2021-04-25 + +### Fixed +- `NetSuiteRestApi` no longer requires a running asyncio loop to be instantiated + +## [0.6.1](https://github.com/jacobsvante/netsuite/compare/v0.6.0...v0.6.1) - 2021-04-25 + +### Fixed +- Fix "local variable 'record_ref' referenced before assignment" error in `NetSuite.get` method - Thanks @VeNoMouS! (#25) + +## [0.6.0] - 2021-04-25 + +### Fixed +- Release 2021.1 wouldn't accept non-GET requests to the REST API without body being part of the signing. Thanks to @mmangione for the PR! (#26) + +### Added +- Documentation site + +### Removed +- Python 3.6 support + +### Changed +- Upgrade to httpx ~0.17 +- Use poetry for package management +- Move to Github Actions + +## [0.5.3] - 2020-05-26 + +### Fixed +- Couldn't import `netsuite` unless `httpx` was installed. Fixes #18 + +## [0.5.2] - 2020-05-02 + +### Fixed +- Only forward explicitly passed in parameters for `netsuite rest-api get` command. Fixes error `Invalid query parameter name: limit. Allowed query parameters are: fields, expand, expandSubResources.` + +### Added +- Ability to have `netsuite rest-api get` only return a given list of fields via `--fields` +- Ability for `netsuite rest-api get` to only expand a given set of sublist and subrecord types via `--expand` + +## [0.5.1] - 2020-05-02 + +### Changed +- HTML title in OpenAPI Swagger docs + +## [0.5.0] - 2020-05-02 + +### Added +- Support for SuiteTalk REST Web Services, including standard GET, POST, PATCH, PUT, DELETE requests as well as making SuiteQL queries. For now it's an optional dependency (install with `pip install netsuite[rest_api]`) +- Start a HTTP server via command line to browse REST API OpenAPI spec docs for a given set of records (utilizes Swagger UI) + +### Changed +- `--log-level`, `--config-path` and `--config-section` must now be passed directly to the `netsuite` command, and not its sub-commands. + +## [0.4.1] - 2020-03-09 + +### Changed +- Extend Zeep Transport GET and POST HTTP methods to apply the account-specific dynamic domain as the remote host +- Update the NetSuite WSDL compatibility to 2019.2 + +## [0.4.0] - 2019-04-29 + +### Added +- Support for specifying operation/request timeouts + +### Changed +- Changed: Throw an exception if Suitetalk returns a response error + +## [0.3.2] - 2019-04-11 + +### Added +- Support for `update` and `search` operations + +## [0.3.1] - 2019-04-11 + +### Changed +- Decrease restlet request time on subsequent requests by half by re-using the OAuth session + +## [0.3.0] - 2019-04-10 + +### Added +- Support for making requests to restlets +- New command to utilize the new restlet request capability +- Added: Document usage of CLI utils +### Removed +- `requests-ntlm` dependency which was never used + +### Changed +- Don't specify `lxml` as a dependency. Implicitly take dependency from `zeep` instead. + +## [0.2.2] - 2018-12-11 + +### Added +- `get`, `getAll`, `add`, `upsert` and `upsertList` methods. Big thanks go out to @matmunn for the original PR. (#6) + +## [0.2.1] - 2018-12-11 + +### Added +- Helper `NetSuite.to_builtin` to convert zeep objects to python builtins +- `lastQtyAvailableChange` filter + +## [0.2.0] - 2018-12-11 + +### Changed +- Sandbox is now configured through account ID, `sandbox` flag is now a no-op +- New default version is 2018.1.0 +- Account specific domains are now used when `wsdl_url` is left unspecified + +### Added +- Support regular credentials Passport +- Listing Python 3.7 as a supported version + +## [0.1.1] - 2018-04-02 + +### Fixed +- `getItemAvailability` only read first passed in external/internal ID + +### Added +- Allow overriding global NS preferences through SOAP headers + +## [0.1.0] - 2018-03-29 + +- Initial version. Support for `getList` and `getItemAvailability` +- Please note that there is currently no handling for error responses from the API diff --git a/README.md b/README.md index 255a9f3..aadc955 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,41 @@ # netsuite -[![Travis CI build status (Linux)](https://travis-ci.org/jmagnusson/netsuite.svg?branch=master)](https://travis-ci.org/jmagnusson/netsuite) +[![Continuous Integration Status](https://github.com/jacobsvante/netsuite/actions/workflows/ci.yml/badge.svg)](https://github.com/jacobsvante/netsuite/actions/workflows/ci.yml) +[![Continuous Delivery Status](https://github.com/jacobsvante/netsuite/actions/workflows/cd.yml/badge.svg)](https://github.com/jacobsvante/netsuite/actions/workflows/cd.yml) +[![Code Coverage](https://img.shields.io/codecov/c/github/jacobsvante/netsuite?color=%2334D058)](https://codecov.io/gh/jacobsvante/netsuite) [![PyPI version](https://img.shields.io/pypi/v/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) [![License](https://img.shields.io/pypi/l/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![Available as wheel](https://img.shields.io/pypi/wheel/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) +[![Python Versions](https://img.shields.io/pypi/pyversions/netsuite.svg)](https://pypi.org/project/netsuite/) [![PyPI status (alpha/beta/stable)](https://img.shields.io/pypi/status/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -Make requests to NetSuite Web Services and Restlets +Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets -## Installation - -Programmatic use only: - - pip install netsuite +## Beta quality disclaimer -With CLI support: - - pip install netsuite[cli] +The project's API is still very much in fluctuation. Please consider pinning your dependency to this package to a minor version (e.g. `poetry add netsuite~0.9` or `pipenv install netsuite~=0.9.0`), which is guaranteed to have no breaking changes. From 1.0 and forward we will keep a stable API. +## Installation -## CLI - -### Configuration - -To use the command line utilities you must add a config file with a section in this format: +With default features (REST API + Restlet support): -```ini -[netsuite] -auth_type = token -account = 123456 -consumer_key = 789123 -consumer_secret = 456789 -token_id = 012345 -token_secret = 678901 -``` + pip install netsuite -You can add multiple sections like this. The `netsuite` section will be read by default, but can be overridden using the `-c` flag. +With Web Services SOAP API support: -The default location that will be read is `~/.config/netsuite.ini`. This can overriden with the `-p` flag. + pip install netsuite[soap_api] -Append `--help` to the commands to see full documentation. +With CLI support: -### `restlet` - Make requests to restlets + pip install netsuite[cli] -``` -$ echo '{"savedSearchId": 987}' | netsuite restlet 123 - -``` +With `orjson` package (faster JSON handling): + pip install netsuite[orjson] -### `interact` - Interact with web services and/or restlets +With all features: -``` -$ netsuite interact -Welcome to Netsuite WS client interactive mode -Available vars: - `ns` - NetSuite client + pip install netsuite[all] -Example usage: - results = ns.getList('customer', internalIds=[1337]) +## Documentation -In [1]: -``` +Is found here: https://jacobsvante.github.io/netsuite/ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..22bad13 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,216 @@ +# netsuite + +Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets + +## Installation + +With default features (REST API + Restlet support): + + pip install netsuite + +With Web Services SOAP API support: + + pip install netsuite[soap_api] + +With CLI support: + + pip install netsuite[cli] + +With `orjson` package (faster JSON handling): + + pip install netsuite[orjson] + +With all features: + + pip install netsuite[all] + + +## Programmatic use + +```python +import asyncio + +from netsuite import NetSuite, Config, TokenAuth + +config = Config( + account="12345", + auth=TokenAuth(consumer_key="abc", consumer_secret="123", token_id="xyz", token_secret="456"), +) + +ns = NetSuite(config) + + +async def async_main(): + rest_api_results = await ns.rest_api.get("/record/v1/salesOrder") + + restlet_results = await ns.restlet.get(987, deploy=2) + + # NOTE: SOAP needs `pip install netsuite[soap_api]` + soap_api_results = await ns.soap_api.getList('customer', internalIds=[1337]) + + # Multiple requests, using the same underlying connection + async with ns.soap_api: + customers = await ns.soap_api.getList('customer', internalIds=[1, 2, 3]) + sales_orders = await ns.soap_api.getList('salesOrder', internalIds=[1, 2]) + +if __name__ == "__main__": + asyncio.run(async_main()) + +``` + +## CLI + +### Configuration + +To use the command line utilities you must add a config file with a section in this format: + +```ini +[netsuite] +auth_type = token +account = 123456 +consumer_key = 789123 +consumer_secret = 456789 +token_id = 012345 +token_secret = 678901 +``` + +You can add multiple sections like this. The `netsuite` section will be read by default, but can be overridden using the `-c` flag. + +The default location that will be read is `~/.config/netsuite.ini`. This can overriden with the `-p` flag. + +Append `--help` to the commands to see full documentation. + +### `rest-api` - Make requests to NetSuite REST API + +See the NetSuite help center for info on how to use the REST API. The `netsuite rest-api openapi-serve` command is also a big help. + +#### `netsuite rest-api get` + +List endpoint examples: + +``` +$ netsuite rest-api get /record/v1/customer +``` + +``` +$ netsuite rest-api get /record/v1/invoice --limit 10 --offset 30 +``` + +``` +$ netsuite rest-api get /record/v1/salesOrder --query 'email IS "john.doe@example.com"' +``` + +Detail endpoint examples: + +``` +$ netsuite rest-api get /record/v1/salesOrder/1337 +``` + +``` +$ netsuite rest-api get /record/v1/invoice/123 --expandSubResources +``` + +#### `netsuite rest-api post` + +Examples: +``` +$ cat ~/customer-no-1-data.json | netsuite rest-api post /record/v1/customer - +``` + +#### `netsuite rest-api put` + +Examples: +``` +$ cat ~/customer-no-1-data.json | netsuite rest-api put /record/v1/customer/123 - +``` + +#### `netsuite rest-api patch` + +Examples: +``` +$ cat ~/changed-customer-data.json | netsuite rest-api patch /record/v1/customer/123 - +``` + +#### `netsuite rest-api delete` + +Examples: +``` +$ netsuite rest-api delete /record/v1/customer/123 +``` + +#### `netsuite rest-api jsonschema` + +Examples: +``` +$ netsuite rest-api jsonschema salesOrder +{"type":"object","properties":... +``` + +#### `netsuite rest-api openapi` + +Examples: +``` +$ netsuite rest-api openapi salesOrder customer invoice +{"openapi":"3.0.1","info":{"title":"NetSuite REST Record API"... +``` + + +#### `netsuite rest-api openapi-serve` + +Start a server that fetches and lists the OpenAPI spec for the given record types, using [Swagger UI](https://swagger.io/tools/swagger-ui/). Defaults to port 8000. + +Examples: + +``` +$ netsuite rest-api openapi-serve customer salesOrder +INFO:netsuite:Fetching OpenAPI spec for record types customer, salesOrder... +INFO:netsuite:NetSuite REST API docs available at http://127.0.0.1:8001 +``` + +It's also possible to fetch the OpenAPI spec for all known record types. This will however take a long time (60+ seconds). +``` +$ netsuite rest-api openapi-serve +WARNING:netsuite:Fetching OpenAPI spec for ALL known record types... This will take a long time! (Consider providing only the record types of interest by passing their names to this command as positional arguments) +INFO:netsuite:NetSuite REST API docs available at http://127.0.0.1:8001 +``` + + +### `interact` - Interact with SOAP/REST web services and restlets + +Starts an IPython REPL where you can interact with the client. + +``` +$ netsuite interact +Welcome to Netsuite WS client interactive mode +Available vars: + `ns` - NetSuite client + +Example usage: + soap_api_results = ns.soap_api.getList('customer', internalIds=[1337]) + rest_api_results = await ns.rest_api.get("/record/v1/salesOrder") + restlet_results = await ns.restlet.get(987, deploy=2) + +In [1]: rest_api_results = await ns.rest_api.get(" +``` + + +### `restlet` - Make requests to restlets + +``` +$ echo '{"savedSearchId": 987}' | netsuite restlet 123 - +``` + +## Developers + +To run the tests, do: + +1. Install Poetry (https://python-poetry.org/docs/) +1. Install dependencies `poetry install --extras all` +1. Run tests: `poetry run pytest` + +Before committing and publishing a pull request, do: + +1. Install pre-commit globally: `pip install pre-commit` +1. Run `pre-commit install` to install the Git hook + +[pre-commit](https://pre-commit.com/) will ensure that all code is formatted per our conventions. Failing to run this will probably make the CI tests fail in the PR instead. diff --git a/examples/soap-api.py b/examples/soap-api.py new file mode 100644 index 0000000..d6b2c8f --- /dev/null +++ b/examples/soap-api.py @@ -0,0 +1,34 @@ +import argparse +import asyncio + +from netsuite import Config +from netsuite.constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION +from netsuite.soap_api.client import NetSuiteSoapApi + +parser = argparse.ArgumentParser() +parser.add_argument("-p", "--config-path", default=DEFAULT_INI_PATH, dest="path") +parser.add_argument( + "-c", "--config-section", default=DEFAULT_INI_SECTION, dest="section" +) + + +async def main(): + args = parser.parse_args() + + config = Config.from_ini(**vars(args)) + api = NetSuiteSoapApi(config) + + resp1 = await api.getList("customer", internalIds=[1]) + print(resp1) + + # Re-use same connection + async with api: + resp2 = await api.getList("salesOrder", internalIds=[1]) + resp3 = await api.getList("salesOrder", internalIds=[2]) + + print(resp2) + print(resp3) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..34f8950 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,3 @@ +site_name: netsuite +theme: + name: material diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..51b77d7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +show_error_codes = True +ignore_missing_imports = True diff --git a/netsuite/__init__.py b/netsuite/__init__.py index d183cf7..8c4c9e0 100644 --- a/netsuite/__init__.py +++ b/netsuite/__init__.py @@ -1,6 +1,6 @@ -import logging - from . import constants # noqa -from .client import NetSuite # noqa - -logger = logging.getLogger(__name__) +from .client import * # noqa +from .config import * # noqa +from .rest_api import * # noqa +from .restlet import * # noqa +from .soap_api import * # noqa diff --git a/netsuite/__main__.py b/netsuite/__main__.py deleted file mode 100644 index d69fe27..0000000 --- a/netsuite/__main__.py +++ /dev/null @@ -1,95 +0,0 @@ -import json -import sys -import logging -import logging.config - -import IPython -import argh -import traitlets - -import netsuite -from netsuite import config -from netsuite.constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION - - -def _set_log_level(log_level): - if log_level is not None: - level = getattr(logging, log_level.upper()) - logging.basicConfig() - logging.getLogger('zeep').setLevel(level) - netsuite.logger.setLevel(level) - - -@argh.arg('-l', '--log-level', help='The log level to use') -@argh.arg('-p', '--config-path', help='The config file to get settings from') -@argh.arg('-c', '--config-section', help='The config section to get settings from') -def interact( - log_level=None, - config_path=DEFAULT_INI_PATH, - config_section=DEFAULT_INI_SECTION -): - """Starts a REPL to enable live interaction with NetSuite webservices""" - _set_log_level(log_level) - - conf = config.from_ini(path=config_path, section=config_section) - - ns = netsuite.NetSuite(conf) - - user_ns = {'ns': ns} - - banner1 = """Welcome to Netsuite WS client interactive mode -Available vars: - `ns` - NetSuite client - -Example usage: - ws_results = ns.getList('customer', internalIds=[1337]) - restlet_results = ns.restlet.request(987) -""" - - IPython.embed( - user_ns=user_ns, - banner1=banner1, - config=traitlets.config.Config(colors='LightBG'), - # To fix no colored input we pass in `using=False` - # See: https://github.com/ipython/ipython/issues/11523 - # TODO: Remove once this is fixed upstream - using=False, - ) - - -@argh.arg('-l', '--log-level', help='The log level to use') -@argh.arg('-p', '--config-path', help='The config file to get settings from') -@argh.arg('-c', '--config-section', help='The config section to get settings from') -def restlet( - script_id, - payload, - deploy=1, - log_level=None, - config_path=DEFAULT_INI_PATH, - config_section=DEFAULT_INI_SECTION -): - """Make requests to restlets""" - - _set_log_level(log_level) - conf = config.from_ini(path=config_path, section=config_section) - ns = netsuite.NetSuite(conf) - - if not payload: - payload = None - elif payload == '-': - payload = json.load(sys.stdin) - else: - payload = json.loads(payload) - - resp = ns.restlet.raw_request( - script_id=script_id, - deploy=deploy, - payload=payload, - raise_on_bad_status=False, - ) - return resp.text - - -command_parser = argh.ArghParser() -command_parser.add_commands([interact, restlet]) -main = command_parser.dispatch diff --git a/netsuite/cli/__init__.py b/netsuite/cli/__init__.py new file mode 100644 index 0000000..38f3549 --- /dev/null +++ b/netsuite/cli/__init__.py @@ -0,0 +1 @@ +from .main import * # noqa diff --git a/netsuite/cli/helpers.py b/netsuite/cli/helpers.py new file mode 100644 index 0000000..ff153dd --- /dev/null +++ b/netsuite/cli/helpers.py @@ -0,0 +1,19 @@ +import argparse + +from ..config import Config + +__all__ = () + + +def load_config_or_error(parser: argparse.ArgumentParser, path: str, section: str) -> Config: # type: ignore[return] + try: + conf = Config.from_ini(path=path, section=section) + except FileNotFoundError: + parser.error(f"Config file {path} not found") + except KeyError as ex: + if ex.args == (section,): + parser.error(f"No config section `{section}` in file {path}") + else: + raise ex + else: + return conf diff --git a/netsuite/cli/interact.py b/netsuite/cli/interact.py new file mode 100644 index 0000000..a384941 --- /dev/null +++ b/netsuite/cli/interact.py @@ -0,0 +1,41 @@ +import IPython +import traitlets + +from ..client import NetSuite + +__all__ = () + + +def add_parser(parser, subparser): + interact_parser = subparser.add_parser( + "interact", + description="Starts a REPL to enable live interaction with NetSuite webservices", + ) + interact_parser.set_defaults(func=interact) + return (interact_parser, None) + + +def interact(config, args): + ns = NetSuite(config) + + user_ns = {"ns": ns} + + banner1 = """Welcome to Netsuite WS client interactive mode +Available vars: + `ns` - NetSuite client + +Example usage: + soap_results = await ns.soap_api.getList('customer', internalIds=[1337]) + restlet_results = await ns.restlet.get(987, deploy=2) + rest_api_results = await ns.rest_api.get("/record/v1/salesOrder") +""" + + IPython.embed( + user_ns=user_ns, + banner1=banner1, + config=traitlets.config.Config(colors="LightBG"), + # To fix no colored input we pass in `using=False` + # See: https://github.com/ipython/ipython/issues/11523 + # TODO: Remove once this is fixed upstream + using=False, + ) diff --git a/netsuite/cli/main.py b/netsuite/cli/main.py new file mode 100644 index 0000000..6e381c2 --- /dev/null +++ b/netsuite/cli/main.py @@ -0,0 +1,81 @@ +import argparse +import asyncio +import inspect +import logging +import sys + +from ..constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION +from . import helpers, interact, misc, rest_api, restlet, soap_api + +__all__ = ("main",) + + +def main(): + + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-l", + "--log-level", + help="The log level to use", + default="INFO", + choices=("DEBUG", "INFO", "WARNING", "ERROR"), + ) + parser.add_argument( + "-p", + "--config-path", + help="The config file to get settings from", + default=DEFAULT_INI_PATH, + ) + parser.add_argument( + "-c", + "--config-section", + help="The config section to get settings from", + default=DEFAULT_INI_SECTION, + ) + + subparser = parser.add_subparsers(help="App CLI", required=True) + + misc.add_parser(parser, subparser) + interact.add_parser(parser, subparser) + restlet_parser, _ = restlet.add_parser(parser, subparser) + rest_api_parser, _ = rest_api.add_parser(parser, subparser) + soap_api_parser, _ = soap_api.add_parser(parser, subparser) + + try: + args = parser.parse_args() + except Exception: + parser.print_help() + return + + subparser_name = sys.argv[-1] + + # Call version directly to avoid loading of config + if subparser_name == "version": + print(args.func()) + return + + # Show help section instead of an error when no arguments were passed... + if subparser_name == "rest-api": + rest_api_parser.print_help() + return + elif subparser_name == "soap-api": + rest_api_parser.print_help() + return + elif subparser_name == "restlet": + restlet_parser.print_help() + return + + config = helpers.load_config_or_error(parser, args.config_path, args.config_section) + + log_level = getattr(logging, args.log_level) + logging.basicConfig(level=log_level) + + ret = args.func(config, args) + + if inspect.iscoroutinefunction(args.func): + ret = asyncio.run(ret) + + if ret is not None: + print(ret) diff --git a/netsuite/cli/misc.py b/netsuite/cli/misc.py new file mode 100644 index 0000000..41cda27 --- /dev/null +++ b/netsuite/cli/misc.py @@ -0,0 +1,13 @@ +import pkg_resources + +__all__ = () + + +def add_parser(parser, subparser): + version_parser = subparser.add_parser("version") + version_parser.set_defaults(func=version) + return (version_parser, None) + + +def version() -> str: + return pkg_resources.get_distribution("netsuite").version diff --git a/netsuite/cli/rest_api.py b/netsuite/cli/rest_api.py new file mode 100644 index 0000000..626c6f6 --- /dev/null +++ b/netsuite/cli/rest_api.py @@ -0,0 +1,378 @@ +import argparse +import functools +import http.server +import logging +import logging.config +import pathlib +import tempfile +from typing import Dict, List, Optional, Union + +from .. import json +from ..client import NetSuite +from ..config import Config + +logger = logging.getLogger("netsuite") + +ParsedHeaders = Dict[str, Union[List[str], str]] + +__all__ = () + + +def add_parser(parser, subparser): + rest_api_parser = subparser.add_parser("rest-api") + rest_api_subparser = rest_api_parser.add_subparsers() + _add_rest_api_get_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_post_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_put_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_patch_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_delete_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_suiteql_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_jsonschema_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_openapi_parser(rest_api_parser, rest_api_subparser) + _add_rest_api_openapi_serve_parser(rest_api_parser, rest_api_subparser) + + return (rest_api_parser, rest_api_subparser) + + +def _add_rest_api_get_parser(parser, subparser): + async def rest_api_get(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + params = {} + if args.expandSubResources is True: + params["expandSubResources"] = "true" + if args.limit is not None: + params["limit"] = args.limit + if args.offset is not None: + params["offset"] = args.offset + if args.fields is not None: + params["fields"] = ",".join(args.fields) + if args.expand is not None: + params["expand"] = ",".join(args.expand) + if args.query is not None: + params["q"] = args.query + resp = await rest_api.get( + args.subpath, params=params, headers=_parse_headers_arg(parser, args.header) + ) + return json.dumps(resp) + + p = subparser.add_parser( + "get", description="Make a GET request to NetSuite REST web services" + ) + p.set_defaults(func=rest_api_get) + p.add_argument( + "subpath", + help="The subpath to GET, e.g. `/record/v1/salesOrder`", + ) + p.add_argument( + "-q", + "--query", + help="Search query used to filter results. See NetSuite help center for syntax information. Only works for list endpoints e.g. /record/v1/customer", + ) + p.add_argument( + "-e", + "--expandSubResources", + action="store_true", + help="Automatically expand all sublists, sublist lines and subrecords on this record. Only works for detail endpoints e.g. /record/v1/invoice/123.", + ) + p.add_argument("-l", "--limit", type=int) + p.add_argument("-o", "--offset", type=int) + p.add_argument( + "-f", + "--fields", + metavar="field", + nargs="*", + help="Only include the given fields in response", + ) + p.add_argument( + "-E", + "--expand", + nargs="*", + help="Expand the given sublist lines and subrecords on this record. Only works for detail endpoints e.g. /record/v1/invoice/123.", + ) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_post_parser(parser, subparser): + async def rest_api_post(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + with args.payload_file as fh: + payload_str = fh.read() + + payload = json.loads(payload_str) + + resp = await rest_api.post( + args.subpath, json=payload, headers=_parse_headers_arg(parser, args.header) + ) + return json.dumps(resp) + + p = subparser.add_parser( + "post", description="Make a POST request to NetSuite REST web services" + ) + p.set_defaults(func=rest_api_post) + p.add_argument( + "subpath", + help="The subpath to POST to, e.g. `/record/v1/salesOrder`", + ) + p.add_argument("payload_file", type=argparse.FileType("r")) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_put_parser(parser, subparser): + async def rest_api_put(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + with args.payload_file as fh: + payload_str = fh.read() + + payload = json.loads(payload_str) + + resp = await rest_api.put( + args.subpath, json=payload, headers=_parse_headers_arg(parser, args.header) + ) + return json.dumps(resp) + + p = subparser.add_parser( + "put", description="Make a PUT request to NetSuite REST web services" + ) + p.set_defaults(func=rest_api_put) + p.add_argument( + "subpath", + help="The subpath to PUT to, e.g. `/record/v1/salesOrder/eid:abc123`", + ) + p.add_argument("payload_file", type=argparse.FileType("r")) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_patch_parser(parser, subparser): + async def rest_api_patch(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + with args.payload_file as fh: + payload_str = fh.read() + + payload = json.loads(payload_str) + + resp = await rest_api.patch( + args.subpath, json=payload, headers=_parse_headers_arg(parser, args.header) + ) + return json.dumps(resp) + + p = subparser.add_parser( + "patch", description="Make a PATCH request to NetSuite REST web services" + ) + p.set_defaults(func=rest_api_patch) + p.add_argument( + "subpath", + help="The subpath to PATCH to, e.g. `/record/v1/salesOrder/eid:abc123`", + ) + p.add_argument("payload_file", type=argparse.FileType("r")) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_delete_parser(parser, subparser): + async def rest_api_delete(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + resp = await rest_api.delete( + args.subpath, headers=_parse_headers_arg(parser, args.header) + ) + return json.dumps(resp) + + p = subparser.add_parser( + "delete", description="Make a DELETE request to NetSuite REST web services" + ) + p.set_defaults(func=rest_api_delete) + p.add_argument( + "subpath", + help="The subpath for the DELETE request, e.g. `/record/v1/salesOrder/eid:abc123`", + ) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_suiteql_parser(parser, subparser): + async def rest_api_suiteql(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + + with args.q_file as fh: + q = fh.read() + + resp = await rest_api.suiteql( + q=q, + limit=args.limit, + offset=args.offset, + headers=_parse_headers_arg(parser, args.header), + ) + + return json.dumps(resp) + + p = subparser.add_parser( + "suiteql", description="Make a SuiteQL request to NetSuite REST web services" + ) + p.set_defaults(func=rest_api_suiteql) + p.add_argument( + "q_file", type=argparse.FileType("r"), help="File containing a SuiteQL query" + ) + p.add_argument("-l", "--limit", type=int, default=10) + p.add_argument("-o", "--offset", type=int, default=0) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_jsonschema_parser(parser, subparser): + async def rest_api_jsonschema(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + resp = await rest_api.jsonschema(args.record_type) + return json.dumps(resp) + + p = subparser.add_parser( + "jsonschema", description="Retrieve JSON Schema for the given record type" + ) + p.set_defaults(func=rest_api_jsonschema) + p.add_argument("record_type", help="The record type to get JSONSchema spec for") + _add_rest_api_headers_arg(p) + + +def _add_rest_api_openapi_parser(parser, subparser): + async def rest_api_openapi(config, args) -> str: + rest_api = _get_rest_api_or_error(parser, config) + resp = await rest_api.openapi(args.record_types) + return json.dumps(resp) + + p = subparser.add_parser( + "openapi", + description="Retrieve OpenAPI spec for the given record types", + ) + p.set_defaults(func=rest_api_openapi) + p.add_argument( + "record_types", + metavar="record_type", + nargs="+", + help="The record type(s) to get OpenAPI spec for", + ) + _add_rest_api_headers_arg(p) + + +def _add_rest_api_openapi_serve_parser(parser, subparser): + async def rest_api_openapi_serve(config, args): + rest_api = _get_rest_api_or_error(parser, config) + if len(args.record_types) == 0: + logger.warning( + "Fetching OpenAPI spec for ALL known record types... This will take a long " + "time! (Consider providing only the record types of interest by passing " + "their names to this command as positional arguments)" + ) + else: + rt_str = ", ".join(args.record_types) + logger.info(f"Fetching OpenAPI spec for record types {rt_str}...") + spec = await rest_api.openapi(args.record_types) + tempdir = pathlib.Path(tempfile.mkdtemp()) + openapi_file = tempdir / "openapi.json" + html_file = tempdir / "index.html" + openapi_file.write_bytes(json.dumps(spec).encode("utf-8")) + html = """ + + + + NetSuite REST Record API + + +
+
+ + + + + """ + html_file.write_text(html) + handler_class = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=str(tempdir), + ) + logger.info( + f"NetSuite REST Record API docs available at http://{args.bind}:{args.port}" + ) + try: + http.server.test( + HandlerClass=handler_class, + ServerClass=http.server.ThreadingHTTPServer, + port=args.port, + bind=args.bind, + ) + finally: + html_file.unlink() + openapi_file.unlink() + tempdir.rmdir() + + p = subparser.add_parser( + "openapi-serve", + description="Start a HTTP server on localhost serving the OpenAPI spec via Swagger UI", + ) + p.set_defaults(func=rest_api_openapi_serve) + p.add_argument( + "record_types", + metavar="record_type", + nargs="*", + help="The record type(s) to get OpenAPI spec for. If not provided the OpenAPI spec for all known record types will be retrieved.", + ) + p.add_argument("-p", "--port", default=8000, type=int, help="The port to listen to") + p.add_argument("-b", "--bind", default="127.0.0.1", help="The host to bind to") + + +def _get_rest_api_or_error(parser, config: Config): + ns = NetSuite(config) + + try: + return ns.rest_api # Cached property that initializes NetSuiteRestApi + except RuntimeError as ex: + parser.error(str(ex)) + + +def _parse_headers_arg( + parser, + headers: Optional[List[str]], +) -> ParsedHeaders: + out: ParsedHeaders = {} + + if headers is None: + headers = [] + + for raw_header in headers: + err = False + try: + k, v = raw_header.split(":", maxsplit=1) + except ValueError: + err = True + else: + k, v = (k.strip(), v.strip()) + if not k or not v: + err = True + if err: + parser.error( + f"Invalid header: `{raw_header}``. Should have format: `NAME: VALUE`" + ) + else: + existing = out.get(k) + if existing: + if isinstance(existing, list): + existing.append(v) + else: + out[k] = [existing, v] + else: + out[k] = v + return out + + +def _add_rest_api_headers_arg(parser): + parser.add_argument( + "-H", + "--header", + action="append", + help="Headers to append. Can be specified multiple time and the format for each is `KEY: VALUE`", + ) diff --git a/netsuite/cli/restlet.py b/netsuite/cli/restlet.py new file mode 100644 index 0000000..86a55c5 --- /dev/null +++ b/netsuite/cli/restlet.py @@ -0,0 +1,108 @@ +import argparse + +from .. import json +from ..client import NetSuite +from ..config import Config + +__all__ = () + + +def add_parser(parser, subparser): + restlet_parser = subparser.add_parser( + "restlet", description="Make NetSuite Restlet requests" + ) + restlet_subparser = restlet_parser.add_subparsers() + _add_restlet_get_parser(restlet_parser, restlet_subparser) + _add_restlet_post_parser(restlet_parser, restlet_subparser) + _add_restlet_put_parser(restlet_parser, restlet_subparser) + _add_restlet_delete_parser(restlet_parser, restlet_subparser) + + return (restlet_parser, restlet_subparser) + + +def _add_restlet_get_parser(parser, subparser): + async def restlet_get(config, args) -> str: + restlet = _get_restlet_or_error(parser, config) + + resp = await restlet.get(script_id=args.script_id, deploy=args.deploy) + return json.dumps(resp) + + p = subparser.add_parser( + "get", description="Make a GET request to NetSuite Restlet" + ) + _add_default_restlet_args(p) + p.set_defaults(func=restlet_get) + + +def _add_restlet_post_parser(parser, subparser): + async def restlet_post(config, args) -> str: + restlet = _get_restlet_or_error(parser, config) + + with args.payload_file as fh: + payload_str = fh.read() + + payload = json.loads(payload_str) + + resp = await restlet.post( + script_id=args.script_id, deploy=args.deploy, json=payload + ) + return json.dumps(resp) + + p = subparser.add_parser( + "post", description="Make a POST request to NetSuite Restlet" + ) + p.set_defaults(func=restlet_post) + _add_default_restlet_args(p) + p.add_argument("payload_file", type=argparse.FileType("r")) + + +def _add_restlet_put_parser(parser, subparser): + async def restlet_put(config, args) -> str: + restlet = _get_restlet_or_error(parser, config) + + with args.payload_file as fh: + payload_str = fh.read() + + payload = json.loads(payload_str) + + resp = await restlet.put( + script_id=args.script_id, deploy=args.deploy, json=payload + ) + return json.dumps(resp) + + p = subparser.add_parser( + "put", description="Make a PUT request to NetSuite Restlet" + ) + p.set_defaults(func=restlet_put) + _add_default_restlet_args(p) + p.add_argument("payload_file", type=argparse.FileType("r")) + + +def _add_restlet_delete_parser(parser, subparser): + async def restlet_delete(config, args) -> str: + restlet = _get_restlet_or_error(parser, config) + + resp = await restlet.put(script_id=args.script_id, deploy=args.deploy) + return json.dumps(resp) + + p = subparser.add_parser( + "delete", description="Make a DELETE request to a NetSuite Restlet" + ) + p.set_defaults(func=restlet_delete) + _add_default_restlet_args(p) + + +def _get_restlet_or_error(parser, config: Config): + ns = NetSuite(config) + + try: + return ns.restlet # Cached property that initializes NetSuiteRestlet + except RuntimeError as ex: + parser.error(str(ex)) + + +def _add_default_restlet_args(parser_: argparse.ArgumentParser): + parser_.add_argument("script_id", type=int, help="The script to run") + parser_.add_argument( + "-d", "--deploy", type=int, default=1, help="The deployment version" + ) diff --git a/netsuite/cli/soap_api.py b/netsuite/cli/soap_api.py new file mode 100644 index 0000000..2390a63 --- /dev/null +++ b/netsuite/cli/soap_api.py @@ -0,0 +1,78 @@ +from .. import json +from ..client import NetSuite +from ..config import Config +from ..soap_api import helpers + +__all__ = () + + +def add_parser(parser, subparser): + soap_api_parser = subparser.add_parser( + "soap-api", description="Make NetSuite SuiteTalk Web Services SOAP requests" + ) + soap_api_subparser = soap_api_parser.add_subparsers() + _add_get_parser(soap_api_parser, soap_api_subparser) + _add_get_list_parser(soap_api_parser, soap_api_subparser) + + return (soap_api_parser, soap_api_subparser) + + +def _add_get_list_parser(parser, subparser): + async def getList(config, args) -> str: + soap_api = _get_soap_api_or_error(parser, config) + resp = await soap_api.getList( + args.record_type, externalIds=args.externalId, internalIds=args.internalId + ) + return _dump_response(resp) + + p = subparser.add_parser("getList", description="Call the getList method") + p.add_argument("record_type", help="The record type to get") + p.add_argument( + "-e", + "--externalId", + action="append", + help="External IDs to get", + ) + p.add_argument( + "-i", + "--internalId", + action="append", + help="Internal IDs to get", + ) + p.set_defaults(func=getList) + + +def _add_get_parser(parser, subparser): + async def get(config, args) -> str: + soap_api = _get_soap_api_or_error(parser, config) + resp = await soap_api.get( + args.record_type, externalId=args.externalId, internalId=args.internalId + ) + return _dump_response(resp) + + p = subparser.add_parser("get", description="Call the `get` method") + p.add_argument("record_type", help="The record type to get") + p.add_argument( + "-e", + "--externalId", + help="External ID to get", + ) + p.add_argument( + "-i", + "--internalId", + help="Internal ID to get", + ) + p.set_defaults(func=get) + + +def _get_soap_api_or_error(parser, config: Config): + ns = NetSuite(config) + + try: + return ns.soap_api # Cached property that initializes NetSuiteRestApi + except RuntimeError as ex: + parser.error(str(ex)) + + +def _dump_response(resp) -> str: + return json.dumps(helpers.to_builtin(resp)) diff --git a/netsuite/client.py b/netsuite/client.py old mode 100755 new mode 100644 index a67be3a..598e87f --- a/netsuite/client.py +++ b/netsuite/client.py @@ -1,644 +1,36 @@ -import logging -import re -import warnings -from contextlib import contextmanager -from datetime import datetime -from functools import wraps -from typing import Any, Callable, Dict, List, Sequence, Union -from urllib.parse import urlparse +from typing import Any, Dict, Optional -import requests -import zeep -from zeep.cache import SqliteCache -from zeep.transports import Transport -from zeep.xsd.valueobjects import CompoundValue - -from . import constants, helpers, passport from .config import Config -from .restlet import NetsuiteRestlet +from .rest_api import NetSuiteRestApi +from .restlet import NetSuiteRestlet +from .soap_api import NetSuiteSoapApi from .util import cached_property -logger = logging.getLogger(__name__) - - -class NetsuiteResponseError(Exception): - """Raised when a Netsuite result was marked as unsuccessful""" - - -def WebServiceCall( - path: str = None, - extract: Callable = None, - *, - default: Any = constants.NOT_SET, -) -> Callable: - """ - Decorator for NetSuite methods returning SOAP responses - - Args: - path: - A dot-separated path for specifying where relevant data resides (where the `status` attribute is set) - extract: - A function to extract data from response before returning it. - default: - If the existing path does not exist in response, return this - instead. - - Returns: - Decorator to use on `NetSuite` web service methods - """ - def decorator(fn): - @wraps(fn) - def wrapper(self, *args, **kw): - response = fn(self, *args, **kw) - - if path is not None: - for part in path.split('.'): - try: - response = getattr(response, part) - except AttributeError: - if default is constants.NOT_SET: - raise - else: - return default - - try: - response_status = response['status'] - except TypeError: - response_status = None - for record in response: - # NOTE: Status is set on each returned record for lists, - # really strange... - response_status = record['status'] - break - - is_success = response_status['isSuccess'] - - if not is_success: - response_detail = response_status['statusDetail'] - raise NetsuiteResponseError(response_detail) - - if extract is not None: - response = extract(response) - - return response - return wrapper - return decorator - - -class NetSuiteTransport(Transport): - """ - NetSuite dynamic domain wrapper for zeep.transports.transport - - Latest NetSuite WSDL now uses relative definition addresses - - zeep maps reflective remote calls to the base WSDL address, - rather than the dynamic subscriber domain - - Wrap the zeep transports service with our address modifications - """ - - def __init__(self, wsdl_url, *args, **kwargs): - """ - Assign the dynamic host domain component to a class variable - """ - parsed_wsdl_url = urlparse(wsdl_url) - self._wsdl_url = f'{parsed_wsdl_url.scheme}://{parsed_wsdl_url.netloc}/' - - super().__init__(**kwargs) - - def _fix_address(self, address): - """ - Munge the address to the dynamic domain, not the default - """ - - idx = address.index('/', 8) + 1 - address = self._wsdl_url + address[idx:] - return address - - def get(self, address, params, headers): - """ - Update the GET address before providing it to zeep.transports.transport - """ - return super().get(self._fix_address(address), params, headers) - - def post(self, address, message, headers): - """ - Update the POST address before providing it to zeep.transports.transport - """ - return super().post(self._fix_address(address), message, headers) +__all__ = ("NetSuite",) class NetSuite: - version = '2019.2.0' - wsdl_url_tmpl = 'https://{account_id}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl' - - def __repr__(self) -> str: - return f'' - def __init__( self, - config: Union[Config, Dict], + config: Config, *, - version: str = None, - wsdl_url: str = None, - cache: zeep.cache.Base = None, - session: requests.Session = None, - sandbox: bool = None - ) -> None: - - if sandbox is not None: - warnings.warn( - 'The `sandbox` flag has been deprecated and no longer has ' - 'any effect. Please locate the correct account ID for your ' - 'sandbox instead (usually `_SB1`)', - DeprecationWarning, - ) - - if version is not None: - assert re.match(r'\d+\.\d+\.\d+', version) - self.version = version - - self.__config = self._make_config(config) - self.__wsdl_url = wsdl_url - self.__cache = cache - self.__session = session - self.__restlet = NetsuiteRestlet(self.__config) - - @property - def restlet(self): - return self.__restlet - - @cached_property - def wsdl_url(self) -> str: - return self.__wsdl_url or self._generate_wsdl_url() - - @cached_property - def cache(self) -> zeep.cache.Base: - return self.__cache or self._generate_cache() - - @cached_property - def session(self) -> requests.Session: - return self.__session or self._generate_session() - - @cached_property - def client(self) -> zeep.Client: - return self._generate_client() - - @cached_property - def transport(self): - return self._generate_transport() - - @property - def config(self) -> Config: - return self.__config - - @cached_property - def hostname(self) -> str: - return self.wsdl_url.replace('https://', '').partition('/')[0] - - @property - def service(self) -> zeep.client.ServiceProxy: - return self.client.service - - def _make_config( - self, - values_obj: Dict - ) -> Config: - if isinstance(values_obj, Config): - return values_obj - return Config(**values_obj) - - @property - def underscored_version(self) -> str: - return self.version.replace('.', '_') - - @property - def underscored_version_no_micro(self) -> str: - return self.underscored_version.rpartition('_')[0] - - def _generate_wsdl_url(self) -> str: - return self.wsdl_url_tmpl.format( - underscored_version=self.underscored_version, - # https://followingnetsuite.wordpress.com/2018/10/18/suitetalk-sandbox-urls-addendum/ - account_id=self.config.account.lower().replace('_', '-'), - ) - - def _generate_cache(self) -> zeep.cache.Base: - return SqliteCache(timeout=60 * 60 * 24 * 365) - - def _generate_session(self) -> requests.Session: - return requests.Session() - - def _generate_transport(self) -> zeep.transports.Transport: - return NetSuiteTransport( - self._generate_wsdl_url(), - session=self.session, - cache=self.cache, - ) - - def generate_passport(self) -> Dict: - return passport.make(self, self.config) - - def to_builtin(self, obj, *args, **kw): - """Turn zeep XML object into python built-in data structures""" - return helpers.to_builtin(obj, *args, **kw) - - @contextmanager - def with_timeout(self, timeout: int): - """Run SuiteTalk operation with the specified timeout""" - with self.transport.settings(timeout=timeout): - yield - - @staticmethod - def _set_default_soapheaders( - client: zeep.Client, - preferences: dict = None - ) -> None: - client.set_default_soapheaders({ - # https://netsuite.custhelp.com/app/answers/detail/a_id/40934 - # (you need to be logged in to SuiteAnswers for this link to work) - # 'preferences': { - # 'warningAsError': True/False, - # 'disableMandatoryCustomFieldValidation': True/False, - # 'disableSystemNotesForCustomFields': True/False, - # 'ignoreReadOnlyFields': True/False, - # 'runServerSuiteScriptAndTriggerWorkflows': True/False, - # }, - }) - - def _generate_client(self) -> zeep.Client: - client = zeep.Client( - self.wsdl_url, - transport=self.transport, - ) - self._set_default_soapheaders( - client, - preferences=self.config.preferences, - ) - return client - - def _get_namespace(self, name: str, sub_namespace: str) -> str: - return ( - 'urn:{name}_{version}.{sub_namespace}.webservices.netsuite.com' - .format( - name=name, - version=self.underscored_version_no_micro, - sub_namespace=sub_namespace, - ) - ) - - def _type_factory( - self, - name: str, - sub_namespace: str - ) -> zeep.client.Factory: - return self.client.type_factory( - self._get_namespace(name, sub_namespace) - ) - - @cached_property - def Core(self) -> zeep.client.Factory: - return self._type_factory('core', 'platform') - - @cached_property - def CoreTypes(self) -> zeep.client.Factory: - return self._type_factory('types.core', 'platform') - - @cached_property - def FaultsTypes(self) -> zeep.client.Factory: - return self._type_factory('types.faults', 'platform') - - @cached_property - def Faults(self) -> zeep.client.Factory: - return self._type_factory('faults', 'platform') - - @cached_property - def Messages(self) -> zeep.client.Factory: - return self._type_factory('messages', 'platform') - - @cached_property - def Common(self) -> zeep.client.Factory: - return self._type_factory('common', 'platform') - - @cached_property - def CommonTypes(self) -> zeep.client.Factory: - return self._type_factory('types.common', 'platform') - - @cached_property - def Scheduling(self) -> zeep.client.Factory: - return self._type_factory('scheduling', 'activities') - - @cached_property - def SchedulingTypes(self) -> zeep.client.Factory: - return self._type_factory('types.scheduling', 'activities') - - @cached_property - def Communication(self) -> zeep.client.Factory: - return self._type_factory('communication', 'general') - - @cached_property - def CommunicationTypes(self) -> zeep.client.Factory: - return self._type_factory('types.communication', 'general') - - @cached_property - def Filecabinet(self) -> zeep.client.Factory: - return self._type_factory('filecabinet', 'documents') - - @cached_property - def FilecabinetTypes(self) -> zeep.client.Factory: - return self._type_factory('types.filecabinet', 'documents') - - @cached_property - def Relationships(self) -> zeep.client.Factory: - return self._type_factory('relationships', 'lists') - - @cached_property - def RelationshipsTypes(self) -> zeep.client.Factory: - return self._type_factory('types.relationships', 'lists') - - @cached_property - def Support(self) -> zeep.client.Factory: - return self._type_factory('support', 'lists') - - @cached_property - def SupportTypes(self) -> zeep.client.Factory: - return self._type_factory('types.support', 'lists') - - @cached_property - def Accounting(self) -> zeep.client.Factory: - return self._type_factory('accounting', 'lists') - - @cached_property - def AccountingTypes(self) -> zeep.client.Factory: - return self._type_factory('types.accounting', 'lists') - - @cached_property - def Sales(self) -> zeep.client.Factory: - return self._type_factory('sales', 'transactions') - - @cached_property - def SalesTypes(self) -> zeep.client.Factory: - return self._type_factory('types.sales', 'transactions') - - @cached_property - def Purchases(self) -> zeep.client.Factory: - return self._type_factory('purchases', 'transactions') - - @cached_property - def PurchasesTypes(self) -> zeep.client.Factory: - return self._type_factory('types.purchases', 'transactions') - - @cached_property - def Customers(self) -> zeep.client.Factory: - return self._type_factory('customers', 'transactions') - - @cached_property - def CustomersTypes(self) -> zeep.client.Factory: - return self._type_factory('types.customers', 'transactions') - - @cached_property - def Financial(self) -> zeep.client.Factory: - return self._type_factory('financial', 'transactions') - - @cached_property - def FinancialTypes(self) -> zeep.client.Factory: - return self._type_factory('types.financial', 'transactions') - - @cached_property - def Bank(self) -> zeep.client.Factory: - return self._type_factory('bank', 'transactions') - - @cached_property - def BankTypes(self) -> zeep.client.Factory: - return self._type_factory('types.bank', 'transactions') - - @cached_property - def Inventory(self) -> zeep.client.Factory: - return self._type_factory('inventory', 'transactions') - - @cached_property - def InventoryTypes(self) -> zeep.client.Factory: - return self._type_factory('types.inventory', 'transactions') - - @cached_property - def General(self) -> zeep.client.Factory: - return self._type_factory('general', 'transactions') - - @cached_property - def Customization(self) -> zeep.client.Factory: - return self._type_factory('customization', 'setup') - - @cached_property - def CustomizationTypes(self) -> zeep.client.Factory: - return self._type_factory('types.customization', 'setup') + soap_api_options: Optional[Dict[str, Any]] = None, + rest_api_options: Optional[Dict[str, Any]] = None, + restlet_options: Optional[Dict[str, Any]] = None, + ): + self._config = config + self._soap_api_options = soap_api_options or {} + self._rest_api_options = rest_api_options or {} + self._restlet_options = restlet_options or {} @cached_property - def Employees(self) -> zeep.client.Factory: - return self._type_factory('employees', 'lists') + def rest_api(self) -> NetSuiteRestApi: + return NetSuiteRestApi(self._config, **self._rest_api_options) @cached_property - def EmployeesTypes(self) -> zeep.client.Factory: - return self._type_factory('types.employees', 'lists') + def soap_api(self) -> NetSuiteSoapApi: + return NetSuiteSoapApi(self._config, **self._soap_api_options) @cached_property - def Website(self) -> zeep.client.Factory: - return self._type_factory('website', 'lists') - - @cached_property - def WebsiteTypes(self) -> zeep.client.Factory: - return self._type_factory('types.website', 'lists') - - @cached_property - def EmployeesTransactions(self) -> zeep.client.Factory: - return self._type_factory('employees', 'transactions') - - @cached_property - def EmployeesTransactionsTypes(self) -> zeep.client.Factory: - return self._type_factory('types.employees', 'transactions') - - @cached_property - def Marketing(self) -> zeep.client.Factory: - return self._type_factory('marketing', 'lists') - - @cached_property - def MarketingTypes(self) -> zeep.client.Factory: - return self._type_factory('types.marketing', 'lists') - - @cached_property - def DemandPlanning(self) -> zeep.client.Factory: - return self._type_factory('demandplanning', 'transactions') - - @cached_property - def DemandPlanningTypes(self) -> zeep.client.Factory: - return self._type_factory('types.demandplanning', 'transactions') - - @cached_property - def SupplyChain(self) -> zeep.client.Factory: - return self._type_factory('supplychain', 'lists') - - @cached_property - def SupplyChainTypes(self) -> zeep.client.Factory: - return self._type_factory('types.supplychain', 'lists') - - def request( - self, - service_name: str, - *args, - **kw - ) -> zeep.xsd.ComplexType: - """ - Make a web service request to NetSuite - - Args: - service_name: - The NetSuite service to call - Returns: - The response from NetSuite - """ - svc = getattr(self.service, service_name) - return svc(*args, _soapheaders=self.generate_passport(), **kw) - - @WebServiceCall( - 'body.readResponseList.readResponse', - extract=lambda resp: [r['record'] for r in resp] - ) - def getList( - self, - recordType: str, - *, - internalIds: Sequence[int] = (), - externalIds: Sequence[str] = () - ) -> List[CompoundValue]: - """Get a list of records""" - - if len(list(internalIds) + list(externalIds)) == 0: - raise ValueError('Please specify `internalId` and/or `externalId`') - - return self.request( - 'getList', - self.Messages.GetListRequest( - baseRef=[ - self.Core.RecordRef( - type=recordType, - internalId=internalId, - ) for internalId in internalIds - ] + [ - self.Core.RecordRef( - type=recordType, - externalId=externalId, - ) for externalId in externalIds - ], - ) - ) - - @WebServiceCall( - 'body.readResponse', - extract=lambda resp: resp['record'], - ) - def get( - self, - recordType: str, - *, - internalId: int = None, - externalId: str = None - ) -> CompoundValue: - """Get a single record""" - if len([v for v in (internalId, externalId) if v is not None]) != 1: - raise ValueError('Specify either `internalId` or `externalId`') - - if internalId: - record_ref = self.Core.RecordRef( - type=recordType, - internalId=internalId, - ) - else: - self.Core.RecordRef( - type=recordType, - externalId=externalId, - ) - - return self.request('get', baseRef=record_ref) - - @WebServiceCall( - 'body.getAllResult', - extract=lambda resp: resp['recordList']['record'], - ) - def getAll(self, recordType: str) -> List[CompoundValue]: - """Get all records of a given type.""" - return self.request( - 'getAll', - record=self.Core.GetAllRecord( - recordType=recordType, - ), - ) - - @WebServiceCall( - 'body.writeResponse', - extract=lambda resp: resp['baseRef'], - ) - def add(self, record: CompoundValue) -> CompoundValue: - """Insert a single record.""" - return self.request('add', record=record) - - @WebServiceCall( - 'body.writeResponse', - extract=lambda resp: resp['baseRef'], - ) - def update(self, record: CompoundValue) -> CompoundValue: - """Insert a single record.""" - return self.request('update', record=record) - - @WebServiceCall( - 'body.writeResponse', - extract=lambda resp: resp['baseRef'], - ) - def upsert(self, record: CompoundValue) -> CompoundValue: - """Upsert a single record.""" - return self.request('upsert', record=record) - - @WebServiceCall( - 'body.searchResult', - extract=lambda resp: resp['recordList']['record'], - ) - def search(self, record: CompoundValue) -> List[CompoundValue]: - """Search records""" - return self.request('search', searchRecord=record) - - @WebServiceCall( - 'body.writeResponseList', - extract=lambda resp: [record['baseRef'] for record in resp], - ) - def upsertList(self, records: List[CompoundValue]) -> List[CompoundValue]: - """Upsert a list of records.""" - return self.request('upsertList', record=records) - - @WebServiceCall( - 'body.getItemAvailabilityResult', - extract=lambda resp: resp['itemAvailabilityList']['itemAvailability'], - default=[] - ) - def getItemAvailability( - self, - *, - internalIds: Sequence[int] = (), - externalIds: Sequence[str] = (), - lastQtyAvailableChange: datetime = None - ) -> List[Dict]: - if len(list(internalIds) + list(externalIds)) == 0: - raise ValueError('Please specify `internalId` and/or `externalId`') - - item_filters = [ - {'type': 'inventoryItem', 'internalId': internalId} - for internalId in internalIds - ] + [ - {'type': 'inventoryItem', 'externalId': externalId} - for externalId in externalIds - ] - - return self.request( - 'getItemAvailability', - itemAvailabilityFilter=[{ - 'item': {'recordRef': item_filters}, - 'lastQtyAvailableChange': lastQtyAvailableChange - }], - ) + def restlet(self) -> NetSuiteRestlet: + return NetSuiteRestlet(self._config, **self._restlet_options) diff --git a/netsuite/config.py b/netsuite/config.py index 0d0a60f..d386b86 100644 --- a/netsuite/config.py +++ b/netsuite/config.py @@ -1,159 +1,50 @@ import configparser -from typing import Dict +from typing import Dict, Union -from .constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION, NOT_SET +from pydantic import BaseModel -TOKEN = 'token' -CREDENTIALS = 'credentials' +from .constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION +__all__ = ("Config", "TokenAuth") -class Config: - """ - Takes dictionary keys/values that will be set as attribute names/values - on the config object if they exist as attributes - Args: - **opts: - Dictionary keys/values that will be set as attribute names/values - """ +class TokenAuth(BaseModel): + consumer_key: str + consumer_secret: str + token_id: str + token_secret: str - auth_type = TOKEN - """The authentication type to use, either 'token' or 'credentials'""" - account = None - """The NetSuite account ID""" +class Config(BaseModel): + account: str + auth: TokenAuth + # TODO: Support OAuth2 + # auth: Union[OAuth2, TokenAuth] - consumer_key = None - """The OAuth 1.0 consumer key""" + @property + def account_slugified(self) -> str: + # https://followingnetsuite.wordpress.com/2018/10/18/suitetalk-sandbox-urls-addendum/ + return self.account.lower().replace("_", "-") - consumer_secret = None - """The OAuth 1.0 consumer secret""" + @classmethod + def from_ini( + cls, path: str = DEFAULT_INI_PATH, section: str = DEFAULT_INI_SECTION + ) -> "Config": + iniconf = configparser.ConfigParser() + with open(path) as fp: + iniconf.read_file(fp) - token_id = None - """The OAuth 1.0 token ID""" + d: Dict[str, Union[str, Dict[str, str]]] = {"auth": {}} - token_secret = None - """The OAuth 1.0 token secret""" + auth_type = iniconf[section].get("auth_type", "token") - application_id = None - """Application ID, used with auth_type=credentials""" + if auth_type != "token": + raise RuntimeError(f"Only token auth is supported, not `{auth_type}`") - email = None - """Account e-mail, used with auth_type=credentials""" - - password = None - """Account password, used with auth_type=credentials""" - - preferences = None - """Additional preferences""" - - _settings_mapping = ( - ( - 'account', - {'type': str, 'required': True}, - ), - ( - 'consumer_key', - {'type': str, 'required_for_auth_type': TOKEN}, - ), - ( - 'consumer_secret', - {'type': str, 'required_for_auth_type': TOKEN}, - ), - ( - 'token_id', - {'type': str, 'required_for_auth_type': TOKEN}, - ), - ( - 'token_secret', - {'type': str, 'required_for_auth_type': TOKEN}, - ), - ( - 'application_id', - {'type': str, 'required_for_auth_type': CREDENTIALS}, - ), - ( - 'email', - {'type': str, 'required_for_auth_type': CREDENTIALS}, - ), - ( - 'password', - {'type': str, 'required_for_auth_type': CREDENTIALS}, - ), - ( - 'preferences', - {'type': dict, 'required': False, 'default': lambda: {}}, - ), - ) - - def __init__(self, **opts) -> None: - self._set(opts) - - def __contains__(self, key: str) -> bool: - return hasattr(self, key) - - def _set_auth_type(self, value: str) -> None: - self._validate_attr('auth_type', value, str, True, {}) - self.auth_type = value - assert self.auth_type in (TOKEN, CREDENTIALS) - - def _set(self, dct: Dict[str, object]) -> None: - # As other setting validations depend on auth_type we set it first - auth_type = dct.get('auth_type', self.auth_type) - self._set_auth_type(auth_type) - - for attr, opts in self._settings_mapping: - value = dct.get(attr, NOT_SET) - type_ = opts['type'] - - required = opts.get( - 'required', - opts.get('required_for_auth_type') == auth_type - ) - - self._validate_attr(attr, value, type_, required, opts) - - if value is NOT_SET and 'default' in opts: - value = opts['default']() - - setattr(self, attr, (None if value is NOT_SET else value)) - - def _validate_attr( - self, - attr: str, - value: object, - type_: object, - required: bool, - opts: dict - ) -> None: - if required and value is NOT_SET: - required_for_auth_type = opts.get('required_for_auth_type') - if required_for_auth_type: - raise ValueError( - f'Attribute {attr} is required for auth_type=' - f'`{required_for_auth_type}`' - ) + for key, val in iniconf[section].items(): + if auth_type == "token" and key in TokenAuth.__fields__: + d["auth"][key] = val # type: ignore[index] else: - raise ValueError(f'Attribute {attr} is required') - if value is not NOT_SET and not isinstance(value, type_): - raise ValueError(f'Attribute {attr} is not of type `{type_}`') - - -def from_ini( - path: str = DEFAULT_INI_PATH, - section: str = DEFAULT_INI_SECTION -) -> Config: - iniconf = configparser.ConfigParser() - with open(path) as fp: - iniconf.read_file(fp) - - config_dict = {'preferences': {}} - - for key, val in iniconf[section].items(): - if key.startswith('preferences_'): - _, key = key.split('_', 1) - config_dict['preferences'][key] = val - else: - config_dict[key] = val + d[key] = val - return Config(**config_dict) + return cls(**d) diff --git a/netsuite/constants.py b/netsuite/constants.py index b631fc5..99c79f8 100644 --- a/netsuite/constants.py +++ b/netsuite/constants.py @@ -2,7 +2,7 @@ NOT_SET: object = object() DEFAULT_INI_PATH: str = os.environ.get( - 'NETSUITE_CONFIG', - os.path.expanduser('~/.config/netsuite.ini'), + "NETSUITE_CONFIG", + os.path.expanduser("~/.config/netsuite.ini"), ) -DEFAULT_INI_SECTION: str = 'netsuite' +DEFAULT_INI_SECTION: str = "netsuite" diff --git a/netsuite/exceptions.py b/netsuite/exceptions.py new file mode 100644 index 0000000..10ecbf0 --- /dev/null +++ b/netsuite/exceptions.py @@ -0,0 +1,13 @@ +class NetsuiteAPIRequestError(Exception): + """Raised when a Netsuite REST API request fails""" + + def __init__(self, status_code: int, response_text: str): + self.status_code = status_code + self.response_text = response_text + + def __str__(self): + return f"HTTP{self.status_code} - {self.response_text}" + + +class NetsuiteAPIResponseParsingError(NetsuiteAPIRequestError): + """Raised when parsing a Netsuite REST API response fails""" diff --git a/netsuite/json.py b/netsuite/json.py new file mode 100644 index 0000000..06ef74e --- /dev/null +++ b/netsuite/json.py @@ -0,0 +1,71 @@ +import datetime +from decimal import Decimal +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Dict, Type, Union +from uuid import UUID + +__all__ = ("dumps", "loads") + +try: + import orjson as _json +except ImportError: + import json as _json # type: ignore[no-redef] + + HAS_ORJSON = False +else: + HAS_ORJSON = True + + +loads = _json.loads + + +def dumps(obj: Any, *args, **kw) -> str: + if HAS_ORJSON: + kw["default"] = _orjson_default + return _json.dumps(obj, *args, **kw).decode("utf-8") + else: + return _json.dumps(obj, *args, **kw) # type: ignore[return-value] + + +def _orjson_default(obj: Any) -> Any: + """Handle cases which orjson doesn't know what to do with""" + + # Handle that orjson doesn't support subclasses of str + if isinstance(obj, str): + return str(obj) + else: + encoder = _get_encoder(obj) + return encoder(obj) + + +def _isoformat(o: Union[datetime.date, datetime.time]) -> str: + return o.isoformat() + + +def _get_encoder(obj: Any) -> Any: + for base in obj.__class__.__mro__[:-1]: + try: + encoder = _ENCODERS_BY_TYPE[base] + except KeyError: + continue + return encoder(obj) + else: # We have exited the for loop without finding a suitable encoder + raise TypeError( + f"Object of type '{obj.__class__.__name__}' is not JSON serializable" + ) + + +_ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { + bytes: lambda o: o.decode(), + datetime.date: _isoformat, + datetime.datetime: _isoformat, + datetime.time: _isoformat, + datetime.timedelta: lambda td: td.total_seconds(), + Decimal: float, + Enum: lambda o: o.value, + frozenset: list, + Path: str, + set: list, + UUID: str, +} diff --git a/netsuite/passport.py b/netsuite/passport.py deleted file mode 100644 index 529d194..0000000 --- a/netsuite/passport.py +++ /dev/null @@ -1,141 +0,0 @@ -import base64 -import hmac -import random -from datetime import datetime -from typing import Dict, TypeVar - -from zeep.xsd.valueobjects import CompoundValue - -from .config import Config - -NetSuite = TypeVar('NetSuite') - - -class Passport: - - def get_element(self) -> str: - raise NotImplementedError - - -class UserCredentialsPassport(Passport): - - def __init__( - self, - ns: NetSuite, - *, - account: str, - email: str, - password: str - ) -> None: - self.ns = ns - self.account = account - self.email = email - self.password = password - - def get_element(self) -> CompoundValue: - return self.ns.Core.Passport( - account=self.account, - email=self.email, - password=self.password, - ) - - -class TokenPassport(Passport): - - def __init__( - self, - ns: NetSuite, - *, - account: str, - consumer_key: str, - consumer_secret: str, - token_id: str, - token_secret: str - ) -> None: - self.ns = ns - self.account = account - self.consumer_key = consumer_key - self.consumer_secret = consumer_secret - self.token_id = token_id - self.token_secret = token_secret - - def _generate_timestamp(self) -> str: - """Generate timestamp - - Returns: - str: A seconds precision timestamp - """ - return str(int(datetime.now().timestamp())) - - def _generate_nonce(self, length: int = 20) -> str: - """Generate pseudorandom number""" - return ''.join([str(random.randint(0, 9)) for i in range(length)]) - - def _get_signature_message(self, nonce: str, timestamp: str) -> str: - return '&'.join(( - self.account, - self.consumer_key, - self.token_id, - nonce, - timestamp, - )) - - def _get_signature_key(self) -> str: - return '&'.join((self.consumer_secret, self.token_secret)) - - def _get_signature_value(self, nonce: str, timestamp: str) -> str: - key = self._get_signature_key() - message = self._get_signature_message(nonce, timestamp) - hashed = hmac.new( - key=key.encode('utf-8'), - msg=message.encode('utf-8'), - digestmod='sha256' - ).digest() - return base64.b64encode(hashed).decode() - - def _get_signature(self, nonce: str, timestamp: str) -> CompoundValue: - return self.ns.Core.TokenPassportSignature( - self._get_signature_value(nonce, timestamp), - algorithm='HMAC-SHA256', - ) - - def get_element(self) -> CompoundValue: - nonce = self._generate_nonce() - timestamp = self._generate_timestamp() - signature = self._get_signature(nonce, timestamp) - return self.ns.Core.TokenPassport( - account=self.account, - consumerKey=self.consumer_key, - token=self.token_id, - nonce=nonce, - timestamp=timestamp, - signature=signature, - ) - - -def make(ns: NetSuite, config: Config) -> Dict: - if config.auth_type == 'token': - token_passport = TokenPassport( - ns, - account=config.account, - consumer_key=config.consumer_key, - consumer_secret=config.consumer_secret, - token_id=config.token_id, - token_secret=config.token_secret, - ) - return {'tokenPassport': token_passport.get_element()} - elif config.auth_type == 'credentials': - passport = UserCredentialsPassport( - ns, - account=config.account, - email=config.email, - password=config.password, - ) - return { - 'applicationInfo': { - 'applicationId': config.application_id, - }, - 'passport': passport.get_element(), - } - else: - raise NotImplementedError(f'config.auth_type={config.auth_type}') diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py new file mode 100644 index 0000000..680647d --- /dev/null +++ b/netsuite/rest_api.py @@ -0,0 +1,100 @@ +import logging +from typing import Sequence + +from . import rest_api_base +from .config import Config +from .util import cached_property + +logger = logging.getLogger(__name__) + +__all__ = ("NetSuiteRestApi",) + + +class NetSuiteRestApi(rest_api_base.RestApiBase): + def __init__( + self, + config: Config, + *, + default_timeout: int = 60, + concurrent_requests: int = 10, + signature_method: str = rest_api_base.DEFAULT_SIGNATURE_METHOD, + ): + self._config = config + self._default_timeout = default_timeout + self._concurrent_requests = concurrent_requests + self._signature_method = signature_method + + @cached_property + def hostname(self) -> str: + return self._make_hostname() + + async def get(self, subpath: str, **request_kw): + return await self._request("GET", subpath, **request_kw) + + async def post(self, subpath: str, **request_kw): + return await self._request( + "POST", + subpath, + **request_kw, + ) + + async def put(self, subpath: str, **request_kw): + return await self._request("PUT", subpath, **request_kw) + + async def patch(self, subpath: str, **request_kw): + return await self._request("PATCH", subpath, **request_kw) + + async def delete(self, subpath: str, **request_kw): + return await self._request("DELETE", subpath, **request_kw) + + async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): + return await self._request( + "POST", + "/query/v1/suiteql", + headers={"Prefer": "transient", **request_kw.pop("headers", {})}, + json={"q": q, **request_kw.pop("json", {})}, + params={"limit": limit, "offset": offset, **request_kw.pop("params", {})}, + **request_kw, + ) + + async def jsonschema(self, record_type: str, **request_kw): + headers = { + "Accept": "application/schema+json", + **request_kw.pop("headers", {}), + } + return await self._request( + "GET", + f"/record/v1/metadata-catalog/{record_type}", + headers=headers, + **request_kw, + ) + + async def openapi(self, record_types: Sequence[str] = (), **request_kw): + headers = { + "Accept": "application/swagger+json", + **request_kw.pop("headers", {}), + } + params = request_kw.pop("params", {}) + + if len(record_types) > 0: + params["select"] = ",".join(record_types) + + return await self._request( + "GET", + "/record/v1/metadata-catalog", + headers=headers, + params=params, + **request_kw, + ) + + def _make_hostname(self): + return f"{self._config.account_slugified}.suitetalk.api.netsuite.com" + + def _make_url(self, subpath: str): + return f"https://{self.hostname}/services/rest{subpath}" + + def _make_default_headers(self): + return { + "Content-Type": "application/json", + "X-NetSuite-PropertyNameValidation": "error", + } diff --git a/netsuite/rest_api_base.py b/netsuite/rest_api_base.py new file mode 100644 index 0000000..a54f7bc --- /dev/null +++ b/netsuite/rest_api_base.py @@ -0,0 +1,105 @@ +import asyncio +import logging + +import httpx +from authlib.integrations.httpx_client import OAuth1Auth +from authlib.oauth1.rfc5849.client_auth import ClientAuth +from authlib.oauth1.rfc5849.signature import generate_signature_base_string +from oauthlib.oauth1.rfc5849.signature import sign_hmac_sha256 + +from . import json +from .exceptions import NetsuiteAPIRequestError, NetsuiteAPIResponseParsingError +from .util import cached_property + +__all__ = ("RestApiBase",) + +DEFAULT_SIGNATURE_METHOD = "HMAC-SHA256" + +logger = logging.getLogger(__name__) + + +def authlib_hmac_sha256_sign_method(client, request): + """Sign a HMAC-SHA256 signature.""" + base_string = generate_signature_base_string(request) + return sign_hmac_sha256(base_string, client.client_secret, client.token_secret) + + +ClientAuth.register_signature_method("HMAC-SHA256", authlib_hmac_sha256_sign_method) + + +class RestApiBase: + _concurrent_requests: int = 10 + _default_timeout: int = 10 + _signature_method: str = DEFAULT_SIGNATURE_METHOD + + @cached_property + def _request_semaphore(self) -> asyncio.Semaphore: + # NOTE: Shouldn't be put in __init__ as we might not have a running + # event loop at that time. + return asyncio.Semaphore(self._concurrent_requests) + + async def _request(self, method: str, subpath: str, **request_kw): + resp = await self._request_impl(method, subpath, **request_kw) + + if resp.status_code < 200 or resp.status_code > 299: + raise NetsuiteAPIRequestError(resp.status_code, resp.text) + + if resp.status_code == 204: + return None + else: + try: + return json.loads(resp.text) + except Exception: + raise NetsuiteAPIResponseParsingError(resp.status_code, resp.text) + + async def _request_impl( + self, method: str, subpath: str, **request_kw + ) -> httpx.Response: + method = method.upper() + url = self._make_url(subpath) + + headers = {**self._make_default_headers(), **request_kw.pop("headers", {})} + + timeout = request_kw.pop("timeout", self._default_timeout) + + if "json" in request_kw: + request_kw["data"] = json.dumps(request_kw.pop("json")) + + kw = {**request_kw} + logger.debug( + f"Making {method.upper()} request to {url}. Keyword arguments: {kw}" + ) + + async with self._request_semaphore: + async with httpx.AsyncClient() as c: + resp = await c.request( + method=method, + url=url, + headers=headers, + auth=self._make_auth(), + timeout=timeout, + **kw, + ) + + resp_headers_json = json.dumps(dict(resp.headers)) + logger.debug(f"Got response headers from NetSuite: {resp_headers_json}") + + return resp + + def _make_url(self, subpath: str): + raise NotImplementedError + + def _make_auth(self): + auth = self._config.auth + return OAuth1Auth( + client_id=auth.consumer_key, + client_secret=auth.consumer_secret, + token=auth.token_id, + token_secret=auth.token_secret, + realm=self._config.account, + force_include_body=True, + signature_method=self._signature_method, + ) + + def _make_default_headers(self): + return {"Content-Type": "application/json"} diff --git a/netsuite/restlet.py b/netsuite/restlet.py index 46968e1..0533ea2 100644 --- a/netsuite/restlet.py +++ b/netsuite/restlet.py @@ -1,109 +1,53 @@ -import json import logging -from typing import Any, Tuple, Union -import requests_oauthlib - -from . import util +from . import rest_api_base +from .config import Config +from .util import cached_property logger = logging.getLogger(__name__) +__all__ = ("NetSuiteRestlet",) -class NetsuiteRestlet: - - _restlet_path_tmpl = \ - '/app/site/hosting/restlet.nl?script={script_id}&deploy={deploy}' - - def __init__(self, config, *, hostname=None): - self.__config = config - self.__hostname = hostname or self._make_default_hostname() - self._request_session = self._make_request_session() - - @property - def config(self): - return self.__config - - @property - def hostname(self): - return self.__hostname - def request( +class NetSuiteRestlet(rest_api_base.RestApiBase): + def __init__( self, - script_id: int, - payload: Any = None, + config: Config, *, - deploy: int = 1, - raise_on_bad_status: bool = True, - timeout: Union[int, Tuple[int, int]] = None, - **requests_kw + default_timeout: int = 60, + concurrent_requests: int = 10, + signature_method: str = rest_api_base.DEFAULT_SIGNATURE_METHOD, ): - resp = self.raw_request( - script_id=script_id, - payload=payload, - deploy=deploy, - raise_on_bad_status=raise_on_bad_status, - timeout=timeout, - **requests_kw - ) - util.raise_for_status_with_body(resp) - return resp.json() - - def raw_request( - self, - script_id: int, - payload: Any = None, - *, - deploy: int = 1, - raise_on_bad_status: bool = True, - timeout: Union[int, Tuple[int, int]] = None, - **requests_kw - ): - url = self._make_url(script_id=script_id, deploy=deploy) - headers = self._make_headers() - - req_headers_json = json.dumps(headers) - logger.debug( - f'Making request to restlet at {url}. Payload {payload}. ' - f'Headers: {req_headers_json}' - ) + self._config = config + self._default_timeout = default_timeout + self._concurrent_requests = concurrent_requests + self._signature_method = signature_method - resp = self._request_session.post( - url, - headers=headers, - json=payload, - timeout=timeout, - **requests_kw - ) + @cached_property + def hostname(self) -> str: + return self._make_hostname() - resp_headers_json = json.dumps(dict(resp.headers)) - logger.debug(f'Got response headers: {resp_headers_json}') + async def get(self, script_id: int, *, deploy: int = 1, **request_kw): + subpath = self._make_restlet_params(script_id, deploy) + return await self._request("GET", subpath, **request_kw) - return resp + async def post(self, script_id: int, *, deploy: int = 1, **request_kw): + subpath = self._make_restlet_params(script_id, deploy) + return await self._request("POST", subpath, **request_kw) - def _make_default_hostname(self): - account_slugified = self.config.account.lower().replace('_', '-') - return f'{account_slugified}.restlets.api.netsuite.com' + async def put(self, script_id: int, *, deploy: int = 1, **request_kw): + subpath = self._make_restlet_params(script_id, deploy) + return await self._request("PUT", subpath, **request_kw) - def _make_restlet_path(self, script_id: int, deploy: int = 1): - return self._restlet_path_tmpl.format( - script_id=script_id, - deploy=deploy, - ) + async def delete(self, script_id: int, *, deploy: int = 1, **request_kw): + subpath = self._make_restlet_params(script_id, deploy) + return await self._request("DELETE", subpath, **request_kw) - def _make_url(self, script_id: int, deploy: int = 1): - path = self._make_restlet_path(script_id=script_id, deploy=deploy) - return f'https://{self.hostname}{path}' + def _make_restlet_params(self, script_id: int, deploy: int = 1) -> str: + return f"?script={script_id}&deploy={deploy}" - def _make_request_session(self): - return requests_oauthlib.OAuth1Session( - client_key=self.config.consumer_key, - client_secret=self.config.consumer_secret, - resource_owner_key=self.config.token_id, - resource_owner_secret=self.config.token_secret, - realm=self.config.account, - ) + def _make_hostname(self): + return f"{self._config.account_slugified}.restlets.api.netsuite.com" - def _make_headers(self): - return { - 'Content-Type': 'application/json', - } + def _make_url(self, subpath: str) -> str: + return f"https://{self.hostname}/app/site/hosting/restlet.nl{subpath}" diff --git a/netsuite/soap_api/__init__.py b/netsuite/soap_api/__init__.py new file mode 100644 index 0000000..38ba87b --- /dev/null +++ b/netsuite/soap_api/__init__.py @@ -0,0 +1,2 @@ +from .client import * # noqa +from .exceptions import * # noqa diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py new file mode 100644 index 0000000..97db3f7 --- /dev/null +++ b/netsuite/soap_api/client.py @@ -0,0 +1,513 @@ +import logging +import re +from contextlib import contextmanager +from datetime import datetime +from typing import Dict, List, Optional, Sequence + +from ..config import Config +from ..util import cached_property +from . import helpers, passport, zeep +from .decorators import WebServiceCall +from .transports import AsyncNetSuiteTransport + +logger = logging.getLogger(__name__) + +__all__ = ("NetSuiteSoapApi",) + + +class NetSuiteSoapApi: + version = "2021.1.0" + wsdl_url_tmpl = "https://{account_slug}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl" + + def __init__( + self, + config: Config, + *, + version: str = None, + wsdl_url: str = None, + cache: zeep.cache.Base = None, + ) -> None: + self._ensure_required_dependencies() + if version is not None: + assert re.match(r"\d+\.\d+\.\d+", version) + self.version = version + self.config: Config = config + self._wsdl_url: Optional[str] = wsdl_url + self._cache: Optional[zeep.cache.Base] = cache + self._client: Optional[zeep.client.AsyncClient] = None + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.hostname}({self.version})>" + + async def __aenter__(self): + await self.client.__aenter__() + return self + + async def __aexit__(self, exc_type=None, exc_value=None, traceback=None) -> None: + await self.client.__aexit__( + exc_type=exc_type, exc_value=exc_value, traceback=traceback + ) + # Connection is now closed by zeep. Generate a new one + self._client = self._generate_client() + + @property + def wsdl_url(self) -> str: + if self._wsdl_url is None: + self._wsdl_url = self._generate_wsdl_url() + return self._wsdl_url + + @property + def cache(self) -> zeep.cache.Base: + if self._cache is None: + self._cache = self._generate_cache() + return self._cache + + @property + def client(self) -> zeep.client.AsyncClient: + if self._client is None: + self._client = self._generate_client() + return self._client + + @property + def transport(self): + return self.client.transport + + @cached_property + def hostname(self) -> str: + return self.wsdl_url.replace("https://", "").partition("/")[0] + + @property + def service(self) -> zeep.client.ServiceProxy: + return self.client.service + + @property + def underscored_version(self) -> str: + return self.version.replace(".", "_") + + @property + def underscored_version_no_micro(self) -> str: + return self.underscored_version.rpartition("_")[0] + + def _generate_wsdl_url(self) -> str: + return self.wsdl_url_tmpl.format( + underscored_version=self.underscored_version, + account_slug=self.config.account_slugified, + ) + + def _generate_cache(self) -> zeep.cache.Base: + return zeep.cache.SqliteCache(timeout=60 * 60 * 24 * 365) + + def _generate_session(self) -> zeep.requests.Session: + return zeep.requests.Session() + + def _generate_transport(self) -> zeep.transports.AsyncTransport: + return AsyncNetSuiteTransport( + self.wsdl_url, + session=self._generate_session(), + cache=self.cache, + ) + + def generate_passport(self) -> Dict: + return passport.make(self, self.config) + + def to_builtin(self, obj, *args, **kw): + """Turn zeep XML object into python built-in data structures""" + return helpers.to_builtin(obj, *args, **kw) + + @contextmanager + def with_timeout(self, timeout: int): + """Run SuiteTalk operation with the specified timeout""" + with self.transport.settings(timeout=timeout): + yield + + def _generate_client(self) -> zeep.client.AsyncClient: + return zeep.client.AsyncClient( + self.wsdl_url, + transport=self._generate_transport(), + ) + + def _get_namespace(self, name: str, sub_namespace: str) -> str: + return "urn:{name}_{version}.{sub_namespace}.webservices.netsuite.com".format( + name=name, + version=self.underscored_version_no_micro, + sub_namespace=sub_namespace, + ) + + def _type_factory(self, name: str, sub_namespace: str) -> zeep.client.Factory: + return self.client.type_factory(self._get_namespace(name, sub_namespace)) + + @classmethod + def _ensure_required_dependencies(cls): + if not cls._has_required_dependencies(): + raise RuntimeError( + "Missing required dependencies for SOAP Web Services API support. " + "Install with `pip install netsuite[soap_api]`" + ) + + @classmethod + def _has_required_dependencies(cls) -> bool: + return zeep.ZEEP_INSTALLED + + @cached_property + def Core(self) -> zeep.client.Factory: + return self._type_factory("core", "platform") + + @cached_property + def CoreTypes(self) -> zeep.client.Factory: + return self._type_factory("types.core", "platform") + + @cached_property + def FaultsTypes(self) -> zeep.client.Factory: + return self._type_factory("types.faults", "platform") + + @cached_property + def Faults(self) -> zeep.client.Factory: + return self._type_factory("faults", "platform") + + @cached_property + def Messages(self) -> zeep.client.Factory: + return self._type_factory("messages", "platform") + + @cached_property + def Common(self) -> zeep.client.Factory: + return self._type_factory("common", "platform") + + @cached_property + def CommonTypes(self) -> zeep.client.Factory: + return self._type_factory("types.common", "platform") + + @cached_property + def Scheduling(self) -> zeep.client.Factory: + return self._type_factory("scheduling", "activities") + + @cached_property + def SchedulingTypes(self) -> zeep.client.Factory: + return self._type_factory("types.scheduling", "activities") + + @cached_property + def Communication(self) -> zeep.client.Factory: + return self._type_factory("communication", "general") + + @cached_property + def CommunicationTypes(self) -> zeep.client.Factory: + return self._type_factory("types.communication", "general") + + @cached_property + def Filecabinet(self) -> zeep.client.Factory: + return self._type_factory("filecabinet", "documents") + + @cached_property + def FilecabinetTypes(self) -> zeep.client.Factory: + return self._type_factory("types.filecabinet", "documents") + + @cached_property + def Relationships(self) -> zeep.client.Factory: + return self._type_factory("relationships", "lists") + + @cached_property + def RelationshipsTypes(self) -> zeep.client.Factory: + return self._type_factory("types.relationships", "lists") + + @cached_property + def Support(self) -> zeep.client.Factory: + return self._type_factory("support", "lists") + + @cached_property + def SupportTypes(self) -> zeep.client.Factory: + return self._type_factory("types.support", "lists") + + @cached_property + def Accounting(self) -> zeep.client.Factory: + return self._type_factory("accounting", "lists") + + @cached_property + def AccountingTypes(self) -> zeep.client.Factory: + return self._type_factory("types.accounting", "lists") + + @cached_property + def Sales(self) -> zeep.client.Factory: + return self._type_factory("sales", "transactions") + + @cached_property + def SalesTypes(self) -> zeep.client.Factory: + return self._type_factory("types.sales", "transactions") + + @cached_property + def Purchases(self) -> zeep.client.Factory: + return self._type_factory("purchases", "transactions") + + @cached_property + def PurchasesTypes(self) -> zeep.client.Factory: + return self._type_factory("types.purchases", "transactions") + + @cached_property + def Customers(self) -> zeep.client.Factory: + return self._type_factory("customers", "transactions") + + @cached_property + def CustomersTypes(self) -> zeep.client.Factory: + return self._type_factory("types.customers", "transactions") + + @cached_property + def Financial(self) -> zeep.client.Factory: + return self._type_factory("financial", "transactions") + + @cached_property + def FinancialTypes(self) -> zeep.client.Factory: + return self._type_factory("types.financial", "transactions") + + @cached_property + def Bank(self) -> zeep.client.Factory: + return self._type_factory("bank", "transactions") + + @cached_property + def BankTypes(self) -> zeep.client.Factory: + return self._type_factory("types.bank", "transactions") + + @cached_property + def Inventory(self) -> zeep.client.Factory: + return self._type_factory("inventory", "transactions") + + @cached_property + def InventoryTypes(self) -> zeep.client.Factory: + return self._type_factory("types.inventory", "transactions") + + @cached_property + def General(self) -> zeep.client.Factory: + return self._type_factory("general", "transactions") + + @cached_property + def Customization(self) -> zeep.client.Factory: + return self._type_factory("customization", "setup") + + @cached_property + def CustomizationTypes(self) -> zeep.client.Factory: + return self._type_factory("types.customization", "setup") + + @cached_property + def Employees(self) -> zeep.client.Factory: + return self._type_factory("employees", "lists") + + @cached_property + def EmployeesTypes(self) -> zeep.client.Factory: + return self._type_factory("types.employees", "lists") + + @cached_property + def Website(self) -> zeep.client.Factory: + return self._type_factory("website", "lists") + + @cached_property + def WebsiteTypes(self) -> zeep.client.Factory: + return self._type_factory("types.website", "lists") + + @cached_property + def EmployeesTransactions(self) -> zeep.client.Factory: + return self._type_factory("employees", "transactions") + + @cached_property + def EmployeesTransactionsTypes(self) -> zeep.client.Factory: + return self._type_factory("types.employees", "transactions") + + @cached_property + def Marketing(self) -> zeep.client.Factory: + return self._type_factory("marketing", "lists") + + @cached_property + def MarketingTypes(self) -> zeep.client.Factory: + return self._type_factory("types.marketing", "lists") + + @cached_property + def DemandPlanning(self) -> zeep.client.Factory: + return self._type_factory("demandplanning", "transactions") + + @cached_property + def DemandPlanningTypes(self) -> zeep.client.Factory: + return self._type_factory("types.demandplanning", "transactions") + + @cached_property + def SupplyChain(self) -> zeep.client.Factory: + return self._type_factory("supplychain", "lists") + + @cached_property + def SupplyChainTypes(self) -> zeep.client.Factory: + return self._type_factory("types.supplychain", "lists") + + async def request(self, service_name: str, *args, **kw): + """ + Make a web service request to NetSuite + + Args: + service_name: + The NetSuite service to call + Returns: + The response from NetSuite + """ + svc = getattr(self.service, service_name) + return await svc(*args, _soapheaders=self.generate_passport(), **kw) + + @WebServiceCall( + "body.readResponseList.readResponse", + extract=lambda resp: [r["record"] for r in resp], + ) + async def getList( + self, + recordType: str, + *, + internalIds: Optional[Sequence[int]] = None, + externalIds: Optional[Sequence[str]] = None, + ) -> List[zeep.xsd.CompoundValue]: + """Get a list of records""" + if internalIds is None: + internalIds = [] + else: + internalIds = list(internalIds) + if externalIds is None: + externalIds = [] + else: + externalIds = list(externalIds) + + if len(internalIds) + len(externalIds) == 0: + return [] + + return await self.request( + "getList", + self.Messages.GetListRequest( + baseRef=[ + self.Core.RecordRef( + type=recordType, + internalId=internalId, + ) + for internalId in internalIds + ] + + [ + self.Core.RecordRef( + type=recordType, + externalId=externalId, + ) + for externalId in externalIds + ], + ), + ) + + @WebServiceCall( + "body.readResponse", + extract=lambda resp: resp["record"], + ) + async def get( + self, recordType: str, *, internalId: int = None, externalId: str = None + ) -> zeep.xsd.CompoundValue: + """Get a single record""" + if len([v for v in (internalId, externalId) if v is not None]) != 1: + raise ValueError("Specify either `internalId` or `externalId`") + + if internalId: + record_ref = self.Core.RecordRef( + type=recordType, + internalId=internalId, + ) + else: + record_ref = self.Core.RecordRef( + type=recordType, + externalId=externalId, + ) + + return await self.request("get", baseRef=record_ref) + + @WebServiceCall( + "body.getAllResult", + extract=lambda resp: resp["recordList"]["record"], + ) + async def getAll(self, recordType: str) -> List[zeep.xsd.CompoundValue]: + """Get all records of a given type.""" + return await self.request( + "getAll", + record=self.Core.GetAllRecord( + recordType=recordType, + ), + ) + + @WebServiceCall( + "body.writeResponse", + extract=lambda resp: resp["baseRef"], + ) + async def add(self, record: zeep.xsd.CompoundValue) -> zeep.xsd.CompoundValue: + """Insert a single record.""" + return await self.request("add", record=record) + + @WebServiceCall( + "body.writeResponse", + extract=lambda resp: resp["baseRef"], + ) + async def update(self, record: zeep.xsd.CompoundValue) -> zeep.xsd.CompoundValue: + """Insert a single record.""" + return await self.request("update", record=record) + + @WebServiceCall( + "body.writeResponse", + extract=lambda resp: resp["baseRef"], + ) + async def upsert(self, record: zeep.xsd.CompoundValue) -> zeep.xsd.CompoundValue: + """Upsert a single record.""" + return await self.request("upsert", record=record) + + @WebServiceCall( + "body.searchResult", + extract=lambda resp: resp["recordList"]["record"], + ) + async def search( + self, record: zeep.xsd.CompoundValue + ) -> List[zeep.xsd.CompoundValue]: + """Search records""" + return await self.request("search", searchRecord=record) + + @WebServiceCall( + "body.writeResponseList", + extract=lambda resp: [record["baseRef"] for record in resp], + ) + async def upsertList( + self, records: List[zeep.xsd.CompoundValue] + ) -> List[zeep.xsd.CompoundValue]: + """Upsert a list of records.""" + return await self.request("upsertList", record=records) + + @WebServiceCall( + "body.getItemAvailabilityResult", + extract=lambda resp: resp["itemAvailabilityList"]["itemAvailability"], + default=[], + ) + async def getItemAvailability( + self, + *, + internalIds: Optional[Sequence[int]] = None, + externalIds: Optional[Sequence[str]] = None, + lastQtyAvailableChange: datetime = None, + ) -> List[Dict]: + if internalIds is None: + internalIds = [] + else: + internalIds = list(internalIds) + if externalIds is None: + externalIds = [] + else: + externalIds = list(externalIds) + + if len(internalIds) + len(externalIds) == 0: + return [] + + item_filters = [ + {"type": "inventoryItem", "internalId": internalId} + for internalId in internalIds + ] + [ + {"type": "inventoryItem", "externalId": externalId} + for externalId in externalIds + ] + + return await self.request( + "getItemAvailability", + itemAvailabilityFilter=[ + { + "item": {"recordRef": item_filters}, + "lastQtyAvailableChange": lastQtyAvailableChange, + } + ], + ) diff --git a/netsuite/soap_api/decorators.py b/netsuite/soap_api/decorators.py new file mode 100644 index 0000000..b00c375 --- /dev/null +++ b/netsuite/soap_api/decorators.py @@ -0,0 +1,73 @@ +from functools import wraps +from typing import Any, Callable + +from .. import constants +from . import zeep +from .exceptions import NetsuiteResponseError + +__all__ = ("WebServiceCall",) + + +def WebServiceCall( + path: str = None, + extract: Callable = None, + *, + default: Any = constants.NOT_SET, +) -> Callable: + """ + Decorator for NetSuite methods returning SOAP responses + + Args: + path: + A dot-separated path for specifying where relevant data resides (where the `status` attribute is set) + extract: + A function to extract data from response before returning it. + default: + If the existing path does not exist in response, return this + instead. + + Returns: + Decorator to use on `NetSuite` web service methods + """ + + def decorator(fn): + @wraps(fn) + def wrapper(self, *args, **kw): + response = fn(self, *args, **kw) + if not isinstance(response, zeep.xsd.ComplexType): + return response + + if path is not None: + for part in path.split("."): + try: + response = getattr(response, part) + except AttributeError: + if default is constants.NOT_SET: + raise + else: + return default + + try: + response_status = response["status"] + except TypeError: + response_status = None + for record in response: + # NOTE: Status is set on each returned record for lists, + # really strange... + response_status = record["status"] + break + + is_success = response_status["isSuccess"] + + if not is_success: + response_detail = response_status["statusDetail"] + raise NetsuiteResponseError(response_detail) + + if extract is not None: + response = extract(response) + + return response + + return wrapper + + return decorator diff --git a/netsuite/soap_api/exceptions.py b/netsuite/soap_api/exceptions.py new file mode 100644 index 0000000..1124c73 --- /dev/null +++ b/netsuite/soap_api/exceptions.py @@ -0,0 +1,5 @@ +__all__ = ("NetsuiteResponseError",) + + +class NetsuiteResponseError(Exception): + """Raised when a Netsuite result was marked as unsuccessful""" diff --git a/netsuite/helpers.py b/netsuite/soap_api/helpers.py similarity index 96% rename from netsuite/helpers.py rename to netsuite/soap_api/helpers.py index 9f0ff1f..a2949dc 100644 --- a/netsuite/helpers.py +++ b/netsuite/soap_api/helpers.py @@ -1,4 +1,4 @@ -import zeep.helpers +from . import zeep def to_builtin(obj, *, target_cls=dict): diff --git a/netsuite/soap_api/passport.py b/netsuite/soap_api/passport.py new file mode 100644 index 0000000..f35aa9c --- /dev/null +++ b/netsuite/soap_api/passport.py @@ -0,0 +1,102 @@ +import base64 +import hmac +import random +from datetime import datetime +from typing import Dict, TypeVar + +from ..config import Config, TokenAuth + +NetSuite = TypeVar("NetSuite") + + +class Passport: + def get_element(self) -> str: + raise NotImplementedError + + +class TokenPassport(Passport): + def __init__( + self, + ns: NetSuite, + *, + account: str, + consumer_key: str, + consumer_secret: str, + token_id: str, + token_secret: str, + ) -> None: + self.ns = ns + self.account = account + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self.token_id = token_id + self.token_secret = token_secret + + def _generate_timestamp(self) -> str: + """Generate timestamp + + Returns: + str: A seconds precision timestamp + """ + return str(int(datetime.now().timestamp())) + + def _generate_nonce(self, length: int = 20) -> str: + """Generate pseudorandom number""" + return "".join([str(random.randint(0, 9)) for i in range(length)]) + + def _get_signature_message(self, nonce: str, timestamp: str) -> str: + return "&".join( + ( + self.account, + self.consumer_key, + self.token_id, + nonce, + timestamp, + ) + ) + + def _get_signature_key(self) -> str: + return "&".join((self.consumer_secret, self.token_secret)) + + def _get_signature_value(self, nonce: str, timestamp: str) -> str: + key = self._get_signature_key() + message = self._get_signature_message(nonce, timestamp) + hashed = hmac.new( + key=key.encode("utf-8"), msg=message.encode("utf-8"), digestmod="sha256" + ).digest() + return base64.b64encode(hashed).decode() + + def _get_signature(self, nonce: str, timestamp: str): + return self.ns.Core.TokenPassportSignature( # type: ignore[attr-defined] + self._get_signature_value(nonce, timestamp), + algorithm="HMAC-SHA256", + ) + + def get_element(self): + nonce = self._generate_nonce() + timestamp = self._generate_timestamp() + signature = self._get_signature(nonce, timestamp) + return self.ns.Core.TokenPassport( + account=self.account, + consumerKey=self.consumer_key, + token=self.token_id, + nonce=nonce, + timestamp=timestamp, + signature=signature, + ) + + +def make(ns: NetSuite, config: Config) -> Dict: + auth = config.auth + if isinstance(auth, TokenAuth): + token_passport = TokenPassport( + ns, + account=config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + return {"tokenPassport": token_passport.get_element()} + else: + raise NotImplementedError(auth.__class__) diff --git a/netsuite/soap_api/transports.py b/netsuite/soap_api/transports.py new file mode 100644 index 0000000..b40e555 --- /dev/null +++ b/netsuite/soap_api/transports.py @@ -0,0 +1,38 @@ +import urllib.parse + +from . import zeep + +__all__ = ("AsyncNetSuiteTransport",) + + +# TODO: ASYNC! Maybe remove this custom transport?!?! + + +class AsyncNetSuiteTransport(zeep.transports.AsyncTransport): + """ + NetSuite company-specific domain wrapper for zeep.transports.transport + + Latest NetSuite WSDL now uses relative definition addresses + + zeep maps reflective remote calls to the base WSDL address, + rather than the dynamic subscriber domain + + Wrap the zeep transports service with our address modifications + """ + + def __init__(self, wsdl_url, *args, **kwargs): + parsed = urllib.parse.urlparse(wsdl_url) + self._netsuite_base_url = f"{parsed.scheme}://{parsed.netloc}" + super().__init__(*args, **kwargs) + + def _fix_address(self, address): + """Munge the address to the company-specific domain, not the default""" + idx = address.index("/", 8) + path = address[idx:] + return f"{self._netsuite_base_url}{path}" + + async def get(self, address, params, headers): + return await super().get(self._fix_address(address), params, headers) + + async def post(self, address, message, headers): + return await super().post(self._fix_address(address), message, headers) diff --git a/netsuite/soap_api/zeep.py b/netsuite/soap_api/zeep.py new file mode 100644 index 0000000..6eb8e0f --- /dev/null +++ b/netsuite/soap_api/zeep.py @@ -0,0 +1,61 @@ +# Compatibility module - in case SOAP isn't enabled in library +try: + import zeep as __zeep # noqa +except ImportError: + ZEEP_INSTALLED = False +else: + ZEEP_INSTALLED = True + +if ZEEP_INSTALLED: + import requests + from zeep import * # noqa + from zeep import cache, client, helpers, transports, xsd +else: + + class _Transport: + ... + + class _BaseCache: + ... + + class _SqliteCache: + ... + + class _CompoundValue: + ... + + class _Client: + ... + + class _ServiceProxy: + ... + + class _Factory: + ... + + class _valueobjects: + CompoundValue = _CompoundValue + + class cache: # type: ignore[no-redef] + Base = _BaseCache + SqliteCache = _SqliteCache + + class client: # type: ignore[no-redef] + Client = _Client + AsyncClient = _Client + ServiceProxy = _ServiceProxy + Factory = _Factory + + class transports: # type: ignore[no-redef] + Transport = _Transport + AsyncTransport = _Transport + + class xsd: # type: ignore[no-redef] + CompoundValue = _CompoundValue + valueobjects = _valueobjects + + class helpers: # type: ignore[no-redef] + serialize_object = None + + class requests: # type: ignore[no-redef] + Session = None diff --git a/netsuite/util.py b/netsuite/util.py index 335ddcc..437592a 100644 --- a/netsuite/util.py +++ b/netsuite/util.py @@ -1,48 +1,28 @@ -import requests - -__all__ = ('cached_property', 'raise_for_status_with_body') - - -class cached_property: - """ Decorator that turns an instance method into a cached property - From https://speakerdeck.com/u/mitsuhiko/p/didntknow, slide #69 - """ - __NOT_SET = object() - - def __init__(self, func): - self.func = func - self.__name__ = func.__name__ - self.__doc__ = func.__doc__ - self.__module__ = func.__module__ - - def __get__(self, obj, type=None): - if obj is None: - return self - value = obj.__dict__.get(self.__name__, self.__NOT_SET) - if value is self.__NOT_SET: - value = self.func(obj) - obj.__dict__[self.__name__] = value - return value - - -def raise_for_status_with_body( - response, - on_bad_status=None -): - """Raise exception on bad HTTP status and capture response body - - Also: - * If an exception occurs the response body will be added to the - exception string. - * If `on_bad_status` is provided this function will run on a request - exception. - """ - try: - response.raise_for_status() - except requests.exceptions.RequestException as ex: - body = response.text - if body and len(ex.args) == 1: - ex.args = (ex.args[0] + f'\nBody: {body}', ) - if on_bad_status is not None: - on_bad_status() - raise ex +__all__ = ("cached_property",) + + +try: + from functools import cached_property # Python 3.8+ +except ImportError: + + class cached_property: # type: ignore[no-redef] + """Decorator that turns an instance method into a cached property + From https://speakerdeck.com/u/mitsuhiko/p/didntknow, slide #69 + """ + + __NOT_SET = object() + + def __init__(self, func): + self.func = func + self.__name__ = func.__name__ + self.__doc__ = func.__doc__ + self.__module__ = func.__module__ + + def __get__(self, obj, type=None): + if obj is None: + return self + value = obj.__dict__.get(self.__name__, self.__NOT_SET) + if value is self.__NOT_SET: + value = self.func(obj) + obj.__dict__[self.__name__] = value + return value diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12b665c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[tool.poetry] +name = "netsuite" +version = "0.9.0" +description = "Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets" +authors = ["Jacob Magnusson "] +license = "MIT" +readme = "README.md" +homepage = "https://jacobsvante.github.io/netsuite/" +repository = "https://github.com/jacobsvante/netsuite" +documentation = "https://jacobsvante.github.io/netsuite/" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", +] + +[tool.poetry.dependencies] +python = "^3.7" +authlib = "~1" +# As per httpx recommendation we will lock to a fixed minor version until 1.0 is released +httpx = "~0.23" +pydantic = "~1" +orjson = {version = "~3", optional = true} +ipython = {version = "~8", optional = true, python = "^3.8"} +zeep = {version = "~4", optional = true, extras = ["async"]} +oauthlib = "~3" + +[tool.poetry.extras] +soap_api = ["zeep"] +cli = ["ipython"] +orjson = ["orjson"] +all = ["zeep", "ipython", "orjson"] + +[tool.poetry.dev-dependencies] +black = "~22" +flake8 = "~4" +isort = "~5" +mkdocs-material = "~8" +mypy = "~0" +pytest = "~7" +pytest-cov = "~3" +types-setuptools = "^57.4.17" +types-requests = "^2.27.30" + +[tool.poetry.scripts] +netsuite = 'netsuite.cli:main' + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +target-version = ["py37", "py38", "py39", "py310"] + +[tool.isort] +profile = "black" +multi_line_output = 3 + +[tool.pytest.ini_options] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2a9acf1..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100755 index c64c46c..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -from setuptools import setup - -setup_kwargs = dict( - name='netsuite', - version='0.4.1', - description='Wrapper around Netsuite SuiteTalk Web Services and Restlets', - packages=['netsuite'], - include_package_data=True, - author='Jacob Magnusson', - author_email='m@jacobian.se', - url='https://github.com/jmagnusson/netsuite', - license='BSD', - platforms='any', - install_requires=[ - 'requests-oauthlib', - 'zeep', - ], - extras_require={ - 'cli': [ - 'argh', - 'ipython', - ], - 'test': { - 'coverage>=4.2', - 'flake8>=3.0.4', - 'mypy>=0.560', - 'pytest>=3.0.3', - 'responses>=0.5.1', - }, - }, - entry_points={ - 'console_scripts': [ - 'netsuite = netsuite.__main__:main', - ], - }, - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - ], -) - -if __name__ == '__main__': - setup(**setup_kwargs) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..959e312 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest + +from netsuite import Config + + +@pytest.fixture +def dummy_config(): + return Config( + account="123456_SB1", + auth={ + "consumer_key": "abcdefghijklmnopqrstuvwxyz0123456789", + "consumer_secret": "abcdefghijklmnopqrstuvwxyz0123456789", + "token_id": "abcdefghijklmnopqrstuvwxyz0123456789", + "token_secret": "abcdefghijklmnopqrstuvwxyz0123456789", + }, + ) diff --git a/tests/test_base.py b/tests/test_base.py deleted file mode 100644 index 6b7c0b8..0000000 --- a/tests/test_base.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest -import netsuite - - -@pytest.fixture -def dummy_config(): - return { - 'account': '123456', - 'consumer_key': 'abcdefghijklmnopqrstuvwxyz0123456789', - 'consumer_secret': 'abcdefghijklmnopqrstuvwxyz0123456789', - 'token_id': 'abcdefghijklmnopqrstuvwxyz0123456789', - 'token_secret': 'abcdefghijklmnopqrstuvwxyz0123456789', - } - - -def test_netsuite_hostname(dummy_config): - ns = netsuite.NetSuite(dummy_config) - assert ns.hostname == '123456.suitetalk.api.netsuite.com' - - -def test_netsuite_wsdl_url(dummy_config): - ns = netsuite.NetSuite(dummy_config) - assert ns.wsdl_url == 'https://123456.suitetalk.api.netsuite.com/wsdl/v2019_2_0/netsuite.wsdl' diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py new file mode 100644 index 0000000..39f1bd6 --- /dev/null +++ b/tests/test_rest_api.py @@ -0,0 +1,6 @@ +from netsuite import NetSuiteRestApi + + +def test_expected_hostname(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + assert rest_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" diff --git a/tests/test_restlet.py b/tests/test_restlet.py new file mode 100644 index 0000000..402823f --- /dev/null +++ b/tests/test_restlet.py @@ -0,0 +1,6 @@ +from netsuite import NetSuiteRestlet + + +def test_expected_hostname(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + assert restlet.hostname == "123456-sb1.restlets.api.netsuite.com" diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py new file mode 100644 index 0000000..710aed1 --- /dev/null +++ b/tests/test_soap_api.py @@ -0,0 +1,19 @@ +import pytest + +from netsuite import NetSuiteSoapApi +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def test_netsuite_hostname(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + assert soap_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" + + +def test_netsuite_wsdl_url(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + assert ( + soap_api.wsdl_url + == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" + ) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 9dbaa56..0000000 --- a/tox.ini +++ /dev/null @@ -1,25 +0,0 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = py36, py37, lint - -[testenv] -commands = - coverage run --source=netsuite -m py.test -deps = - .[cli,test] - -[testenv:lint] -commands = - flake8 netsuite tests -deps = - .[cli,test] - -[flake8] -ignore = - E501, - E711, - E712