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