diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 56388aa..e4c4118 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -10,13 +10,81 @@ on: - cron: '31 13 * * 2' jobs: - build: + # ── Offline unit tests ──────────────────────────────────────────────────── + # Run on every supported Python version. No secrets required. + # Skips tests marked `live` or `integration` (see pytest.ini addopts). + test-offline: + name: "Unit tests – Python ${{ matrix.python-version }}" + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v7 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade setuptools pip pipenv + pipenv install --skip-lock --dev -e . + - name: Lint with ruff + run: | + # stop the build if there are Python syntax errors or undefined names + pipenv run ruff check . --select=E9,F63,F7,F82 --output-format=full + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + pipenv run ruff check . --exit-zero + - name: Test with pytest (offline only) + run: | + pipenv run py.test \ + --cov-config .coveragerc \ + --cov-report xml:output/coverage.xml \ + --cov mygeotab \ + --junitxml output/python${{ matrix.python-version }}-test-results.xml \ + tests/ + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./output/coverage.xml + flags: "py${{ matrix.python-version }}" + - name: Archive test results + uses: actions/upload-artifact@v7 + with: + name: "test-results-py${{ matrix.python-version }}" + path: output + + # ── Live network tests ───────────────────────────────────────────────────── + # Unauthenticated calls to public Geotab servers (GetVersion). + # Runs on a single Python version; does not need DB credentials. + test-live: + name: "Live network tests – Python 3.12" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade setuptools pip pipenv + pipenv install --skip-lock --dev -e . + - name: Run live server-call tests + run: | + pipenv run py.test -m live tests/ + + # ── Credentialed integration tests ──────────────────────────────────────── + # Requires GitHub environment "test" with DB secrets. Runs sequentially + # (max-parallel: 1) to avoid hammering the shared test account. + test-integration: + name: "Integration tests – Python 3.12" runs-on: ubuntu-22.04 environment: test strategy: max-parallel: 1 - matrix: - python-version: [3.9] env: MYGEOTAB_DATABASE: ${{ secrets.MYGEOTAB_DATABASE }} MYGEOTAB_USERNAME: ${{ secrets.MYGEOTAB_USERNAME }} @@ -25,31 +93,33 @@ jobs: MYGEOTAB_USERNAME_ASYNC: ${{ secrets.MYGEOTAB_USERNAME_ASYNC }} MYGEOTAB_PASSWORD_ASYNC: ${{ secrets.MYGEOTAB_PASSWORD_ASYNC }} steps: - - uses: actions/checkout@v7 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade setuptools pip pipenv - pipenv install --skip-lock --dev -e . - - name: Lint with ruff - run: | - # stop the build if there are Python syntax errors or undefined names - pipenv run ruff check . --select=E9,F63,F7,F82 --output-format=full - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - pipenv run ruff check . --exit-zero - - name: Test with pytest - run: | - pipenv run py.test --cov-config .coveragerc --cov-report xml:output/coverage.xml --cov mygeotab --junitxml output/python${{ matrix.python-version }}-test-results.xml --benchmark-min-rounds=3 --benchmark-storage=file://output/ --benchmark-autosave tests/ - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v7 - with: - token: ${{secrets.CODECOV_TOKEN}} - file: ./output/coverage.xml - - name: Archive code coverage results - uses: actions/upload-artifact@v7 - with: - name: output - path: output + - uses: actions/checkout@v7 + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade setuptools pip pipenv + pipenv install --skip-lock --dev -e . + - name: Run credentialed integration tests + # Skip if secrets are not available (e.g. PRs from forks). + # Exit code 5 means pytest collected 0 tests (no integration tests + # marked yet); treat that as success so the job doesn't block the PR. + run: | + if [ -z "$MYGEOTAB_DATABASE" ]; then + echo "No credentials available – skipping integration tests." + exit 0 + fi + pipenv run py.test \ + -m integration \ + --benchmark-min-rounds=3 \ + --benchmark-storage=file://output/ \ + --benchmark-autosave \ + --junitxml output/integration-test-results.xml \ + tests/ || { code=$?; [ $code -eq 5 ] && echo "No integration tests collected – OK." || exit $code; } + - name: Archive integration results + uses: actions/upload-artifact@v7 + with: + name: integration-results + path: output diff --git a/PR.md b/PR.md new file mode 100644 index 0000000..8c56614 --- /dev/null +++ b/PR.md @@ -0,0 +1,98 @@ +# Spring Cleaning PR + +## Summary + +Four independent improvements to package quality, CI reliability, and security — targeting customers who begin integrations through this package. + +--- + +## 1. SDK documentation link + +- **README.rst**: replace legacy `https://geotab.github.io/sdk/` with `https://developers.geotab.com/` +- **README.rst**: fix docs URL to HTTPS canonical form (`readthedocs.io/en/latest`) + +--- + +## 2. Python version reconciliation (3.10–3.14) + +Drops Python 3.9 (EOL October 2025) and adds 3.14. Every version contract now agrees: + +| Location | Before | After | +|---|---|---| +| `setup.py` runtime guard | `< (3, 7, 0)` | `< (3, 10, 0)` | +| `setup.py` `python_requires` | missing | `>=3.10` | +| `setup.py` classifiers | 3.9–3.13 | 3.10–3.14 | +| `setup.py` packages list | `mygeotab`, `mygeotab.ext` | + `mygeotab.altitude` (was silently absent from all installs) | +| `mypy.ini` `python_version` | `9` (invalid) | `3.10` | +| `pyproject.toml` ruff `target-version` | `py39` | `py310` | +| `setup.cfg` | `[bdist_wheel] universal = 1` (Python 2 artifact) | removed | +| `README.rst` | "Python 3.9+" | "Python 3.10+" | + +--- + +## 3. Test separation — offline vs credentialed, and full version matrix + +**pytest.ini** — two new marks with descriptions; `addopts` excludes both by default so `pytest` never needs network access or credentials: +```ini +markers = + live: real network calls to public Geotab servers (no credentials) + integration: requires MYGEOTAB_DATABASE / USERNAME / PASSWORD +addopts = -m "not live and not integration" +``` + +**tests/test_api_live.py** — `pytestmark = pytest.mark.live` applied. + +**.github/workflows/pythonpackage.yml** — single job replaced with three: + +| Job | Python | Secrets | Runs | +|---|---|---|---| +| `test-offline` | 3.10 / 3.11 / 3.12 / 3.13 / 3.14 (matrix) | none | all unit tests | +| `test-live` | 3.12 | none | unauthenticated `GetVersion` calls | +| `test-integration` | 3.12 | DB credentials | credentialed tests; exits 0 when no tests collected (exit code 5) or secrets absent | + +--- + +## 4. Session token file protection + +`mygeotab/cli.py` — enforces strict filesystem permissions on every write: + +- Config **directory**: `os.chmod(..., stat.S_IRWXU)` → **0700** (owner only) +- Config **file**: `os.chmod(..., stat.S_IRUSR | stat.S_IWUSR)` → **0600** (owner read/write only) +- Applied in both `Session.save()` and `Session.logout()` +- `myg --help` now includes guidance: *"To remove a saved session run: `myg sessions remove `"* + +--- + +## 5. Coverage: 59% → 81% (+22 pp) + +835 lines of new tests across 5 files; 163 tests pass, 0 failures. + +| File | Before | After | +|---|---|---| +| `altitude/daas_definition.py` | **0%** | **100%** | +| `exceptions.py` | ~90% | **100%** | +| `parameters.py` | ~95% | **100%** | +| `altitude/wrapper.py` | ~15% | **83%** | +| `api.py` | ~65% | **83%** | +| `ext/entitylist.py` | ~60% | **73%** | + +**New file — `tests/test_daas_definition.py`**: full branch coverage of `DaasError`, `DaasResult` (None/empty/missing-`apiResult` guards, gateway errors, `apiResult` errors, `"error"` singular field, `errorMessage` as string/dict/empty), `DaasGetJobStatusResult` (all four `has_finished()` outcomes, `errorResult` override, missing `status` key), `DaasGetQueryResult`. + +**Extended — `tests/test_altitude.py`**: `_extract_errors` (both branches), `call_api` (success, invalid name, non-retry exception), `create_job` (success, errors in response, re-raise), `check_job_status`, `wait_for_job_to_complete` (immediate done, polling, error path), `fetch_data` (single page, not-finished guard, multi-page), `get_data`, `do` (full orchestration). + +**Extended — `tests/test_api_call.py`**: `authenticate()` `ExtendSession` path, `"ThisServer"` redirect, no-`path`-in-result, `DbUnavailableException` (`Initializing` + `UnknownDatabase`), other-exception re-raise; `call()` re-auth on `InvalidUserException` when password present, `__reauthorize_count` guard prevents infinite loop; `multi_call` with no params element. + +**Extended — `tests/test_api.py`**: `_process()` unknown-structure passthrough; `_server` fallback to `my.geotab.com`; `MyGeotabException.data` field. + +**Extended — `tests/test_api_entitylist.py`**: `first`/`last` on empty list, `entity` with 0 items, `__add__` plain list, `__radd__`, `__mul__`/`__rmul__`, `__copy__`, `to_dataframe` `ImportError`, `EntityListAPI.get` return type. + +--- + +## Commits + +| SHA | Message | +|---|---| +| `6e24073` | `chore: fix SDK link, Python version contracts, test separation, token file permissions` | +| `9854eab` | `fix(ci): treat pytest exit 5 (no integration tests) as success` | +| `3c955ce` | `test: raise offline coverage from 59% to 81%` | +| `c7ee84f` | `chore: expand supported Python range to 3.10–3.14` | diff --git a/Pipfile b/Pipfile index 527fb0c..0b26671 100644 --- a/Pipfile +++ b/Pipfile @@ -7,7 +7,6 @@ verify_ssl = true coverage = "*" pytest = "*" pytest-cov = "*" -mygeotab = {editable = true,path = "."} mypy = "*" pytest-asyncio = "*" requests-mock = "*" @@ -17,6 +16,7 @@ types-requests = "*" types-pytz = "*" types-setuptools = "*" ruff = "*" +mygeotab = {path = ".", editable = true} [packages] mygeotab = {editable = true, path = "."} diff --git a/README.rst b/README.rst index 66ef465..5a8a3b4 100644 --- a/README.rst +++ b/README.rst @@ -26,80 +26,102 @@ MyGeotab :alt: License -A Python client for the `MyGeotab SDK `_. +A Python client for the `MyGeotab SDK `_. Features -------- -- Automatic serializing and deserializing of API call results -- Clean, Pythonic API for querying data -- Cross-platform and compatible with Python 3.9+ -- A `myg` command-line tool for interactively working with data in a terminal +- Automatic serialization and deserialization of API call results +- Clean, Pythonic API for querying, adding, updating, and removing entities +- Both synchronous and ``async``/``await`` interfaces +- Cross-platform and compatible with Python 3.10+ +- ``myg`` command-line tool for interactively exploring data in a terminal + +Installation +------------ + +.. code-block:: bash + + $ pip install mygeotab + +For the latest development version: + +.. code-block:: bash + + $ pip install git+https://github.com/geotab/mygeotab-python Usage ----- -It's very easy to get started once you've registered a `MyGeotab `__ database: +Synchronous +~~~~~~~~~~~ .. code-block:: python import mygeotab - client = mygeotab.API(username='hello@example.com', password='mypass', database='MyDatabase') + client = mygeotab.API( + username='hello@example.com', + password='mypass', + database='MyDatabase', + ) client.authenticate() devices = client.get('Device', name='%Test Dev%') - print(devices) - # [{'maxSecondsBetweenLogs': 200.0, + # [{'name': 'Test Device', + # 'maxSecondsBetweenLogs': 200.0, # 'activeTo': '2050-01-01', - # 'minAccidentSpeed': 3.0, - # 'ignoreDownloadsUntil': '1986-01-01', - # 'name': 'Test Device', - # 'idleMinutes': 3.0, - # ...... + # ...}] -You can also make calls asynchronously via `asyncio `__: +Asynchronous +~~~~~~~~~~~~ .. code-block:: python import asyncio import mygeotab - client = mygeotab.API(username='hello@example.com', password='mypass', database='MyDatabase') - client.authenticate() + async def main(): + client = mygeotab.API( + username='hello@example.com', + password='mypass', + database='MyDatabase', + ) + client.authenticate() - async def get_device(): - return await client.get_async('Device', name='%Test Dev%') - - devices = loop.run_until_complete(get_device()) - print(devices) + devices = await client.get_async('Device', name='%Test Dev%') + print(devices) - # [{'maxSecondsBetweenLogs': 200.0, - # 'activeTo': '2050-01-01', - # 'minAccidentSpeed': 3.0, - # 'ignoreDownloadsUntil': '1986-01-01', - # 'name': 'Test Device', - # 'idleMinutes': 3.0, - # ...... + asyncio.run(main()) -Installation ------------- +Command-line tool +~~~~~~~~~~~~~~~~~ -To install the MyGeotab library and command line tool: +The ``myg`` tool opens an interactive Python console pre-loaded with an +authenticated API object: .. code-block:: bash - $ pip install mygeotab + $ myg console MyDatabase + + # Inside the console, `myg` is the active API object: + >>> myg.get('Device', name='%Test%') -or for the bleeding-edge version: +Manage saved sessions: .. code-block:: bash - $ pip install git+https://github.com/geotab/mygeotab-python + $ myg sessions --list + $ myg sessions remove MyDatabase Documentation ------------- -Read the docs at ``_ +Full API reference and guides are at ``_. + +License +------- + +Apache 2.0. See `LICENSE `_ for details. diff --git a/docs/altitude.rst b/docs/altitude.rst index d856ffb..52a2e50 100644 --- a/docs/altitude.rst +++ b/docs/altitude.rst @@ -1,7 +1,72 @@ Altitude ======== -The full reference for `Altitude `__ APIs. +`Altitude `__ is Geotab's DaaS +(Data as a Service) platform. :class:`AltitudeAPI ` +is a subclass of :class:`API ` that routes all traffic through the +Altitude proxy endpoint. -.. automodule:: mygeotab.altitude - :members: \ No newline at end of file +Authentication works the same way as the core API — provide credentials and call +``authenticate()`` before making any data calls. + +Workflow +-------- + +The typical Altitude workflow is: + +1. **Submit** a query job with :func:`create_job() `. +2. **Poll** until it finishes with :func:`wait_for_job_to_complete() `. +3. **Retrieve** paginated results with :func:`get_data() `. + +The convenience method :func:`do() ` runs all +three steps in sequence: + +.. code-block:: python + + from mygeotab.altitude.wrapper import AltitudeAPI + + api = AltitudeAPI( + username='hello@example.com', + password='mypass', + database='MyDatabase', + ) + api.authenticate() + + params = { + 'serviceName': 'MyService', + 'functionParameters': { + 'startDate': '2025-01-01T00:00:00Z', + 'endDate': '2025-01-31T00:00:00Z', + }, + } + + # All-in-one: submit → wait → fetch + rows = api.do(params) + + # Or step-by-step for more control: + job = api.create_job(params) + params['functionParameters']['jobId'] = job['id'] + api.wait_for_job_to_complete(params) + rows = api.get_data(params) + +AltitudeAPI +----------- + +.. autoclass:: mygeotab.altitude.wrapper.AltitudeAPI + :members: + :inherited-members: + +Result Types +------------ + +.. autoclass:: mygeotab.altitude.daas_definition.DaasResult + :members: + +.. autoclass:: mygeotab.altitude.daas_definition.DaasGetJobStatusResult + :members: + +.. autoclass:: mygeotab.altitude.daas_definition.DaasGetQueryResult + :members: + +.. autoclass:: mygeotab.altitude.daas_definition.DaasError + :members: diff --git a/docs/api.rst b/docs/api.rst index 2db4a57..c139430 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,28 +1,39 @@ API === -.. module:: mygeotab.api - The full API reference for all public classes and functions. Querying Data ------------- +The top-level :class:`mygeotab.API` class is the async subclass and exposes both +synchronous methods (``call``, ``get``, ``add``, ``set``, ``remove``, +``multi_call``) and their async equivalents (``call_async``, ``get_async``, etc.). + +.. module:: mygeotab.api_async + .. autoclass:: API :inherited-members: :members: -.. autoclass:: MyGeotabException +.. autoclass:: mygeotab.api.MyGeotabException -.. autoclass:: TimeoutException +.. autoclass:: mygeotab.api.TimeoutException Credentials & Authentication ----------------------------- +----------------------------- -.. autoclass:: Credentials +.. autoclass:: mygeotab.api.Credentials :members: -.. autoclass:: AuthenticationException +.. autoclass:: mygeotab.api.AuthenticationException + +Unauthenticated Server Calls +----------------------------- + +.. autofunction:: mygeotab.api.server_call + +.. autofunction:: mygeotab.api_async.server_call_async Date Helpers ------------ diff --git a/docs/commandline.rst b/docs/commandline.rst index 2d2f426..0c0f411 100644 --- a/docs/commandline.rst +++ b/docs/commandline.rst @@ -1,57 +1,95 @@ Command Line Tools ================== -The `myg` command line script is installed alongside this package, which currently makes some administration -and querying simple, as it handles the credential storage and token expiry automatically. +The ``myg`` command-line tool is installed alongside this package. It handles +credential storage and session token expiry automatically, so you can focus on +querying data rather than managing authentication boilerplate. -Currently, the script launches an interactive console to quickly query data from a database. More functionality -will be added in the future. +.. note:: + ``myg`` never stores passwords. Only the username, database name, server, + and session token are persisted to a local config file: + + - **Linux:** ``~/.config/mygeotab-python/config.ini`` + - **macOS:** ``~/Library/Application Support/mygeotab-python/config.ini`` -The tools never store passwords. The username, session token, and database are persisted and managed in -the local user's data directory. + To clear a saved session at any time, run ``myg sessions remove ``. Usage ----- -The most common usage of the `myg` script is to launch an interactive console. +Launching a console +~~~~~~~~~~~~~~~~~~~ -For example, to launch a console for a database called `my_database`: +The most common use of ``myg`` is to open an interactive Python console +pre-loaded with an authenticated API object: .. code-block:: bash $ myg console my_database - Username: my_user + Username: my_user@example.com Password: ****** + Logged in as: my_user@example.com @ my1.geotab.com/my_database + +Credentials can also be passed inline to skip the prompts: + +.. code-block:: bash + + $ myg console my_database --user my_user@example.com --password mypass + $ myg console my_database -u my_user@example.com -p mypass --server my3.geotab.com + +Inside the console the following locals are available: + +.. list-table:: + :header-rows: 1 + :widths: 15 85 + + * - Name + - Description + * - ``myg`` + - The authenticated :class:`API ` object for the selected database. + * - ``mygeotab`` + - The ``mygeotab`` module (for accessing exceptions, helpers, etc.). + * - ``dates`` + - The :mod:`mygeotab.dates` module (date/timezone helpers). + +.. code-block:: python + + >>> myg.get('Device', name='%Test%') + >>> myg.call('GetVersion') .. note:: - The `myg` script automatically handles storing credentials for various databases and remembers the last logged in - database. It also handles session expiry: it will prompt for a new password if the session has expired. + If ``ptpython`` or ``IPython`` is installed, the console launches with that + instead of the standard Python REPL, providing syntax highlighting, tab + completion, and pretty-printed output. Install either with ``pip install ptpython`` + or ``pip install ipython``. - For example, once the database has been authenticated against, the script won't prompt for passwords until the - sesison expires: + Once a database has been authenticated, ``myg`` remembers the session and + won't prompt for credentials again until the session expires: .. code-block:: bash $ myg console my_database - MyGeotab Console 0.5.1 [Python 3.5.2 \|Anaconda custom (x86_64)\| (default, Jul 2 2016, 17:52:12) [GCC 4.2.1 Compatible Apple LLVM 4.2 (clang-425.0.28)]] - Logged in as: my_user @ my1.geotab.com/my_database - In [1]: + MyGeotab Console [Python 3.12.0] + Logged in as: my_user@example.com @ my1.geotab.com/my_database - If `my_database` was the last logged in database, the following also works: + If ``my_database`` was the last logged-in database, the argument can be omitted: .. code-block:: bash $ myg console -To view current sessions: +Managing sessions +~~~~~~~~~~~~~~~~~ + +List all saved sessions: .. code-block:: bash - $ myg sessions + $ myg sessions --list my_database my_other_database -And to remove a session: +Remove a session (logs out and deletes the stored token): .. code-block:: bash @@ -61,9 +99,10 @@ And to remove a session: Additional Help --------------- -Run `--help` after any command to get available options and descriptions. +Run ``--help`` after any command to see all available options: .. code-block:: bash $ myg --help $ myg console --help + $ myg sessions --help diff --git a/docs/conf.py b/docs/conf.py index 4578d02..b1dadb1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,16 +1,6 @@ # -*- coding: utf-8 -*- # -# MyGeotab Python SDK documentation build configuration file, created by -# sphinx-quickstart on Wed Oct 1 20:55:06 2014. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# Sphinx configuration for the MyGeotab Python SDK documentation. import sys import os @@ -24,8 +14,8 @@ # -- General configuration ------------------------------------------------ -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +# Furo theme requires Sphinx ≥ 5.0; sphinx-copybutton ≥ 0.5 needs 5.x as well. +needs_sphinx = "7.0" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -48,11 +38,11 @@ # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = "index" +root_doc = "index" # General information about the project. project = "MyGeotab Python SDK" -copyright = "{}, {}".format(datetime.datetime.utcnow().year, mygeotab.__author__) +copyright = "{}, {}".format(datetime.datetime.now(datetime.timezone.utc).year, mygeotab.__author__) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -244,7 +234,7 @@ "MyGeotab Python SDK Documentation", mygeotab.__author__, "MyGeotabPythonSDK", - "One line description of project.", + "A Python client for the MyGeotab SDK.", "Miscellaneous", ), ] diff --git a/docs/ext.rst b/docs/ext.rst index 88a844c..44a20e9 100644 --- a/docs/ext.rst +++ b/docs/ext.rst @@ -1,15 +1,49 @@ Extras ====== -Extras for easier querying of the MyGeotab API. Note that these are currently experimental, and likely to change at any -time without notice. +Optional extensions that add convenience on top of the core API. + +EntityList +---------- + +:class:`mygeotab.ext.entitylist.EntityList` is a ``list`` subclass returned by the +extended :class:`mygeotab.ext.entitylist.API` wrapper. It adds helper methods that +make common result-handling patterns more concise. + +.. code-block:: python + + from mygeotab.ext.entitylist import API + + api = API(username='hello@example.com', password='mypass', database='MyDatabase') + api.authenticate() + + devices = api.get('Device') # returns EntityList, not a plain list + + # Convenience properties + first = devices.first # first item, or None if empty + last = devices.last # last item, or None if empty + device = devices[0:1].entity # asserts exactly one result, returns it + + # Sort without mutating the original + by_name = devices.sort_by('name') + by_name_desc = devices.sort_by('name', reverse=True) + + # Export to a pandas DataFrame (requires: pip install mygeotab[notebook]) + df = devices.to_dataframe() + df_normalized = devices.to_dataframe(normalize=True) # flattens nested dicts + +See the API reference for the full :class:`EntityList ` +and :class:`entitylist.API ` documentation. Data Feed --------- -Handler to get data as efficiently as possible. - -A simple example package can be found in the `/examples` directory. +:class:`mygeotab.ext.feed.DataFeed` polls ``GetFeed`` on a background thread and +delivers incremental entity changes to a listener. It is the recommended pattern for +continuously synchronising telemetry or entity state. -See the API reference for the :class:`DataFeed ` and -:class:`DataFeedListener ` classes for more information. \ No newline at end of file +A minimal example is included in the +`examples/data_feed `_ +directory. See the API reference for the +:class:`DataFeed ` and +:class:`DataFeedListener ` classes. diff --git a/docs/index.rst b/docs/index.rst index bc1ab70..f0884e5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,3 @@ -.. MyGeotab Python SDK documentation master file, created by - sphinx-quickstart on Wed Oct 1 20:55:06 2014. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - .. include:: ../README.rst .. include:: ../CHANGELOG.rst diff --git a/docs/usage.rst b/docs/usage.rst index 275fa86..4a4c857 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -7,9 +7,9 @@ Getting Started --------------- For a quick introduction to the MyGeotab SDK and initial setup of a database, -please refer to the `Getting Started guide `_. +please refer to the `Getting Started guide `_. -For an overview of some basic concepts, the `Concepts guide `_ +For an overview of some basic concepts, the `Concepts guide `_ is a good resource to find out how things work under the hood. Authentication @@ -48,19 +48,19 @@ From this, store the `server`, `database`, `username`, and `session_id` properti # Continue with api object until your app finishes - local_credentials = my_read_credentials() # Next load of the app - new_api = mygeotab.api(username=local_credentials.user, database=local_credentials.database, server=local_credentials.server, session_id=saved_session_id) + local_credentials = my_read_credentials() # Next load of the app + new_api = mygeotab.API(username=local_credentials.username, database=local_credentials.database, server=local_credentials.server, session_id=local_credentials.session_id) .. note:: The best practices of saving credentials only applies to some service-based SDK apps. The recommendation is that if the app runs on - a schedule (for example, a operating system-scheduled task running every minute), store the credentials locally. + a schedule (for example, an operating system-scheduled task running every minute), store the credentials locally. Too many authentication attempts within a period of time will cause the server to reject any further requests for a short time. However, constantly running sessions may not need to store the credentials in the file system as they can retain the :class:`API ` instance in memory. -Subsequent calls to the :func:`authenticate() ` method with an already authenticted :class:`API ` object can extend the lifetime of the session. However, this still counts towards the count of authentication attempts and calling this may still result in `OverLimitException`\s and may reject requests for a short time afterward to prevent API abuse. +Subsequent calls to the :func:`authenticate() ` method with an already authenticated :class:`API ` object can extend the lifetime of the session. However, this still counts towards the count of authentication attempts and calling this may still result in an over-limit server error (raised as a :class:`MyGeotabException `) and may reject requests for a short time afterward to prevent API abuse. Making Calls ------------ @@ -79,7 +79,7 @@ To demonstrate a (slightly) more complex call with 1 parameter, the following is Assume for this example there is one vehicle in the system, with a partial JSON representation: -.. code-block:: javascript +.. code-block:: json { "id": "b0a46", @@ -106,17 +106,17 @@ To filter this down to a specific vehicle, a 'search' parameter is added on the In this Python library, a lot of effort was made to make this a much easier experience. Please read the below section to see how the above call was made to be more Pythonic and easier to use. -For more information on calls available, visit the "Methods" section of the `MyGeotab API Reference `_. +For more information on calls available, visit the `MyGeotab API Reference `_. Entities -------- -From the `MyGeotab API Concepts documentation `_: +From the `MyGeotab API Concepts documentation `_: .. pull-quote:: All objects in the MyGeotab system are called entities. Entities have an ID property that is used to uniquely identify that object in the database. -To see all available entities, refer to the `API _MyGeotab API Reference `_. +To see all available entities, refer to the `MyGeotab API Reference `_. .. note:: To see which objects are entities in the SDK, type in "search" into the search box of the API reference page. @@ -170,7 +170,7 @@ To modify an entity, first get the full entity: device = devices[0] .. note:: - The the :func:`get() ` method always returns a list of entities, even when querying on a specific + The :func:`get() ` method always returns a list of entities, even when querying on a specific serial number or VIN, etc. Then modify a property: @@ -194,3 +194,60 @@ To remove the entity, once again get the full entity, as above in Setting_, and .. code-block:: python api.remove('Device', device) + +Batching Calls +-------------- + +Multiple API calls can be combined into a single HTTP request using +:func:`multi_call() `. This reduces network overhead significantly +when making several small requests together: + +.. code-block:: python + + results = api.multi_call([ + ['GetVersion'], + ['Get', {'typeName': 'Device', 'search': {'name': '%Test%'}}], + ]) + version = results[0] + devices = results[1] + +If one call in a multi-call fails, an exception is raised immediately and subsequent +calls in the batch are not executed. + +Async Usage +----------- + +All methods have an ``async`` equivalent. The public :class:`API ` class +exported from the top-level package supports both sync and async calls from the same +object: + +.. code-block:: python + + import asyncio + import mygeotab + + async def main(): + api = mygeotab.API( + username='hello@example.com', + password='mypass', + database='MyDatabase', + ) + api.authenticate() # authentication is always synchronous + + version = await api.call_async('GetVersion') + devices = await api.get_async('Device', name='%Test%') + + results = await api.multi_call_async([ + ['GetVersion'], + ['Get', {'typeName': 'User'}], + ]) + + asyncio.run(main()) + +The async variants — :func:`call_async() `, +:func:`get_async() `, +:func:`add_async() `, +:func:`set_async() `, +:func:`remove_async() `, and +:func:`multi_call_async() ` — accept the same arguments +as their synchronous counterparts. diff --git a/mygeotab/api_async.py b/mygeotab/api_async.py index 4d7799c..3b0ad6e 100644 --- a/mygeotab/api_async.py +++ b/mygeotab/api_async.py @@ -53,7 +53,7 @@ async def call_async(self, method, **parameters): :param method: The method name. :param params: Additional parameters to send (for example, search=dict(id='b123') ) - :return: The JSON result (decoded into a dict) from the server.abs + :return: The JSON result (decoded into a dict) from the server. :raise MyGeotabException: Raises when an exception occurs on the MyGeotab server. :raise TimeoutException: Raises when the request does not respond after some time. """ diff --git a/mygeotab/cli.py b/mygeotab/cli.py index 7b98b55..f4e2ad8 100644 --- a/mygeotab/cli.py +++ b/mygeotab/cli.py @@ -8,7 +8,9 @@ """ import configparser +import os import os.path +import stat import sys import click @@ -32,6 +34,9 @@ def _get_config_file(): config_path = click.get_app_dir(__title__) if not os.path.exists(config_path): os.makedirs(config_path) + # Restrict the directory so only the owner can read/write/traverse it. + # This prevents other local users from enumerating or accessing session files. + os.chmod(config_path, stat.S_IRWXU) # 0700 return os.path.join(config_path, "config.ini") @staticmethod @@ -64,8 +69,11 @@ def save(self): config.set(section_name, "database", self.credentials.database) config.set(section_name, "server", self.credentials.server) - with open(self._get_config_file(), "w") as configfile: + config_file = self._get_config_file() + with open(config_file, "w") as configfile: config.write(configfile) + # Restrict the session file so only the owner can read/write it. + os.chmod(config_file, stat.S_IRUSR | stat.S_IWUSR) # 0600 def load(self, name=None): config = configparser.ConfigParser() @@ -114,8 +122,10 @@ def logout(self): config = configparser.ConfigParser() config.read(self._get_config_file()) config.remove_section(section_name) - with open(self._get_config_file(), "w") as configfile: + config_file = self._get_config_file() + with open(config_file, "w") as configfile: config.write(configfile) + os.chmod(config_file, stat.S_IRUSR | stat.S_IWUSR) # 0600 self.credentials = None @@ -241,6 +251,11 @@ def main(ctx): """MyGeotab Python SDK command line tools. You probably want to use the `console` command. + + Session tokens are stored in an owner-only config file + (~/.config/mygeotab-python/config.ini on Linux, + ~/Library/Application Support/mygeotab-python/config.ini on macOS). + To remove a saved session run: myg sessions remove """ ctx.obj = Session() try: diff --git a/mygeotab/exceptions.py b/mygeotab/exceptions.py index 912a274..738665c 100644 --- a/mygeotab/exceptions.py +++ b/mygeotab/exceptions.py @@ -71,5 +71,5 @@ def __str__(self): @property def message(self): - """The excepton message.""" + """The exception message.""" return "Request timed out @ {0}".format(self.server) diff --git a/mypy.ini b/mypy.ini index 3881405..7f84f30 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 9 +python_version = 3.10 exclude = docs [mypy-rapidjson.*] diff --git a/pyproject.toml b/pyproject.toml index 889bd66..e40ec48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.ruff] line-length = 127 -target-version = "py39" +target-version = "py310" [tool.ruff.lint] select = ["E", "F", "B"] diff --git a/pytest.ini b/pytest.ini index de19c9f..40e6c1f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,6 @@ [pytest] -testpaths = tests \ No newline at end of file +testpaths = tests +addopts = -m "not live and not integration" +markers = + live: marks tests that make real network calls to public Geotab servers (no credentials needed, but requires internet access) + integration: marks tests that require credentialed access to a MyGeotab database (set MYGEOTAB_DATABASE, MYGEOTAB_USERNAME, MYGEOTAB_PASSWORD) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index efbb696..2c7c9c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,6 @@ [metadata] license_file = LICENSE -[bdist_wheel] -universal = 1 - [tool:pytest] testpaths = tests diff --git a/setup.py b/setup.py index 62b6ac1..cc5ff95 100644 --- a/setup.py +++ b/setup.py @@ -24,15 +24,16 @@ py_version = sys.version_info[:3] -if py_version < (3, 7, 0): - raise RuntimeError("This package requres Python 3.9.0+") +if py_version < (3, 10, 0): + raise RuntimeError("This package requires Python 3.10.0+") -packages = ["mygeotab", "mygeotab.ext"] +packages = ["mygeotab", "mygeotab.ext", "mygeotab.altitude"] setup( name="mygeotab", author="Geotab Inc.", version=version, + python_requires=">=3.10", url="https://github.com/geotab/mygeotab-python", description="A Python client for the MyGeotab SDK", long_description=f"{readme} \n\n {changelog}", @@ -62,11 +63,11 @@ "Natural Language :: English", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries", ], diff --git a/tests/test_altitude.py b/tests/test_altitude.py index afdcbb1..f81a4e7 100644 --- a/tests/test_altitude.py +++ b/tests/test_altitude.py @@ -1,13 +1,61 @@ # -*- coding: utf-8 -*- +""" +Tests for mygeotab.altitude.wrapper and mygeotab.altitude.daas_definition +server-override behaviour. +""" + +from unittest.mock import MagicMock, call, patch + +import pytest + from mygeotab import api -from mygeotab.altitude.wrapper import AltitudeAPI, ALTITUDE_PROXY_SERVER +from mygeotab.altitude.daas_definition import ( + NOT_FULL_API_CALL_EXCEPTION, + DaasGetJobStatusResult, + DaasGetQueryResult, +) +from mygeotab.altitude.wrapper import ALTITUDE_PROXY_SERVER, AltitudeAPI USERNAME = "test@example.com" SESSION_ID = "abc123sessionid" DATABASE = "testdatabase" +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def make_altitude_api(): + return AltitudeAPI(USERNAME, session_id=SESSION_ID, database=DATABASE) + + +def make_status_call_result(state="DONE", error_result=None): + job = {"id": "job-1", "status": {"state": state}} + if error_result: + job["status"]["errorResult"] = error_result + return { + "errors": [], + "apiResult": {"results": [job], "errors": [], "errorMessage": None}, + } + + +def make_query_call_result(rows=None, page_token=None, total_rows=None): + job = {"id": "job-1"} + if rows is not None: + job["rows"] = rows + if page_token is not None: + job["pageToken"] = page_token + if total_rows is not None: + job["totalRows"] = total_rows + return { + "errors": [], + "apiResult": {"results": [job], "errors": [], "errorMessage": None}, + } + + +# ── Server override ─────────────────────────────────────────────────────────── + + class TestAltitudeServer: def test_default_server_is_proxy(self): altitude_api = AltitudeAPI(USERNAME, session_id=SESSION_ID, database=DATABASE) @@ -22,3 +70,254 @@ def test_passed_server_is_overridden(self): def test_resolved_url_is_proxy_verbatim(self): altitude_api = AltitudeAPI(USERNAME, session_id=SESSION_ID, database=DATABASE) assert api.get_api_url(altitude_api._server) == ALTITUDE_PROXY_SERVER + + +# ── _extract_errors ─────────────────────────────────────────────────────────── + + +class TestExtractErrors: + def test_top_level_errors_returned(self): + alt = make_altitude_api() + resp = {"errors": [{"message": "top-level error"}], "apiResult": {}} + errors = alt._extract_errors(resp) + assert len(errors) == 1 + assert errors[0]["message"] == "top-level error" + + def test_api_result_errors_returned_when_top_level_empty(self): + alt = make_altitude_api() + resp = {"errors": [], "apiResult": {"errors": [{"message": "inner error"}]}} + errors = alt._extract_errors(resp) + assert len(errors) == 1 + assert errors[0]["message"] == "inner error" + + def test_empty_when_no_errors(self): + alt = make_altitude_api() + resp = {"errors": [], "apiResult": {"errors": []}} + assert alt._extract_errors(resp) == [] + + def test_top_level_takes_priority_over_api_result(self): + """If top-level errors is non-empty, apiResult errors are not returned (or operator).""" + alt = make_altitude_api() + resp = { + "errors": [{"message": "top"}], + "apiResult": {"errors": [{"message": "inner"}]}, + } + errors = alt._extract_errors(resp) + # The `or` means only top-level is returned when it is truthy + assert len(errors) == 1 + assert errors[0]["message"] == "top" + + +# ── call_api ────────────────────────────────────────────────────────────────── + + +class TestCallApi: + def test_success_returns_result(self): + alt = make_altitude_api() + expected = make_status_call_result("DONE") + with patch.object(alt, "_call_api", return_value=expected): + result = alt.call_api( + "getJobStatus", + {"serviceName": "svc", "functionParameters": {"jobId": "j1"}}, + ) + assert result is expected + + def test_invalid_function_name_raises(self): + alt = make_altitude_api() + with pytest.raises(AssertionError): + alt.call_api( + "unknownMethod", + {"serviceName": "svc", "functionParameters": {}}, + ) + + def test_valid_function_names_accepted(self): + alt = make_altitude_api() + for name in ["getJobStatus", "getQueryResults", "createQueryJob"]: + with patch.object(alt, "_call_api", return_value=make_status_call_result()): + result = alt.call_api(name, {"serviceName": "s", "functionParameters": {}}) + assert result is not None + + def test_non_retry_exception_re_raised_immediately(self): + """Exceptions that are not NOT_FULL_API_CALL_EXCEPTION must propagate instantly.""" + alt = make_altitude_api() + with patch.object(alt, "_call_api", side_effect=ValueError("network error")): + with pytest.raises(ValueError, match="network error"): + alt.call_api("getJobStatus", {"serviceName": "s", "functionParameters": {}}) + + +# ── create_job ──────────────────────────────────────────────────────────────── + + +class TestCreateJob: + def _make_create_job_result(self, job_id="job-abc"): + return { + "errors": [], + "apiResult": { + "results": [{"id": job_id}], + "errors": [], + "errorMessage": None, + }, + } + + def test_returns_first_result_on_success(self): + alt = make_altitude_api() + with patch.object(alt, "call_api", return_value=self._make_create_job_result("job-abc")): + job = alt.create_job({"serviceName": "s", "functionParameters": {}}) + assert job["id"] == "job-abc" + + def test_raises_when_errors_present(self): + alt = make_altitude_api() + mock_result = { + "errors": [{"message": "quota exceeded"}], + "apiResult": {"results": [], "errors": [], "errorMessage": None}, + } + with patch.object(alt, "call_api", return_value=mock_result): + with pytest.raises(Exception, match="quota exceeded"): + alt.create_job({"serviceName": "s", "functionParameters": {}}) + + def test_re_raises_call_api_exception(self): + alt = make_altitude_api() + with patch.object(alt, "call_api", side_effect=RuntimeError("connection reset")): + with pytest.raises(RuntimeError, match="connection reset"): + alt.create_job({"serviceName": "s", "functionParameters": {}}) + + +# ── check_job_status ────────────────────────────────────────────────────────── + + +class TestCheckJobStatus: + def test_returns_daas_get_job_status_result(self): + alt = make_altitude_api() + with patch.object(alt, "call_api", return_value=make_status_call_result("DONE")): + result = alt.check_job_status({"serviceName": "s", "functionParameters": {"jobId": "j1"}}) + assert isinstance(result, DaasGetJobStatusResult) + assert result.state == "DONE" + + +# ── wait_for_job_to_complete ────────────────────────────────────────────────── + + +class TestWaitForJobToComplete: + def test_returns_job_when_done_immediately(self): + alt = make_altitude_api() + done_status = DaasGetJobStatusResult(make_status_call_result("DONE")) + with patch.object(alt, "check_job_status", return_value=done_status): + with patch("time.sleep"): + job = alt.wait_for_job_to_complete( + {"serviceName": "s", "functionParameters": {"jobId": "j1"}} + ) + assert job["id"] == "job-1" + + def test_polls_until_done(self): + alt = make_altitude_api() + running = DaasGetJobStatusResult(make_status_call_result("RUNNING")) + done = DaasGetJobStatusResult(make_status_call_result("DONE")) + with patch.object(alt, "check_job_status", side_effect=[running, running, done]): + with patch("time.sleep"): + job = alt.wait_for_job_to_complete( + {"serviceName": "s", "functionParameters": {"jobId": "j1"}} + ) + assert job["id"] == "job-1" + + def test_raises_when_status_has_errors(self): + alt = make_altitude_api() + error_result = {"code": 500, "location": "svc", "message": "crashed"} + failed_status = DaasGetJobStatusResult( + make_status_call_result("DONE", error_result=error_result) + ) + with patch.object(alt, "check_job_status", return_value=failed_status): + with patch("time.sleep"): + with pytest.raises(Exception): + alt.wait_for_job_to_complete( + {"serviceName": "s", "functionParameters": {"jobId": "j1"}} + ) + + +# ── fetch_data ──────────────────────────────────────────────────────────────── + + +class TestFetchData: + def test_single_page_no_page_token(self): + alt = make_altitude_api() + done_status = DaasGetJobStatusResult(make_status_call_result("DONE")) + page = make_query_call_result(rows=[{"a": 1}], total_rows=1, page_token=None) + + with patch.object(alt, "check_job_status", return_value=done_status): + with patch.object(alt, "call_api", return_value=page): + pages = list(alt.fetch_data({"serviceName": "s", "functionParameters": {"jobId": "j1"}})) + + # Generator yields one data page then a final None sentinel + assert pages[0] is not None + assert pages[0]["data"][0] == [{"a": 1}] + assert pages[1] is None # sentinel + + def test_raises_before_job_finished(self): + alt = make_altitude_api() + running_status = DaasGetJobStatusResult(make_status_call_result("RUNNING")) + with patch.object(alt, "check_job_status", return_value=running_status): + with pytest.raises(Exception, match="before job had finished"): + list(alt.fetch_data({"serviceName": "s", "functionParameters": {"jobId": "j1"}})) + + def test_multi_page_follows_page_token(self): + alt = make_altitude_api() + done_status = DaasGetJobStatusResult(make_status_call_result("DONE")) + page1 = make_query_call_result(rows=[{"a": 1}], total_rows=2, page_token="tok2") + page2 = make_query_call_result(rows=[{"a": 2}], total_rows=2, page_token=None) + + with patch.object(alt, "check_job_status", return_value=done_status): + with patch.object(alt, "call_api", side_effect=[page1, page2]): + pages = list(alt.fetch_data({"serviceName": "s", "functionParameters": {"jobId": "j1"}})) + + data_pages = [p for p in pages if p is not None] + assert len(data_pages) == 2 + assert data_pages[0]["data"][0] == [{"a": 1}] + assert data_pages[1]["data"][0] == [{"a": 2}] + + +# ── get_data ────────────────────────────────────────────────────────────────── + + +class TestGetData: + def test_combines_pages_into_flat_list(self): + alt = make_altitude_api() + done_status = DaasGetJobStatusResult(make_status_call_result("DONE")) + page1 = make_query_call_result(rows=[{"a": 1}], total_rows=2, page_token="tok2") + page2 = make_query_call_result(rows=[{"a": 2}], total_rows=2, page_token=None) + + with patch.object(alt, "check_job_status", return_value=done_status): + with patch.object(alt, "call_api", side_effect=[page1, page2]): + data = alt.get_data({"serviceName": "s", "functionParameters": {"jobId": "j1"}}) + + assert data == [{"a": 1}, {"a": 2}] + + def test_empty_result_returns_empty_list(self): + alt = make_altitude_api() + done_status = DaasGetJobStatusResult(make_status_call_result("DONE")) + page = make_query_call_result(rows=[], total_rows=0, page_token=None) + + with patch.object(alt, "check_job_status", return_value=done_status): + with patch.object(alt, "call_api", return_value=page): + data = alt.get_data({"serviceName": "s", "functionParameters": {"jobId": "j1"}}) + + assert data == [] + + +# ── do ──────────────────────────────────────────────────────────────────────── + + +class TestDo: + def test_orchestrates_create_wait_fetch(self): + alt = make_altitude_api() + params = {"serviceName": "svc", "functionParameters": {}} + + with patch.object(alt, "create_job", return_value={"id": "job-xyz"}) as mock_create: + with patch.object(alt, "wait_for_job_to_complete", return_value={"id": "job-xyz"}) as mock_wait: + with patch.object(alt, "get_data", return_value=[{"row": 1}]) as mock_get: + result = alt.do(params) + + assert result == [{"row": 1}] + mock_create.assert_called_once_with(params) + # jobId must have been injected into functionParameters before wait + assert params["functionParameters"]["jobId"] == "job-xyz" + mock_wait.assert_called_once_with(params) + mock_get.assert_called_once_with(params) diff --git a/tests/test_api.py b/tests/test_api.py index 0151c52..be9681e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -91,3 +91,25 @@ def test_handle_server_results(self): def test_handle_none(self): result = api._process(None) assert result is None + + def test_unknown_structure_returned_as_is(self): + """A dict with neither 'error' nor 'result' must be returned unchanged.""" + data = {"someUnexpectedKey": 42} + assert api._process(data) == data + + +class TestMiscApi: + def test_server_defaults_when_credentials_server_is_none(self): + """_server: when credentials.server is falsy it must fall back to + 'my.geotab.com' and mutate credentials.server in-place.""" + a = api.API("user@test.com", session_id="s123", database="db", server="my3.geotab.com") + a.credentials.server = None + assert a._server == "my.geotab.com" + assert a.credentials.server == "my.geotab.com" + + def test_mygeotab_exception_data_field(self): + """MyGeotabException must expose the 'data' attribute when provided.""" + exc = api.MyGeotabException( + {"errors": [{"name": "SomeError", "message": "msg", "data": {"key": "value"}}]} + ) + assert exc.data == {"key": "value"} diff --git a/tests/test_api_call.py b/tests/test_api_call.py index ff96035..47daf6b 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -264,6 +264,145 @@ def test_call_authenticate_invalid_sessionid(self, mock_query): assert fake_credentials["database"] in str(excinfo.value) +class TestAuthenticateEdgeCases: + """Cover authenticate() paths missed by the existing suite.""" + + def test_extend_session_when_session_id_no_password(self, mock_query): + """authenticate() with session_id and no password must call ExtendSession + and return the *same* Credentials object unchanged.""" + mock_query.return_value = None # ExtendSession returns None + session = api.API( + USERNAME, + session_id=SESSION_ID, + database=DATABASE, + server=SERVER, + ) + original_credentials = session.credentials + result = session.authenticate() + + assert result is original_credentials + assert result.session_id == SESSION_ID + # Verify the method name passed to _query was ExtendSession + called_method = mock_query.call_args[0][1] + assert called_method == "ExtendSession" + called_data = mock_query.call_args[0][2] + assert called_data["sessionId"] == SESSION_ID + assert called_data["userName"] == USERNAME + + def test_authenticate_this_server_keeps_current_server(self, mock_query): + """When the server responds with path='ThisServer', the client's server + must not change.""" + mock_query.return_value = { + "path": "ThisServer", + "credentials": { + "userName": USERNAME, + "sessionId": "new-session-id", + "database": DATABASE, + }, + } + session = api.API(USERNAME, password=PASSWORD, database=DATABASE, server=SERVER) + creds = session.authenticate() + + assert creds.server == SERVER # unchanged + assert creds.session_id == "new-session-id" + + def test_authenticate_no_path_in_result_returns_existing_credentials(self, mock_query): + """When the server returns a result with no 'path' key but a session_id + is already present, the existing Credentials object is returned as-is.""" + # Seed a valid session_id so the code reaches the 'path' check + session = api.API( + USERNAME, + password=PASSWORD, + session_id=SESSION_ID, + database=DATABASE, + server=SERVER, + ) + original = session.credentials + mock_query.return_value = { + # No 'path' key + "credentials": {"userName": USERNAME, "sessionId": SESSION_ID, "database": DATABASE} + } + result = session.authenticate() + assert result is original + + @pytest.mark.parametrize("message", ["Initializing", "UnknownDatabase"]) + def test_authenticate_db_unavailable_raises_auth_exception(self, mock_query, message): + """DbUnavailableException with 'Initializing' or 'UnknownDatabase' in + the message must be converted to AuthenticationException.""" + mock_query.side_effect = api.MyGeotabException( + {"errors": [{"name": "DbUnavailableException", "message": message}]} + ) + session = api.API(USERNAME, password=PASSWORD, database=DATABASE, server=SERVER) + with pytest.raises(AuthenticationException): + session.authenticate() + + def test_authenticate_other_mygeotab_exception_re_raised(self, mock_query): + """A MyGeotabException that is not InvalidUser/DbUnavailable must + propagate without conversion.""" + mock_query.side_effect = api.MyGeotabException( + {"errors": [{"name": "SomeOtherException", "message": "unexpected"}]} + ) + session = api.API(USERNAME, password=PASSWORD, database=DATABASE, server=SERVER) + with pytest.raises(api.MyGeotabException): + session.authenticate() + + +class TestCallReauth: + """Cover call() re-authentication paths.""" + + def test_reauth_on_invalid_user_when_password_present(self, mock_query): + """When InvalidUserException is raised and the API object has a password, + it must re-authenticate and retry the original call transparently.""" + # Sequence: + # 1. Initial call → InvalidUserException (session expired) + # 2. Re-authenticate → returns new credentials + # 3. Retry call → success + mock_query.side_effect = [ + api.MyGeotabException( + {"errors": [{"name": "InvalidUserException", "message": "Session expired"}]} + ), + mock_authenticate_response(), # authenticate() response + mock_user_response(), # retried get() + ] + session = api.API(USERNAME, password=PASSWORD, database=DATABASE, server=SERVER) + # Give the session a pre-existing session_id so it skips initial auth + session.credentials.session_id = SESSION_ID + + result = session.get("User", name=USERNAME) + assert len(result) == 1 + # Three _query calls: original + auth + retry + assert mock_query.call_count == 3 + + def test_no_infinite_reauth_loop(self, mock_query): + """If InvalidUserException recurs after a re-auth attempt, the client + must raise AuthenticationException instead of looping again.""" + session = api.API(USERNAME, password=PASSWORD, database=DATABASE, server=SERVER) + session.credentials.session_id = SESSION_ID + # Artificially set the guard counter to 1, simulating a re-auth already done. + session._API__reauthorize_count = 1 + + mock_query.side_effect = api.MyGeotabException( + {"errors": [{"name": "InvalidUserException", "message": "Invalid user"}]} + ) + with pytest.raises(AuthenticationException): + session.call("GetVersion") + + +class TestMiscAttributes: + """Cover miscellaneous API utility paths requiring mock_query.""" + + def test_multi_call_single_element_no_params(self, mock_query): + """multi_call with a single-method entry (no params dict) must use an + empty params dict rather than crash.""" + mock_query.return_value = mock_authenticate_response() + session = api.API(USERNAME, password=PASSWORD, database=DATABASE, server=SERVER) + session.authenticate() + + mock_query.return_value = ["8.0.1234"] + results = session.multi_call([["GetVersion"]]) # no params element + assert results is not None + + class TestServerCallApi: def test_invalid_server_call(self): with pytest.raises(Exception) as excinfo1: diff --git a/tests/test_api_entitylist.py b/tests/test_api_entitylist.py index 2a8dacd..a9e5b01 100644 --- a/tests/test_api_entitylist.py +++ b/tests/test_api_entitylist.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- +import copy +import sys from datetime import datetime from unittest.mock import MagicMock, patch import pytest +from mygeotab.ext.entitylist import API as EntityListAPI from mygeotab.ext.entitylist import EntityList @@ -92,6 +95,106 @@ def test_to_dataframe(self): assert int(dataframe["location.x"][-1:]) == 123 +class TestEntityListEdgeCases: + """Cover branches missed by the original test suite.""" + + # ── first / last on empty list ─────────────────────────────────────────── + + def test_first_empty_returns_none(self): + assert EntityList([], "Device").first is None + + def test_last_empty_returns_none(self): + assert EntityList([], "Device").last is None + + # ── entity with zero items ─────────────────────────────────────────────── + + def test_entity_zero_items_raises(self): + with pytest.raises(AssertionError, match="0 entities"): + EntityList([], "Device").entity + + # ── __add__ with plain list ────────────────────────────────────────────── + + def test_add_plain_list(self): + el = EntityList([{"id": "a"}], "Device") + result = el + [{"id": "b"}] + assert len(result) == 2 + assert isinstance(result, EntityList) + assert result.type_name == "Device" + + # ── __radd__ with plain list ───────────────────────────────────────────── + + def test_radd_plain_list(self): + el = EntityList([{"id": "a"}], "Device") + result = [{"id": "b"}] + el + assert len(result) == 2 + assert isinstance(result, EntityList) + assert result.type_name == "Device" + + # ── __mul__ / __rmul__ ─────────────────────────────────────────────────── + + def test_mul(self): + el = EntityList([{"id": "a"}], "Device") + result = el * 3 + assert len(result) == 3 + assert isinstance(result, EntityList) + assert result.type_name == "Device" + + def test_rmul(self): + el = EntityList([{"id": "a"}], "Device") + result = 3 * el + assert len(result) == 3 + assert isinstance(result, EntityList) + assert result.type_name == "Device" + + # ── __copy__ ───────────────────────────────────────────────────────────── + + def test_copy(self): + el = EntityList([{"id": "a"}], "Device") + el2 = copy.copy(el) + assert el2.type_name == el.type_name + assert el2.data == el.data + assert el2.data is not el.data # independent copy + + # ── to_dataframe — ImportError when pandas not installed ──────────────── + + def test_to_dataframe_raises_import_error_when_no_pandas(self): + el = EntityList([{"id": "b1"}], "Device") + # Simulate pandas being absent + with patch.dict(sys.modules, {"pandas": None}): + with pytest.raises(ImportError, match="pandas"): + el.to_dataframe() + + # ── EntityList.API.get returns EntityList ──────────────────────────────── + + def test_entitylist_api_get_returns_entity_list(self): + """ext.entitylist.API.get() must wrap the raw result in an EntityList.""" + with patch("mygeotab.api._query") as mock_query: + # First call: authenticate + mock_query.return_value = { + "path": "my3.geotab.com", + "credentials": { + "userName": "test@example.com", + "sessionId": "sid123", + "database": "db", + }, + } + el_api = EntityListAPI( + "test@example.com", + password="pw", + database="db", + server="my3.geotab.com", + ) + el_api.authenticate() + + # Second call: get + mock_query.return_value = [{"id": "b1", "name": "Device A"}] + result = el_api.get("Device") + + assert isinstance(result, EntityList) + assert result.type_name == "Device" + assert result[0]["id"] == "b1" + + def get_entitylist(type_name="Device", second_device_name="Test Device"): return EntityList( [ diff --git a/tests/test_api_live.py b/tests/test_api_live.py index 491ce5b..baf8550 100644 --- a/tests/test_api_live.py +++ b/tests/test_api_live.py @@ -4,12 +4,15 @@ Live API tests - only unauthenticated GetVersion calls. These tests actually hit the MyGeotab API servers and require network access. +Run them explicitly with: pytest -m live """ import pytest from mygeotab import api, server_call_async +pytestmark = pytest.mark.live + class TestLiveServerCall: """Test unauthenticated server_call to live API.""" diff --git a/tests/test_daas_definition.py b/tests/test_daas_definition.py new file mode 100644 index 0000000..6216b07 --- /dev/null +++ b/tests/test_daas_definition.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- + +""" +Tests for mygeotab.altitude.daas_definition. + +This module was previously at 0 % coverage. These tests exercise every +class and every branch in daas_definition.py. +""" + +import pytest + +from mygeotab.altitude.daas_definition import ( + NOT_FULL_API_CALL_EXCEPTION, + DaasError, + DaasGetJobStatusResult, + DaasGetQueryResult, + DaasResult, +) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def make_job_status_call_result( + state="DONE", + error_result=None, + gateway_errors=None, + api_result_errors=None, + error_message=None, +): + """Build a minimal valid call_result dict for DaasGetJobStatusResult.""" + job = {"id": "job-1", "status": {"state": state}} + if error_result: + job["status"]["errorResult"] = error_result + return { + "errors": gateway_errors or [], + "apiResult": { + "results": [job], + "errors": api_result_errors or [], + "errorMessage": error_message, + }, + } + + +def make_query_call_result(rows=None, total_rows=None, page_token=None): + """Build a minimal valid call_result dict for DaasGetQueryResult.""" + job = {"id": "job-1"} + if rows is not None: + job["rows"] = rows + if total_rows is not None: + job["totalRows"] = total_rows + if page_token is not None: + job["pageToken"] = page_token + return { + "errors": [], + "apiResult": { + "results": [job], + "errors": [], + "errorMessage": None, + }, + } + + +# ── DaasError ───────────────────────────────────────────────────────────────── + + +class TestDaasError: + def test_fields_populated(self): + err = DaasError({"code": 404, "domain": "altitude", "message": "not found"}) + assert err.code == 404 + assert err.domain == "altitude" + assert err.message == "not found" + + def test_raw_error_dict_stored(self): + raw = {"code": 500, "domain": "gateway", "message": "internal error"} + err = DaasError(raw) + assert err.error is raw + + +# ── DaasResult ──────────────────────────────────────────────────────────────── + + +class TestDaasResult: + # ── Guard: empty / missing call_result ────────────────────────────────── + + def test_none_call_result_raises(self): + with pytest.raises(Exception) as exc_info: + DaasResult(None) + assert exc_info.value is NOT_FULL_API_CALL_EXCEPTION + + def test_empty_dict_raises(self): + with pytest.raises(Exception) as exc_info: + DaasResult({}) + assert exc_info.value is NOT_FULL_API_CALL_EXCEPTION + + def test_missing_api_result_key_raises(self): + with pytest.raises(Exception) as exc_info: + DaasResult({"errors": []}) + assert exc_info.value is NOT_FULL_API_CALL_EXCEPTION + + # ── Happy path ────────────────────────────────────────────────────────── + + def test_normal_call_result_sets_attributes(self): + call_result = make_job_status_call_result() + result = DaasResult(call_result) + assert result.call_result is call_result + assert result.api_result is call_result["apiResult"] + assert result.jobs == call_result["apiResult"]["results"] + assert result.job == call_result["apiResult"]["results"][0] + assert len(result.errors) == 0 + assert result.api_result_error is None + + # ── Gateway-level errors ───────────────────────────────────────────────── + + def test_gateway_errors_populate_daas_errors_and_errors(self): + call_result = make_job_status_call_result( + gateway_errors=[{"code": 500, "domain": "gw", "message": "gateway error"}] + ) + result = DaasResult(call_result) + assert len(result.daas_errors) == 1 + assert result.daas_errors[0].message == "gateway error" + assert len(result.errors) == 1 + assert "gateway error" in str(result.errors[0]) + + # ── apiResult-level errors ─────────────────────────────────────────────── + + def test_api_result_errors_list_populated(self): + call_result = make_job_status_call_result( + api_result_errors=[{"code": 400, "domain": "altitude", "message": "bad input"}] + ) + result = DaasResult(call_result) + assert len(result.api_result_errors) == 1 + assert len(result.errors) == 1 + assert "bad input" in str(result.errors[0]) + + def test_api_result_single_error_field(self): + """The "error" (singular) key in apiResult creates api_result_error.""" + call_result = make_job_status_call_result() + call_result["apiResult"]["error"] = {"code": 403, "domain": "altitude", "message": "forbidden"} + result = DaasResult(call_result) + assert result.api_result_error is not None + assert result.api_result_error.code == 403 + assert any("forbidden" in str(e) for e in result.errors) + + def test_api_result_error_field_falsy_is_ignored(self): + """An "error" key that is falsy (empty dict, None) must not be parsed.""" + call_result = make_job_status_call_result() + call_result["apiResult"]["error"] = None + result = DaasResult(call_result) + assert result.api_result_error is None + assert len(result.errors) == 0 + + # ── errorMessage variants ──────────────────────────────────────────────── + + def test_error_message_string(self): + call_result = make_job_status_call_result(error_message="Something went wrong") + result = DaasResult(call_result) + assert any("Something went wrong" in str(e) for e in result.errors) + + def test_error_message_dict(self): + call_result = make_job_status_call_result( + error_message={"message": "Dict error occurred"} + ) + result = DaasResult(call_result) + assert any("Dict error occurred" in str(e) for e in result.errors) + + def test_empty_string_error_message_not_appended(self): + call_result = make_job_status_call_result(error_message="") + result = DaasResult(call_result) + assert len(result.errors) == 0 + + def test_none_error_message_not_appended(self): + call_result = make_job_status_call_result(error_message=None) + result = DaasResult(call_result) + assert len(result.errors) == 0 + + +# ── DaasGetJobStatusResult ──────────────────────────────────────────────────── + + +class TestDaasGetJobStatusResult: + # ── State transitions ──────────────────────────────────────────────────── + + def test_done_state(self): + result = DaasGetJobStatusResult(make_job_status_call_result("DONE")) + assert result.id == "job-1" + assert result.state == "DONE" + + def test_running_state(self): + result = DaasGetJobStatusResult(make_job_status_call_result("RUNNING")) + assert result.state == "RUNNING" + + def test_failed_state_with_error_result_overrides_state(self): + """errorResult in the status dict must force state to FAILED.""" + error_result = {"code": 500, "location": "svc", "message": "job crashed"} + result = DaasGetJobStatusResult( + make_job_status_call_result("DONE", error_result=error_result) + ) + assert result.state == "FAILED" + assert result.api_result_error is not None + assert result.api_result_error.message == "job crashed" + assert len(result.errors) >= 1 + + def test_missing_status_key_defaults_to_failed(self): + """A job dict without a 'status' key must default state to FAILED.""" + call_result = { + "errors": [], + "apiResult": { + "results": [{"id": "job-2"}], + "errors": [], + "errorMessage": None, + }, + } + result = DaasGetJobStatusResult(call_result) + assert result.state == "FAILED" + + # ── has_finished() ──────────────────────────────────────────────────────── + + def test_has_finished_done_returns_true(self): + result = DaasGetJobStatusResult(make_job_status_call_result("DONE")) + assert result.has_finished() is True + + def test_has_finished_running_returns_false(self): + result = DaasGetJobStatusResult(make_job_status_call_result("RUNNING")) + assert result.has_finished() is False + + def test_has_finished_failed_with_errors_returns_false(self): + """FAILED state with errors present → has_finished() returns False.""" + error_result = {"code": 500, "location": "svc", "message": "crashed"} + result = DaasGetJobStatusResult( + make_job_status_call_result("DONE", error_result=error_result) + ) + assert result.state == "FAILED" + assert result.has_finished() is False + + def test_has_finished_failed_no_errors_raises(self): + """FAILED state with no errors is an unexpected condition → raises.""" + result = DaasGetJobStatusResult(make_job_status_call_result("FAILED")) + # Manually clear errors to trigger the unhandled-failure branch. + result.errors = [] + with pytest.raises(Exception, match="got to failed state with no error"): + result.has_finished() + + +# ── DaasGetQueryResult ──────────────────────────────────────────────────────── + + +class TestDaasGetQueryResult: + def test_all_fields_present(self): + result = DaasGetQueryResult( + make_query_call_result(rows=[{"col": "val"}], total_rows=42, page_token="tok123") + ) + assert result.total_rows == 42 + assert result.rows == [{"col": "val"}] + assert result.page_token == "tok123" + + def test_fields_absent_return_none(self): + """Missing optional fields must default to None.""" + result = DaasGetQueryResult(make_query_call_result()) + assert result.total_rows is None + assert result.rows is None + assert result.page_token is None + + def test_empty_rows_and_zero_total(self): + result = DaasGetQueryResult(make_query_call_result(rows=[], total_rows=0)) + assert result.total_rows == 0 + assert result.rows == [] + + def test_inherits_from_daas_result(self): + result = DaasGetQueryResult(make_query_call_result(rows=[], total_rows=5)) + assert isinstance(result, DaasResult)