diff --git a/.flake8 b/.flake8 index bf8d2ed..0448813 100644 --- a/.flake8 +++ b/.flake8 @@ -4,6 +4,8 @@ per-file-ignores = __init__.py: F401 max-line-length = 120 ignore = + # line break before binary operator + W503 # whitespace before ':' E203 # Missing Docstrings diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 41a282e..3cc5b97 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,8 +12,7 @@ on: - main jobs: - build: - + lint-test-coverage: runs-on: ubuntu-latest strategy: matrix: @@ -21,10 +20,12 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - name: Cache pip uses: actions/cache@v2 with: @@ -48,7 +49,53 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics - name: Run linting environment and pre-commit hooks run: | - tox -e linting - - name: Test with pytest and coverage via Tox - run: | - tox + tox -e lint + + + tox-coveralls: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.7 + uses: actions/setup-python@v2 + with: + python-version: 3.7 + + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox pytest pytest-cov coverage responses + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Tox testenv + run: | + tox + + - name: Pytest + run: | + pytest + + - name: Coveralls + uses: AndreMiras/coveralls-python-action@develop + with: + parallel: true + flag-name: Python Test Suite + + coveralls_finish: + needs: tox-coveralls + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55201e7..0039f46 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,20 +3,26 @@ repos: rev: 20.8b1 hooks: - id: black - args: [ "--safe", "--diff", "--color" ] + args: [ "--safe", "--diff", "--color"] + verbose: true + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: [ "--safe", "--quiet"] language_version: python3 - repo: https://github.com/pycqa/isort - rev: 5.6.4 + rev: 5.8.0 hooks: - id: isort args: ["--profile", "black", "--filter-files"] - repo: https://github.com/asottile/blacken-docs - rev: v1.9.2 + rev: v1.10.0 hooks: - id: blacken-docs additional_dependencies: [ black==20.8b1 ] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.0.1 hooks: - id: check-docstring-first - id: trailing-whitespace @@ -35,7 +41,7 @@ repos: - id: python-use-type-annotations - id: rst-backticks - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + rev: 3.9.2 hooks: - id: flake8 language_version: python3 diff --git a/README.md b/README.md index 2436b5f..1ad6593 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ ShipEngine SDK - Python ======================= ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/ShipEngine/shipengine-python/Python%20package?label=shipengine-python&logo=github&logoColor=white) +[![Coverage Status](https://coveralls.io/repos/github/ShipEngine/shipengine-python/badge.svg?branch=main)](https://coveralls.io/github/ShipEngine/shipengine-python?branch=main) ![OS Compatibility](https://shipengine.github.io/img/badges/os-badges.svg) > ATTN: This project is under development and not ready for consumer use. diff --git a/poetry.lock b/poetry.lock index f2f7c89..c1ff5ad 100644 --- a/poetry.lock +++ b/poetry.lock @@ -154,6 +154,39 @@ toml = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["toml"] +[[package]] +name = "coveralls" +version = "3.1.0" +description = "Show coverage stats online via coveralls.io" +category = "dev" +optional = false +python-versions = ">= 3.5" + +[package.dependencies] +coverage = ">=4.1,<6.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "dataclasses-json" +version = "0.5.3" +description = "Easily serialize dataclasses to and from JSON" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +marshmallow = ">=3.3.0,<4.0.0" +marshmallow-enum = ">=1.5.1,<2.0.0" +stringcase = "1.2.0" +typing-inspect = ">=0.4.0" + +[package.extras] +dev = ["pytest (>=6.2.3)", "ipython", "mypy (>=0.710)", "hypothesis", "portray", "flake8", "simplejson"] + [[package]] name = "distlib" version = "0.3.1" @@ -162,6 +195,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "docutils" version = "0.16" @@ -221,7 +262,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.0.1" +version = "4.2.0" description = "Read metadata from Python packages" category = "dev" optional = false @@ -270,6 +311,31 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "marshmallow" +version = "3.12.1" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (==0.812)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (==4.0.0)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.4)"] +lint = ["mypy (==0.812)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "marshmallow-enum" +version = "1.5.1" +description = "Enum field for Marshmallow" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +marshmallow = ">=2.0.0" + [[package]] name = "mccabe" version = "0.6.1" @@ -298,7 +364,7 @@ python-versions = ">=3.6" name = "mypy-extensions" version = "0.4.3" description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" +category = "main" optional = false python-versions = "*" @@ -491,6 +557,22 @@ urllib3 = ">=1.21.1,<1.27" security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +[[package]] +name = "responses" +version = "0.13.3" +description = "A utility library for mocking out the `requests` Python library." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +requests = ">=2.0" +six = "*" +urllib3 = ">=1.25.10" + +[package.extras] +tests = ["coverage (>=3.7.1,<6.0.0)", "pytest-cov", "pytest-localserver", "flake8", "pytest (>=4.6,<5.0)", "pytest (>=4.6)", "mypy"] + [[package]] name = "six" version = "1.16.0" @@ -609,6 +691,14 @@ python-versions = ">=3.5" lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] +[[package]] +name = "stringcase" +version = "1.2.0" +description = "String case converter." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "toml" version = "0.10.2" @@ -656,18 +746,30 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "typing-inspect" +version = "0.6.0" +description = "Runtime inspection utilities for typing module." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + [[package]] name = "urllib3" -version = "1.26.4" +version = "1.26.5" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] +brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "virtualenv" @@ -724,7 +826,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "d7f6fec96cf7c4dbffe42d5a228cde1ad3e26587965fda8eac92b4f92b4bb202" +content-hash = "d97d275553d9fb816a0b9191f3edf0a090c8d271d96a62e2cdbd60302e9a76f5" [metadata.files] aiohttp = [ @@ -867,10 +969,21 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] +coveralls = [ + {file = "coveralls-3.1.0-py2.py3-none-any.whl", hash = "sha256:172fb79c5f61c6ede60554f2cac46deff6d64ee735991fb2124fb414e188bdb4"}, + {file = "coveralls-3.1.0.tar.gz", hash = "sha256:9b3236e086627340bf2c95f89f757d093cbed43d17179d3f4fb568c347e7d29a"}, +] +dataclasses-json = [ + {file = "dataclasses-json-0.5.3.tar.gz", hash = "sha256:fe17da934cfc4ec792ebe7e9a303434ecf4f5f8d8a7705acfbbe7ccbd34bf1ae"}, + {file = "dataclasses_json-0.5.3-py3-none-any.whl", hash = "sha256:740e7b564d72ddaa0f66406b4ecb799447afda2799c1c425a4a76151bfcfda50"}, +] distlib = [ {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, ] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, @@ -896,8 +1009,8 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, - {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, + {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, + {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, ] isort = [ {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, @@ -943,6 +1056,14 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] +marshmallow = [ + {file = "marshmallow-3.12.1-py2.py3-none-any.whl", hash = "sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01"}, + {file = "marshmallow-3.12.1.tar.gz", hash = "sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040"}, +] +marshmallow-enum = [ + {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"}, + {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"}, +] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -1128,6 +1249,10 @@ requests = [ {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, ] +responses = [ + {file = "responses-0.13.3-py2.py3-none-any.whl", hash = "sha256:b54067596f331786f5ed094ff21e8d79e6a1c68ef625180a7d34808d6f36c11b"}, + {file = "responses-0.13.3.tar.gz", hash = "sha256:18a5b88eb24143adbf2b4100f328a2f5bfa72fbdacf12d97d41f07c26c45553d"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1164,6 +1289,9 @@ sphinxcontrib-serializinghtml = [ {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] +stringcase = [ + {file = "stringcase-1.2.0.tar.gz", hash = "sha256:48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1209,9 +1337,14 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] +typing-inspect = [ + {file = "typing_inspect-0.6.0-py2-none-any.whl", hash = "sha256:de08f50a22955ddec353876df7b2545994d6df08a2f45d54ac8c05e530372ca0"}, + {file = "typing_inspect-0.6.0-py3-none-any.whl", hash = "sha256:3b98390df4d999a28cf5b35d8b333425af5da2ece8a4ea9e98f71e7591347b4f"}, + {file = "typing_inspect-0.6.0.tar.gz", hash = "sha256:8f1b1dd25908dbfd81d3bebc218011531e7ab614ba6e5bf7826d887c834afab7"}, +] urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, + {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, + {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, ] virtualenv = [ {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, diff --git a/pyproject.toml b/pyproject.toml index 5036301..fc3d5df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ python = "^3.7" aiohttp = "^3.7.4" requests = "^2.25.1" python-dotenv = "^0.15.0" +dataclasses-json = "^0.5.3" [tool.poetry.dev-dependencies] pytest = "^4.6" @@ -23,19 +24,21 @@ coverage = "^5.5" pytest-cov = "^2.11.1" pre-commit = "^2.11.0" isort = "^5.8.0" +responses = "^0.13.3" +coveralls = "^3.1.0" [tool.black] line-length = 100 target-verstion = ["py37"] safe = true -quiet = true -diff = true -color = true [tool.isort] profile = "black" multi_line_output = 3 +[tool.coverage.run] +relative_files = true + [tool.poetry.scripts] [build-system] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc82309 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +aiohttp==3.7.4.post0; python_version >= "3.6" +async-timeout==3.0.1; python_full_version >= "3.5.3" and python_version >= "3.6" +attrs==21.2.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" +certifi==2020.12.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" +chardet==4.0.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" +dataclasses-json==0.5.3; python_version >= "3.6" +idna==2.10; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" +marshmallow==3.12.1; python_version >= "3.6" +marshmallow-enum==1.5.1; python_version >= "3.6" +multidict==5.1.0; python_version >= "3.6" +mypy-extensions==0.4.3; python_version >= "3.6" +python-dotenv==0.15.0 +requests==2.25.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") +stringcase==1.2.0; python_version >= "3.6" +typing-extensions==3.10.0.0; python_version < "3.8" and python_version >= "3.6" +typing-inspect==0.6.0; python_version >= "3.6" +urllib3==1.26.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version < "4" +yarl==1.6.3; python_version >= "3.6" diff --git a/shipengine_sdk/__init__.py b/shipengine_sdk/__init__.py index 6edf480..6751847 100644 --- a/shipengine_sdk/__init__.py +++ b/shipengine_sdk/__init__.py @@ -6,7 +6,7 @@ from logging import NullHandler # SDK imports here -from shipengine_sdk.shipengine import ShipEngine -from shipengine_sdk.shipengine_config import ShipEngineConfig +from .shipengine import ShipEngine +from .shipengine_config import ShipEngineConfig logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/shipengine_sdk/errors/__init__.py b/shipengine_sdk/errors/__init__.py index 8e77ec2..470f6c8 100644 --- a/shipengine_sdk/errors/__init__.py +++ b/shipengine_sdk/errors/__init__.py @@ -46,6 +46,9 @@ def _are_enums_valid(self): f"Error type must be a member of ErrorCode enum - [{self.error_code}] provided." ) + def to_dict(self): + return (lambda o: o.__dict__)(self) + def to_json(self): return json.dumps(self, default=lambda o: o.__dict__, indent=2) @@ -92,14 +95,15 @@ def __init__( class InvalidFieldValueError(ShipEngineError): - def __init__(self, field_name: str, reason: str, field_value) -> None: + def __init__(self, field_name: str, reason: str, field_value, source: str = None) -> None: """This error occurs when a field has been set to an invalid value.""" self.field_name = field_name self.field_value = field_value + self.source = source super(InvalidFieldValueError, self).__init__( request_id=None, - message=f"{self.field_name} - {reason}", - source=None, + message=f"{self.field_name} - {reason} {self.field_value} was provided.", + source=self.source, error_type=ErrorType.VALIDATION.value, error_code=ErrorCode.INVALID_FIELD_VALUE.value, ) diff --git a/shipengine_sdk/http_client/__init__.py b/shipengine_sdk/http_client/__init__.py index 0cb716b..96e8ffd 100644 --- a/shipengine_sdk/http_client/__init__.py +++ b/shipengine_sdk/http_client/__init__.py @@ -1 +1,2 @@ -"""Initial Docstring.""" +"""Synchronous HTTP Client for ShipEngine SDK.""" +from .client import ShipEngineClient diff --git a/shipengine_sdk/http_client/client.py b/shipengine_sdk/http_client/client.py index fa65d26..1fa5957 100644 --- a/shipengine_sdk/http_client/client.py +++ b/shipengine_sdk/http_client/client.py @@ -1 +1,125 @@ """A Python library for ShipEngine API.""" +import json +import os +import platform +from typing import Dict, Optional, Union + +import requests +from requests import PreparedRequest, Request, RequestException, Response, Session +from requests.adapters import HTTPAdapter +from requests.auth import AuthBase +from requests.packages.urllib3.util.retry import Retry + +from shipengine_sdk import __version__ +from shipengine_sdk.errors import ShipEngineError +from shipengine_sdk.jsonrpc.process_request import handle_response, wrap_request +from shipengine_sdk.models import ErrorCode, ErrorSource, ErrorType +from shipengine_sdk.shipengine_config import ShipEngineConfig +from shipengine_sdk.util.sdk_assertions import ( + is_response_404, + is_response_429, + is_response_500, +) + + +class ShipEngineAuth(AuthBase): + def __init__(self, api_key: str) -> None: + """Auth Base appends `Api-Key` header to all requests.""" + self.api_key = api_key + + def __call__(self, request: Request, *args, **kwargs) -> Request: + request.headers["Api-Key"] = self.api_key + return request + + +class ShipEngineClient: + _BASE_URI = "" + + def __init__(self) -> None: + """""" + self.session = requests.session() + + def send_rpc_request( + self, method: str, params: Optional[Dict[str, any]], retry: int, config: ShipEngineConfig + ) -> dict: + """ + Send a `JSON-RPC 2.0` request via HTTP Messages to ShipEngine API. If the response + * is successful, the result is returned. Otherwise, an error is thrown. + + TODO: add param and return docs + """ + # TODO: debug the below base_uri variable to verify ternary logic works as intended. + client: Session = self._request_retry_session(retries=config.retries) + base_uri: Union[str, None] = ( + config.base_uri + if os.getenv("CLIENT_BASE_URI") is None + else os.getenv("CLIENT_BASE_URI") + ) + + request_headers: dict = { + "User-Agent": self._derive_user_agent(), + "Content-Type": "application/json", + "Accept": "application/json", + } + + request_body: dict = wrap_request(method=method, params=params) + + req: Request = Request( + method="POST", + url=base_uri, + data=json.dumps(request_body), + headers=request_headers, + auth=ShipEngineAuth(config.api_key), + ) + prepared_req: PreparedRequest = req.prepare() + + try: + resp: Response = client.send(request=prepared_req, timeout=config.timeout) + except RequestException as err: + raise ShipEngineError( + message=f"An unknown error occurred while calling the ShipEngine {method} API:\n {err.response}", + source=ErrorSource.SHIPENGINE.value, + error_type=ErrorType.SYSTEM.value, + error_code=ErrorCode.UNSPECIFIED.value, + ) + + resp_body = resp.json() + status_code = resp.status_code + + is_response_404(status_code=status_code, response_body=resp_body) + is_response_429(status_code=status_code, response_body=resp_body, config=config) + is_response_500(status_code=status_code, response_body=resp_body) + + return handle_response(resp.json()) + + def _request_retry_session( + self, retries: int = 1, backoff_factor=1, status_force_list=(429, 500, 502, 503, 504) + ) -> Session: + """A requests `Session()` that has retries enforced.""" + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_force_list, + ) + adapter = HTTPAdapter(max_retries=retry) + self.session.mount("http://", adapter=adapter) + self.session.mount("https://", adapter=adapter) + return self.session + + @staticmethod + def _derive_user_agent() -> str: + """ + Derive a User-Agent header from the environment. This is the user-agent that will + be set on every request via the ShipEngine Client. + + :returns: A user-agent string that will be set in the `ShipEngineClient` request headers. + :rtype: str + """ + sdk_version = f"shipengine-python/{__version__}" + os_kernel = platform.platform(terse=True) + python_version = platform.python_version() + python_implementation = platform.python_implementation() + + return f"{sdk_version} {os_kernel} {python_version} {python_implementation}" diff --git a/shipengine_sdk/jsonrpc/__init__.py b/shipengine_sdk/jsonrpc/__init__.py new file mode 100644 index 0000000..351f26f --- /dev/null +++ b/shipengine_sdk/jsonrpc/__init__.py @@ -0,0 +1,44 @@ +""" +A collection of methods that provide `JSON-RPC 2.0` HTTP client +functionality for sending HTTP requests from the ShipEngine SDK. +""" +import time +from typing import Dict, Optional + +from shipengine_sdk.errors import RateLimitExceededError +from shipengine_sdk.http_client import ShipEngineClient +from shipengine_sdk.shipengine_config import ShipEngineConfig + +from .process_request import handle_response, wrap_request + + +def rpc_request( + method: str, config: ShipEngineConfig, params: Optional[Dict[str, any]] = None +) -> dict: + """ + Create and send a `JSON-RPC 2.0` request over HTTP messages. + TODO: add param and return docs + """ + return rpc_request_loop(method, params, config) + + +def rpc_request_loop(method: str, params: dict, config: ShipEngineConfig) -> dict: + client = ShipEngineClient() + api_response = None + retry: int = 0 + while retry <= config.retries: + try: + api_response = client.send_rpc_request( + method=method, params=params, retry=retry, config=config + ) + except Exception as err: + if ( + retry < config.retries + and type(err) is RateLimitExceededError + and err.retry_after < config.timeout + ): + time.sleep(err.retry_after) + else: + raise err + retry += 1 + return api_response diff --git a/shipengine_sdk/jsonrpc/process_request.py b/shipengine_sdk/jsonrpc/process_request.py new file mode 100644 index 0000000..01bc16b --- /dev/null +++ b/shipengine_sdk/jsonrpc/process_request.py @@ -0,0 +1,89 @@ +"""Functions that help with process requests and handle responses.""" +from typing import Dict, Optional +from uuid import uuid4 + +from shipengine_sdk.errors import ( + AccountStatusError, + BusinessRuleError, + ClientSecurityError, + ClientSystemError, + ShipEngineError, + ValidationError, +) +from shipengine_sdk.models import ErrorType + + +def wrap_request(method: str, params: Optional[Dict[str, any]]) -> dict: + """ + Wrap request per `JSON-RPC 2.0` spec. + + :param str method: The RPC Method to be sent to the RPC Server to + invoke a specific remote procedure. + :param params: The request data for the RPC request. This argument + is optional and can either be a dictionary or None. + :type params: Optional[Dict[str, any]] + """ + if params is None: + return dict(id=f"req_{str(uuid4()).replace('-', '')}", jsonrpc="2.0", method=method) + else: + return dict( + id=f"req_{str(uuid4()).replace('-', '')}", jsonrpc="2.0", method=method, params=params + ) + + +def handle_response(response_body: dict) -> dict: + """Handles the response from ShipEngine API.""" + if "result" in response_body: + return response_body + + error = response_body["error"] + error_data = error["data"] + error_type = error_data["type"] + if error_type is ErrorType.ACCOUNT_STATUS.value: + raise AccountStatusError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) + elif error_type is ErrorType.SECURITY.value: + raise ClientSecurityError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) + elif error_type is ErrorType.VALIDATION.value: + raise ValidationError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) + elif error_type is ErrorType.BUSINESS_RULES.value: + raise BusinessRuleError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) + elif error_type is ErrorType.SYSTEM.value: + raise ClientSystemError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) + else: + raise ShipEngineError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) diff --git a/shipengine_sdk/models/__init__.py b/shipengine_sdk/models/__init__.py index ff6f842..c8d770b 100644 --- a/shipengine_sdk/models/__init__.py +++ b/shipengine_sdk/models/__init__.py @@ -1,4 +1,2 @@ """ShipEngine SDK Models & Enumerations""" -from shipengine_sdk.models.enums import ErrorCode -from shipengine_sdk.models.enums import ErrorSource -from shipengine_sdk.models.enums import ErrorType +from shipengine_sdk.models.enums import ErrorCode, ErrorSource, ErrorType diff --git a/shipengine_sdk/models/address/__init__.py b/shipengine_sdk/models/address/__init__.py new file mode 100644 index 0000000..0aacafd --- /dev/null +++ b/shipengine_sdk/models/address/__init__.py @@ -0,0 +1,32 @@ +"""Initial Docstring""" + +from dataclasses import dataclass +from typing import List, Optional + +from dataclasses_json import LetterCase, dataclass_json + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Address: + street: List[str] + city_locality: str + state_province: str + postal_code: str + country_code: str + is_residential: bool = False + name: str = "" + phone: str = "" + company: str = "" + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass(frozen=True) +class AddressValidateResult: + is_valid: Optional[bool] + request_id: str + normalized_address: Optional[Address] + messages: List + # info: List + # warnings: List + # errors: List diff --git a/shipengine_sdk/models/enums/__init__.py b/shipengine_sdk/models/enums/__init__.py index 9c1e29f..0d630fc 100644 --- a/shipengine_sdk/models/enums/__init__.py +++ b/shipengine_sdk/models/enums/__init__.py @@ -7,5 +7,16 @@ class Endpoints(Enum): + """API Endpoint URI's used throughout the ShipEngine SDK.""" + TEST_RPC_URL = "https://simengine.herokuapp.com/jsonrpc" SHIPENGINE_RPC_URL = "https://api.shipengine.com/jsonrpc" + + +class RPCMethods(Enum): + """A collection of RPC Methods used throughout the ShipEngine SDK.""" + + ADDRESS_VALIDATE = "address.validate.v1" + CREATE_TAG = "create.tag.v1" + LIST_CARRIERS = "carrier.listAccounts.v1" + TRACK_PACKAGE = "package.track.v1" diff --git a/shipengine_sdk/services/__init__.py b/shipengine_sdk/services/__init__.py index 0cb716b..a677db9 100644 --- a/shipengine_sdk/services/__init__.py +++ b/shipengine_sdk/services/__init__.py @@ -1 +1 @@ -"""Initial Docstring.""" +"""ShipEngine SDK service objects.""" diff --git a/shipengine_sdk/services/address_validation.py b/shipengine_sdk/services/address_validation.py new file mode 100644 index 0000000..5f0bbec --- /dev/null +++ b/shipengine_sdk/services/address_validation.py @@ -0,0 +1,33 @@ +"""Validate a single address or multiple addresses.""" +from shipengine_sdk.jsonrpc import rpc_request +from shipengine_sdk.models.address import Address, AddressValidateResult +from shipengine_sdk.models.enums import RPCMethods +from shipengine_sdk.shipengine_config import ShipEngineConfig + + +def validate(address: Address, config: ShipEngineConfig) -> AddressValidateResult: + """ + Validate a single address via the `address/validate` remote procedure. + + :param Address address: The address to be validate. + :param ShipEngineConfig config: The global ShipEngine configuration object. + :returns: :class:`AddressValidateResult`: The response from ShipEngine API including the + validated and normalized address. + """ + api_response: dict = rpc_request( + method=RPCMethods.ADDRESS_VALIDATE.value, + config=config, + params={"address": address.to_dict()}, + ) + result = api_response["result"] + return AddressValidateResult( + is_valid=result["isValid"], + request_id=api_response["id"], + normalized_address=Address.from_dict(result["normalizedAddress"]), + messages=result["messages"], + ) + + +def normalize(address: Address, config: ShipEngineConfig) -> Address: + validation_result = validate(address=address, config=config) + return validation_result.normalized_address diff --git a/shipengine_sdk/shipengine.py b/shipengine_sdk/shipengine.py index 939a663..10bcb34 100644 --- a/shipengine_sdk/shipengine.py +++ b/shipengine_sdk/shipengine.py @@ -1,6 +1,8 @@ """The entrypoint to the ShipEngine API SDK.""" -from typing import Dict -from typing import Union +from typing import Dict, Union + +from shipengine_sdk.models.address import Address, AddressValidateResult +from shipengine_sdk.services.address_validation import validate from .shipengine_config import ShipEngineConfig @@ -13,7 +15,7 @@ class ShipEngine: unless specifically overridden when calling a method. """ - def __init__(self, config: Union[str, Dict[str, any]]) -> None: + def __init__(self, config: Union[str, Dict[str, any], ShipEngineConfig]) -> None: """ Exposes the functionality of the ShipEngine API. @@ -25,3 +27,17 @@ def __init__(self, config: Union[str, Dict[str, any]]) -> None: self.config = ShipEngineConfig({"api_key": config}) elif type(config) is dict: self.config = ShipEngineConfig(config) + + def validate_address( + self, address: Address, config: Union[Dict[str, any], ShipEngineConfig] = None + ) -> AddressValidateResult: + """ + Validate an address in nearly any countryCode in the world. + + :param Address address: The address to be validate. + :param ShipEngineConfig config: The global ShipEngine configuration object. + :returns: :class:`AddressValidateResult`: The response from ShipEngine API including the + validated and normalized address. + """ + config = self.config.merge(new_config=config) + return validate(address, config) diff --git a/shipengine_sdk/shipengine_config.py b/shipengine_sdk/shipengine_config.py index 5c8418a..14af331 100644 --- a/shipengine_sdk/shipengine_config.py +++ b/shipengine_sdk/shipengine_config.py @@ -1,9 +1,10 @@ """The global configuration object for the ShipEngine SDK.""" import json +from typing import Dict, Optional from shipengine_sdk.models.enums import Endpoints -from shipengine_sdk.util import is_api_key_valid -from shipengine_sdk.util import is_retries_less_than_zero +from shipengine_sdk.util import is_api_key_valid, is_retries_valid +from shipengine_sdk.util.sdk_assertions import is_timeout_valid class ShipEngineConfig: @@ -28,6 +29,7 @@ def __init__(self, config: dict) -> None: is_api_key_valid(config) self.api_key = config["api_key"] + is_timeout_valid(config) if "timeout" in config: self.timeout = config["timeout"] else: @@ -43,14 +45,14 @@ def __init__(self, config: dict) -> None: else: self.page_size = self.DEFAULT_PAGE_SIZE - is_retries_less_than_zero(config) + is_retries_valid(config) if "retries" in config: self.retries = config["retries"] else: self.retries = self.DEFAULT_RETRIES # TODO: add event listener to config object once it"s implemented. - def merge(self, new_config: dict = None): + def merge(self, new_config: Optional[Dict[str, any]] = None): """ The method allows the merging of a method-level configuration adjustment into the current configuration. @@ -84,5 +86,8 @@ def merge(self, new_config: dict = None): return ShipEngineConfig(config) + def to_dict(self): + return (lambda o: o.__dict__)(self) + def to_json(self): return json.dumps(self, default=lambda o: o.__dict__, indent=2) diff --git a/shipengine_sdk/util/__init__.py b/shipengine_sdk/util/__init__.py index f77d2be..d321a47 100644 --- a/shipengine_sdk/util/__init__.py +++ b/shipengine_sdk/util/__init__.py @@ -1,6 +1,19 @@ -"""Initial Docstring""" +"""Testing a string manipulation helper function.""" from shipengine_sdk.util.sdk_assertions import ( api_key_validation_error_assertions, is_api_key_valid, - is_retries_less_than_zero, + is_retries_valid, ) + + +def snake_to_camel(snake_case_string: str) -> str: + """ + Takes in a `snake_case` string and returns a `camelCase` string. + + :params str snake_case_string: The snake_case string to be converted + into camelCase. + :returns: camelCase string + :rtype: str + """ + initial, *temp = snake_case_string.split("_") + return "".join([initial.lower(), *map(str.title, temp)]) diff --git a/shipengine_sdk/util/sdk_assertions.py b/shipengine_sdk/util/sdk_assertions.py index 70857c4..fc2d4df 100644 --- a/shipengine_sdk/util/sdk_assertions.py +++ b/shipengine_sdk/util/sdk_assertions.py @@ -1,34 +1,144 @@ """Assertion helper functions.""" -from shipengine_sdk.errors import InvalidFieldValueError, ValidationError +import re + +from shipengine_sdk.errors import ( + ClientSystemError, + ClientTimeoutError, + InvalidFieldValueError, + RateLimitExceededError, + ValidationError, +) from shipengine_sdk.models import ErrorCode, ErrorSource, ErrorType def is_api_key_valid(config: dict) -> None: - """Check if API Key is set and is not empty or whitespace.""" + """ + Check if API Key is set and is not empty or whitespace. + + :param dict config: The config dictionary passed into `ShipEngineConfig`. + :returns: None, only raises exceptions. + :rtype: None + """ + message = "A ShipEngine API key must be specified." if "api_key" not in config or config["api_key"] == "": raise ValidationError( - message="A ShipEngine API key must be specified.", + message=message, source=ErrorSource.SHIPENGINE.value, error_type=ErrorType.VALIDATION.value, error_code=ErrorCode.FIELD_VALUE_REQUIRED.value, ) + if re.match(r"\s", config["api_key"]): + raise ValidationError( + message=message, + source=ErrorSource.SHIPENGINE.value, + error_type=ErrorType.VALIDATION.value, + error_code=ErrorCode.FIELD_VALUE_REQUIRED.value, + ) -def is_retries_less_than_zero(config: dict) -> None: - """Checks that config.retries is less than zero.""" + +def is_retries_valid(config: dict) -> None: + """ + Checks that config.retries is a valid value. + + :param dict config: The config dictionary passed into `ShipEngineConfig`. + :returns: None, only raises exceptions. + :rtype: None + """ if "retries" in config and config["retries"] < 0: raise InvalidFieldValueError( field_name="retries", reason="Retries must be zero or greater.", field_value=config["retries"], + source=ErrorSource.SHIPENGINE.value, + ) + + +def is_timeout_valid(config: dict) -> None: + """ + Checks that config.timeout is valid value. + + :param dict config: The config dictionary passed into `ShipEngineConfig`. + :returns: None, only raises exceptions. + :rtype: None + """ + if "timeout" in config and config["timeout"] < 0: + raise InvalidFieldValueError( + field_name="timeout", + reason="Timeout must be zero or greater.", + field_value=config["timeout"], + source=ErrorSource.SHIPENGINE.value, ) -def api_key_validation_error_assertions(e: ValidationError): - """Helper test function that has common assertions pertaining to ValidationErrors.""" - assert type(e) is ValidationError - assert e.request_id is None - assert e.error_type is ErrorType.VALIDATION.value - assert e.error_code is ErrorCode.FIELD_VALUE_REQUIRED.value - assert e.source is ErrorSource.SHIPENGINE.value - assert e.message == "A ShipEngine API key must be specified." +def api_key_validation_error_assertions(error) -> None: + """ + Helper test function that has common assertions pertaining to ValidationErrors. + + :param error: The error to execute assertions on. + :returns: None, only executes assertions. + :rtype: None + """ + assert type(error) is ValidationError + assert error.request_id is None + assert error.error_type is ErrorType.VALIDATION.value + assert error.error_code is ErrorCode.FIELD_VALUE_REQUIRED.value + assert error.source is ErrorSource.SHIPENGINE.value + assert error.message == "A ShipEngine API key must be specified." + + +def timeout_validation_error_assertions(error) -> None: + """Helper test function that has common assertions pertaining to InvalidFieldValueError.""" + assert type(error) is InvalidFieldValueError + assert error.request_id is None + assert error.error_type is ErrorType.VALIDATION.value + assert error.error_code is ErrorCode.INVALID_FIELD_VALUE.value + assert error.source is ErrorSource.SHIPENGINE.value + + +def is_response_404(status_code: int, response_body: dict) -> None: + """Check if status_code is 404 and raises an error if so.""" + if "error" in response_body: + error = response_body["error"] + error_data = error["data"] + if status_code == 404: + raise ClientSystemError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) + + +def is_response_429(status_code: int, response_body: dict, config) -> None: + """Check if status_code is 429 and raises an error if so.""" + if "error" in response_body and status_code == 429: + error = response_body["error"] + retry_after = error["data"]["retryAfter"] + if retry_after > config.timeout: + raise ClientTimeoutError( + retry_after=config.timeout, + source=ErrorSource.SHIPENGINE.value, + request_id=response_body["id"], + ) + else: + raise RateLimitExceededError( + retry_after=retry_after, + source=ErrorSource.SHIPENGINE.value, + request_id=response_body["id"], + ) + + +def is_response_500(status_code: int, response_body: dict) -> None: + """Check if the status code is 500 and raises an error if so.""" + if status_code == 500: + error = response_body["error"] + error_data = error["data"] + raise ClientSystemError( + message=error["message"], + request_id=response_body["id"], + source=error_data["source"], + error_type=error_data["type"], + error_code=error_data["code"], + ) diff --git a/tests/errors/test_errors.py b/tests/errors/test_errors.py index d763ac6..4b5c51d 100644 --- a/tests/errors/test_errors.py +++ b/tests/errors/test_errors.py @@ -1,14 +1,16 @@ """Tests for the ShipEngine SDK Errors""" import pytest -from shipengine_sdk.errors import AccountStatusError -from shipengine_sdk.errors import BusinessRuleError -from shipengine_sdk.errors import ClientSecurityError -from shipengine_sdk.errors import ClientTimeoutError -from shipengine_sdk.errors import InvalidFieldValueError -from shipengine_sdk.errors import RateLimitExceededError -from shipengine_sdk.errors import ShipEngineError -from shipengine_sdk.errors import ValidationError +from shipengine_sdk.errors import ( + AccountStatusError, + BusinessRuleError, + ClientSecurityError, + ClientTimeoutError, + InvalidFieldValueError, + RateLimitExceededError, + ShipEngineError, + ValidationError, +) def shipengine_error(): diff --git a/tests/http_client/test_http_client.py b/tests/http_client/test_http_client.py new file mode 100644 index 0000000..e7c0bfe --- /dev/null +++ b/tests/http_client/test_http_client.py @@ -0,0 +1,48 @@ +"""Testing basic ShipEngineClient functionality.""" +import pytest +import responses + +from shipengine_sdk import ShipEngine +from shipengine_sdk.errors import ClientSystemError +from shipengine_sdk.models.address import Address +from shipengine_sdk.models.enums import Endpoints + + +def get_500_server_error(): + error_address = Address( + street=["500 Server Error"], + city_locality="Boston", + state_province="MA", + postal_code="02215", + country_code="US", + ) + shipengine = ShipEngine( + dict( + api_key="baz", + base_uri=Endpoints.TEST_RPC_URL.value, + page_size=50, + retries=2, + timeout=10, + ) + ) + return shipengine.validate_address(error_address) + + +@responses.activate +def test_500_server_response(): + responses.add( + responses.POST, + Endpoints.TEST_RPC_URL.value, + json={ + "jsonrpc": "2.0", + "id": "req_DezVNUvRkAP819f3JeqiuS", + "error": { + "code": "-32603", + "message": "Unable to connect to the database", + "data": {"source": "shipengine", "type": "system", "code": "unspecified"}, + }, + }, + status=500, + ) + with pytest.raises(ClientSystemError): + get_500_server_error() diff --git a/tests/services/test_address_validation.py b/tests/services/test_address_validation.py new file mode 100644 index 0000000..e130959 --- /dev/null +++ b/tests/services/test_address_validation.py @@ -0,0 +1,55 @@ +"""Initial Docstring""" +from shipengine_sdk import ShipEngine +from shipengine_sdk.models.address import Address, AddressValidateResult +from shipengine_sdk.models.enums import Endpoints + + +def stub_config() -> dict: + """ + Return a test configuration dictionary to be used + when instantiating the ShipEngine object. + """ + return dict( + api_key="baz", base_uri=Endpoints.TEST_RPC_URL.value, page_size=50, retries=2, timeout=15 + ) + + +def stub_shipengine_instance() -> ShipEngine: + """Return a test instance of the ShipEngine object.""" + return ShipEngine(stub_config()) + + +def valid_residential_address() -> Address: + """ + Return a test Address object with valid residential + address information. + """ + return Address( + street=["4 Jersey St", "Apt. 2b"], + city_locality="Boston", + state_province="MA", + postal_code="02215", + country_code="US", + ) + + +class TestValidateAddress: + def test_valid_residential_address(self): + """DX-1024 - Valid residential address""" + shipengine = stub_shipengine_instance() + valid_address = valid_residential_address() + validated_address = shipengine.validate_address(valid_address) + address = validated_address.normalized_address + + assert type(validated_address) is AddressValidateResult + assert validated_address.is_valid is True + assert address is not None + assert ( + address.street[0] + == (valid_address.street[0] + " " + valid_address.street[1]).replace(".", "").upper() + ) + assert address.city_locality == valid_address.city_locality.upper() + assert address.state_province == valid_address.state_province.upper() + assert address.postal_code == valid_address.postal_code + assert address.country_code == valid_address.country_code.upper() + assert address.is_residential is True diff --git a/tests/test___init__.py b/tests/test___init__.py new file mode 100644 index 0000000..b2f5f60 --- /dev/null +++ b/tests/test___init__.py @@ -0,0 +1,8 @@ +"""Initial Docstring""" +from shipengine_sdk.util import snake_to_camel + + +class TestSnakeToCamelCase: + def test_snake_to_camel(self): + camel_case = snake_to_camel("python_is_awesome") + assert camel_case == "pythonIsAwesome" diff --git a/tests/test_shipengine.py b/tests/test_shipengine.py index efc5b85..9d549dc 100644 --- a/tests/test_shipengine.py +++ b/tests/test_shipengine.py @@ -6,19 +6,19 @@ from shipengine_sdk.util.sdk_assertions import api_key_validation_error_assertions -def shipengine_no_api_key(): +def shipengine_no_api_key() -> ShipEngine: """Return an error from no API Key.""" return ShipEngine(dict(retries=2)) -def shipengine_empty_api_key(): +def shipengine_empty_api_key() -> ShipEngine: """Return an error from empty API Key.""" return ShipEngine(config="") -def shipengine_whitespace_in_api_key(): +def shipengine_whitespace_in_api_key() -> ShipEngine: """Return an error from whitespace in API Key.""" - return ShipEngine(config=" ") + return ShipEngine(config=" ") class TestShipEngine: @@ -30,7 +30,7 @@ def test_no_api_key_provided(self) -> None: """DX-1440 - No API Key at instantiation.""" try: shipengine_no_api_key() - except ValidationError as e: + except Exception as e: with pytest.raises(ValidationError): shipengine_no_api_key() api_key_validation_error_assertions(e) @@ -39,7 +39,7 @@ def test_empty_api_key_provided(self) -> None: """DX-1441 - Empty API Key at instantiation.""" try: shipengine_empty_api_key() - except ValidationError as e: + except Exception as e: with pytest.raises(ValidationError): shipengine_empty_api_key() api_key_validation_error_assertions(e) diff --git a/tests/test_shipengine_config.py b/tests/test_shipengine_config.py index cabc6e2..562dd5f 100644 --- a/tests/test_shipengine_config.py +++ b/tests/test_shipengine_config.py @@ -1,32 +1,115 @@ """Testing the ShipEngineConfig object.""" import pytest -from shipengine_sdk import ShipEngineConfig -from shipengine_sdk.errors import ValidationError +from shipengine_sdk import ShipEngine, ShipEngineConfig +from shipengine_sdk.errors import InvalidFieldValueError, ValidationError +from shipengine_sdk.models.address import Address +from shipengine_sdk.models.enums import Endpoints from shipengine_sdk.util import api_key_validation_error_assertions +from shipengine_sdk.util.sdk_assertions import timeout_validation_error_assertions -def config_with_no_api_key(): +def stub_config() -> dict: + """ + Return a test configuration dictionary to be used + when instantiating the ShipEngine object. + """ + return dict( + api_key="baz", base_uri=Endpoints.TEST_RPC_URL.value, page_size=50, retries=2, timeout=15 + ) + + +def valid_residential_address() -> Address: + """ + Return a test Address object with valid residential + address information. + """ + return Address( + street=["4 Jersey St", "Apt. 2b"], + city_locality="Boston", + state_province="MA", + postal_code="02215", + country_code="US", + ) + + +def config_with_no_api_key() -> ShipEngineConfig: """Return an error from no API Key.""" return ShipEngineConfig(dict(retries=2)) -def config_with_empty_api_key(): +def config_with_empty_api_key() -> ShipEngineConfig: """Return an error from empty API Key.""" return ShipEngineConfig(dict(api_key="")) -def config_with_whitespace_in_api_key(): +def config_with_whitespace_in_api_key() -> ShipEngineConfig: """Return an error from whitespace in API Key.""" return ShipEngineConfig(dict(api_key=" ")) +def set_config_timeout(timeout: int) -> ShipEngineConfig: + """ + Return an error from an invalid timeout value being passed in or + returns the successfully created `ShipEngineConfig` object if valid + configuration values are passed in. + + :param int timeout: The timeout to be passed into the `ShipEngineConfig` object. + :returns: :class:`ShipEngineConfig`: Global configuration object for the ShipEngine SDK. + :raises: :class:`InvalidFieldValueError`: If invalid value is passed into `ShipEngineConfig` + object at instantiation. + """ + return ShipEngineConfig(dict(api_key="baz", timeout=timeout)) + + +def set_config_retries(retries: int) -> ShipEngineConfig: + """ + Return a ShipEngineConfig object with the set retries and + API Key, where the rest of the configuration values are + the default values. + + :param int retries: The retries to be passed into the `ShipEngineConfig` object. + :returns: :class:`ShipEngineConfig`: Global configuration object for the ShipEngine SDK. + :raises: :class:`InvalidFieldValueError`: If invalid value is passed into `ShipEngineConfig` + object at instantiation. + """ + return ShipEngineConfig(dict(api_key="baz", retries=retries)) + + +def complete_valid_config() -> ShipEngineConfig: + """ + Return a `ShipEngineConfig` object that has valid custom + values passed in. + """ + return ShipEngineConfig( + dict( + api_key="baz", + base_uri=Endpoints.TEST_RPC_URL.value, + page_size=50, + retries=2, + timeout=10, + ) + ) + + class TestShipEngineConfig: + def test_valid_custom_config(self): + """ + Test case where a config object has been passed custom + valid values for each attribute. + """ + valid_config: ShipEngineConfig = complete_valid_config() + assert valid_config.api_key == "baz" + assert valid_config.base_uri is Endpoints.TEST_RPC_URL.value + assert valid_config.page_size == 50 + assert valid_config.retries == 2 + assert valid_config.timeout == 10 + def test_no_api_key_provided(self) -> None: """DX-1440 - No API Key at instantiation""" try: config_with_no_api_key() - except ValidationError as e: + except Exception as e: api_key_validation_error_assertions(e) with pytest.raises(ValidationError): config_with_no_api_key() @@ -35,7 +118,77 @@ def test_empty_api_key_provided(self) -> None: """DX-1441 - Empty API Key at instantiation.""" try: config_with_empty_api_key() - except ValidationError as e: + except Exception as e: api_key_validation_error_assertions(e) with pytest.raises(ValidationError): config_with_empty_api_key() + + def test_valid_retries(self): + """Test case where a valid value is passed in for the retries.""" + retries = 2 + valid_retries = set_config_retries(retries) + assert valid_retries.api_key == "baz" + assert valid_retries.retries == retries + + def test_invalid_retries_provided(self): + """DX-1442 - Invalid retries at instantiation.""" + retries = -3 + try: + set_config_retries(retries) + except InvalidFieldValueError as e: + timeout_validation_error_assertions(e) + assert ( + e.message == f"retries - Retries must be zero or greater. {retries} was provided." + ) + with pytest.raises(InvalidFieldValueError): + set_config_retries(retries) + + def test_invalid_timeout_provided(self): + """DX-1443 - Invalid timeout at instantiation.""" + timeout = -5 + try: + set_config_timeout(timeout) + except InvalidFieldValueError as e: + timeout_validation_error_assertions(e) + assert ( + e.message == f"timeout - Timeout must be zero or greater. {timeout} was provided." + ) + + def test_invalid_timeout_in_method_call(self): + """DX-1447 - Invalid timeout in method call configuration.""" + timeout = -5 + try: + shipengine = ShipEngine(stub_config()) + shipengine.validate_address( + address=valid_residential_address(), config=dict(timeout=timeout) + ) + except InvalidFieldValueError as e: + timeout_validation_error_assertions(e) + assert ( + e.message == f"timeout - Timeout must be zero or greater. {timeout} was provided." + ) + + def test_invalid_retries_in_method_call(self): + """DX-1446 - Invalid retries in method call configuration.""" + retries = -5 + try: + shipengine = ShipEngine(stub_config()) + shipengine.validate_address( + address=valid_residential_address(), config=dict(retries=retries) + ) + except InvalidFieldValueError as e: + timeout_validation_error_assertions(e) + assert ( + e.message == f"retries - Retries must be zero or greater. {retries} was provided." + ) + + def test_invalid_api_key_in_method_call(self): + """DX-1445 - Invalid api_key in method call configuration.""" + api_key = " " + try: + shipengine = ShipEngine(stub_config()) + shipengine.validate_address( + address=valid_residential_address(), config=dict(api_key=api_key) + ) + except Exception as e: + api_key_validation_error_assertions(e) diff --git a/tox.ini b/tox.ini index 75091d6..114a92e 100644 --- a/tox.ini +++ b/tox.ini @@ -17,22 +17,34 @@ basepython = python3.7 [testenv] description = run tests with pytest under {basepython} +passenv = + GITHUB_TOKEN + GITHUB_ACTION + GITHUB_REPOSITORY + GITHUB_RUN_ID setenv = PIP_DISABLE_PIP_VERSION_CHECK = 1 COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} + COVERALLS_REPO_TOKEN = GITHUB_TOKEN changedir = tests deps = pytest pytest-cov flake8 coverage + coveralls + responses commands = pytest {posargs:} +; coveralls --submit={toxworkdir}/.coverage.{envname} [testenv:lint] skip_install = True deps = pre-commit>=2.9.3 -commands = pre-commit run --all-files --show-diff-on-failure {posargs:} +commands = pre-commit run --show-diff-on-failure {posargs:} + +[coverage:run] +relative_files = True [flake8] max-line-length = 100 @@ -40,6 +52,8 @@ per-file-ignores = ; imported but unused __init__.py: F401 extend-ignore = + ; line break before binary operator + W503 ; whitespace before ':' E203 ; Missing Docstrings