From 0c7fd335e7d009ad5c1af157a9eef468a62d6920 Mon Sep 17 00:00:00 2001 From: decryptus Date: Tue, 22 Sep 2026 13:15:04 +0200 Subject: [PATCH] Fix client isolation and API encoding; add tests, docs and automated PyPI releases --- .github/scripts/release.py | 53 +++++++ .github/workflows/ci.yml | 125 +++++++++++++++ CHANGELOG | 10 ++ MANIFEST.in | 4 + Makefile | 121 ++------------ README.md | 134 +++++++++++++++- RELEASE | 2 +- VERSION | 2 +- docs/conf.py | 193 ++-------------------- docs/index.md | 1 - docs/index.rst | 8 + docs/releases.md | 57 +++++++ docs/requirements.txt | 5 +- docs/usage.md | 4 + pyproject.toml | 3 + requirements.txt | 3 +- setup.cfg | 2 +- setup.py | 2 +- setup.yml | 24 +-- setup.yml.j2 | 20 +-- tests/test_client.py | 235 +++++++++++++++++++++++++++ tests/test_release.py | 70 ++++++++ updownio/__init__.py | 16 +- updownio/service.py | 255 ++++++++++++++++-------------- updownio/services/__init__.py | 32 +--- updownio/services/checks.py | 102 ++++-------- updownio/services/nodes.py | 10 +- updownio/services/recipients.py | 12 +- updownio/services/status_pages.py | 36 ++--- 29 files changed, 925 insertions(+), 616 deletions(-) create mode 100644 .github/scripts/release.py create mode 100644 .github/workflows/ci.yml create mode 100644 MANIFEST.in delete mode 100644 docs/index.md create mode 100644 docs/index.rst create mode 100644 docs/releases.md create mode 100644 docs/usage.md create mode 100644 pyproject.toml create mode 100644 tests/test_client.py create mode 100644 tests/test_release.py diff --git a/.github/scripts/release.py b/.github/scripts/release.py new file mode 100644 index 0000000..78329e6 --- /dev/null +++ b/.github/scripts/release.py @@ -0,0 +1,53 @@ +"""Select a version tag without modifying Git refs.""" +import os +from pathlib import Path +import re +import subprocess + + +def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + + +def select(): + version = Path('VERSION').read_text().strip() + if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version): + raise ValueError('VERSION must be X.Y.Z') + if Path('RELEASE').read_text().strip() != version: + raise ValueError('VERSION and RELEASE differ') + tag = 'v' + version + head = git('rev-parse', 'HEAD') + ref = 'refs/tags/' + tag + exists = subprocess.run(['git', 'show-ref', '--verify', '--quiet', ref]).returncode == 0 + if exists: + tagged = git('rev-parse', ref + '^{commit}') + subprocess.run(['git', 'merge-base', '--is-ancestor', tagged, head], check=True) + return tag, head, not exists + + +if __name__ == '__main__': + requested = os.environ.get('RELEASE_TAG', '') + if os.environ.get('GITHUB_REF', '').startswith('refs/tags/'): + requested = os.environ['GITHUB_REF'].removeprefix('refs/tags/') + if not re.fullmatch(r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', requested): + raise ValueError('Expected vX.Y.Z') + sha = git('rev-parse', 'HEAD') + subprocess.run(['git', 'merge-base', '--is-ancestor', sha, 'origin/main'], check=True) + if git('show', sha + ':VERSION') != requested[1:] or git('show', sha + ':RELEASE') != requested[1:]: + raise ValueError('Tag and version files differ') + tag, create, publish = requested, False, True + elif requested: + if os.environ.get('GITHUB_EVENT_NAME') != 'workflow_dispatch' or os.environ.get('GITHUB_REF') != 'refs/heads/main': + raise ValueError('Manual publication must run from main') + if not re.fullmatch(r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', requested): + raise ValueError('Expected vX.Y.Z') + sha = git('rev-parse', '--verify', 'refs/tags/' + requested + '^{commit}') + subprocess.run(['git', 'merge-base', '--is-ancestor', sha, 'HEAD'], check=True) + if git('show', sha + ':VERSION') != requested[1:] or git('show', sha + ':RELEASE') != requested[1:]: + raise ValueError('Tag and version files differ') + tag, create, publish = requested, False, True + else: + tag, sha, create = select() + publish = create or git('rev-parse', 'refs/tags/' + tag + '^{commit}') == sha + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'tag={tag}\nsha={sha}\ncreate={str(create).lower()}\npublish={str(publish).lower()}\n') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4470938 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +name: Tests, version tag and PyPI +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to publish (optional, for example v0.1.0)' + required: false + type: string +permissions: + contents: read +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install -r requirements.txt build PyYAML + - run: python -m unittest discover -s tests -v + - name: Check version consistency + run: | + python - <<'PY' + import ast, pathlib, yaml + version = pathlib.Path('VERSION').read_text().strip() + assert version == pathlib.Path('RELEASE').read_text().strip() + config = yaml.safe_load(pathlib.Path('setup.yml').read_text()) + assert config['version'] == config['release'] == version + PY + - run: python -m build + - name: Install and smoke-test wheel + run: | + python -m pip install dist/*.whl + cd "$RUNNER_TEMP" + python -c "import updownio; assert updownio.service('checks', api_key='dummy').timeout == 60" + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r docs/requirements.txt + - run: python -m sphinx -W --keep-going -b html docs docs/_build/html + + tag: + outputs: + publish: ${{ steps.release.outputs.publish }} + sha: ${{ steps.release.outputs.sha }} + tag: ${{ steps.release.outputs.tag }} + needs: [tests, docs] + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: updownio-tag + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Select tag + id: release + env: + RELEASE_TAG: ${{ inputs.tag }} + run: python .github/scripts/release.py + - name: Create tag after successful tests + if: steps.release.outputs.create == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + run: gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$TAG" -f sha="$RELEASE_SHA" + + build-pypi: + needs: tag + if: needs.tag.outputs.publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.tag.outputs.sha }} + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements.txt build twine + - run: python -m unittest discover -s tests -v + - run: python -m build + - run: python -m twine check --strict dist/* + - uses: actions/upload-artifact@v4 + with: + name: pypi-distributions + path: dist/* + if-no-files-found: error + retention-days: 7 + publish-pypi: + needs: build-pypi + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/updownio/ + permissions: + id-token: write + concurrency: + group: updownio-pypi + cancel-in-progress: false + steps: + - uses: actions/download-artifact@v4 + with: + name: pypi-distributions + path: dist/ + - name: Publish to PyPI using Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true diff --git a/CHANGELOG b/CHANGELOG index 3981177..6f9d4a0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,13 @@ +updownio (0.1.0) unstable; urgency=medium + + * Isolate service clients and validate configuration. + * Fix arrays, nested headers, empty responses and URL-based lookups. + * Preserve caller data, prevent credential forwarding on redirects. + * Add offline regression tests, CI, automatic tags and PyPI publishing. + * Repair source distributions and document Python 3.10+ support. + + -- Adrien DELLE CAVE (Decryptus) Tue, 22 Sep 2026 12:00:00 +0000 + updownio (0.0.7) unstable; urgency=medium * Fixed README.md. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1cf7b0c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include requirements.txt setup.yml VERSION RELEASE CHANGELOG +recursive-include tests *.py +recursive-include docs *.md *.rst *.py *.txt +recursive-include .github/scripts *.py diff --git a/Makefile b/Makefile index 171b686..74d7b66 100644 --- a/Makefile +++ b/Makefile @@ -1,111 +1,10 @@ -#!/usr/bin/make -f -# -*- makefile -*- - -CHANGELOG_PATH ?= "$(CURDIR)/CHANGELOG" -DCH_PATH ?= dch -DPKG_PARSECHANGELOG_PATH ?= dpkg-parsechangelog -EDITOR_PATH ?= vim -FIND_PATH ?= find -GIT_PATH ?= git -GREP_PATH ?= grep -J2_PATH ?= j2 -MAKE_PATH ?= make -PYTHON2_PATH ?= python2 -PYTHON3_PATH ?= python3 -RM_PATH ?= rm -SED_PATH ?= sed -TWINE_PATH ?= twine - -export PROJECT_RELEASE=$(shell $(DPKG_PARSECHANGELOG_PATH) -S Version -l $(CHANGELOG_PATH)) -export PROJECT_VERSION=$(shell $(DPKG_PARSECHANGELOG_PATH) -S Version -l $(CHANGELOG_PATH) | $(SED_PATH) -nre 's/^[^0-9]*(([0-9]+\.)*[0-9]+).*/\1/p') -export PROJECT_GIT_RELEASE="$(shell echo -n "$(PROJECT_RELEASE)"| $(SED_PATH) 's/~/-/g')" -export PROJECT_COPYRIGHT_YEAR=$(shell date +'%Y') - -changelog: - EDITOR="$(EDITOR_PATH)" $(DCH_PATH) -i -D unstable --no-auto-nmu -m --changelog "$(CHANGELOG_PATH)" - $(MAKE_PATH) release - $(MAKE_PATH) version - -release: - $(shell echo $(PROJECT_RELEASE) > "$(CURDIR)/RELEASE") - -version: - $(shell echo $(PROJECT_VERSION) > "$(CURDIR)/VERSION") - -setup-config: - $(MAKE_PATH) release - $(MAKE_PATH) version - $(J2_PATH) setup.yml.j2 -o setup.yml - -docs: setup-config - cd docs && $(MAKE_PATH) html - -clean-docs: - cd docs && $(MAKE_PATH) clean - -build-git-commit: - $(DPKG_PARSECHANGELOG_PATH) -S Changes -l "$(CHANGELOG_PATH)"|\ - $(GREP_PATH) '^\s\+\*'|\ - $(SED_PATH) -e 's/^\s\+\*\s\(.\+\)$$/\1/'|\ - $(GIT_PATH) commit -a -F - || true - -build-git-version: - if [ -d "bin" ]; then \ - $(FIND_PATH) bin/ -type f | while read file; \ - do \ - $(SED_PATH) -i -e "s/^\(__version__\s*=\s*\)['\"]\(.*\)['\"]/\1'${PROJECT_VERSION}'/" "$$file"; \ - done \ - fi ; - -push-git-version: - $(GIT_PATH) push - $(GIT_PATH) tag -a "v$(PROJECT_VERSION)" -m "version: $(PROJECT_VERSION)" - $(GIT_PATH) push origin "v$(PROJECT_VERSION)" - -push-git-release: - $(GIT_PATH) push - $(GIT_PATH) tag -a "$(PROJECT_GIT_RELEASE)-release" -m "release: $(PROJECT_GIT_RELEASE)" - $(GIT_PATH) push origin "$(PROJECT_GIT_RELEASE)-release" - -git-push: - $(MAKE_PATH) changelog - $(MAKE_PATH) setup-config - $(MAKE_PATH) build-git-version - $(MAKE_PATH) build-git-commit - -git-version: - $(MAKE_PATH) git-push - $(MAKE_PATH) push-git-version - -git-release: - $(MAKE_PATH) git-push - $(MAKE_PATH) push-git-release - -build-pip2: clean-pip - $(PYTHON2_PATH) setup.py bdist_wheel - -build-pip3: clean-pip - $(PYTHON3_PATH) setup.py bdist_wheel - -push-pip2: - $(TWINE_PATH) upload dist/* - -push-pip3: - $(TWINE_PATH) upload dist/* - -push-pip: - $(MAKE_PATH) build-pip2 - $(MAKE_PATH) push-pip2 - $(MAKE_PATH) build-pip3 - $(MAKE_PATH) push-pip3 - -clean-pip: - $(RM_PATH) -rf build/ dist/ - -clean-python: - $(FIND_PATH) -type f -name "*.pyc" -delete - $(FIND_PATH) -type d -name __pycache__ -exec rm -rf '{}' + - -clean: clean-docs clean-pip clean-python - -.PHONY: changelog release version +.PHONY: test build docs check +PYTHON ?= python3 +test: + $(PYTHON) -m unittest discover -s tests -v +build: + $(PYTHON) -m build +docs: + $(PYTHON) -m sphinx -W --keep-going -b html docs docs/_build/html +check: test build docs + $(PYTHON) -m twine check --strict dist/* diff --git a/README.md b/README.md index 67425d4..c573cb9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,79 @@ -## updownio project +# updownio [![PyPI pyversions](https://img.shields.io/pypi/pyversions/updownio.svg)](https://pypi.org/project/updownio/) [![PyPI version shields.io](https://img.shields.io/pypi/v/updownio.svg)](https://pypi.org/project/updownio/) [![Documentation Status](https://readthedocs.org/projects/updownio/badge/?version=latest)](https://updownio.readthedocs.io/) -updownio is a free and open-source, it's a python wrapper for the updown.io API. +A Python client for the [updown.io REST API](https://updown.io/api). +Manage checks, retrieve downtime and performance data, configure alert recipients, +and maintain status pages from Python scripts. + +Requires **Python 3.10 or newer**. It is a community client, not a monitoring +server. Only `requests` is required at runtime. + +## Quick start + +```sh +python -m pip install updownio +export UPDOWN_API_KEY='your-api-key' +``` + +```python +import updownio + +checks = updownio.service('checks', timeout=15) +for check in checks.list(): + print(check['token'], check['url'], check['down']) +``` + +Use a read-only API key for reporting and a read/write key for changes. The API +key is sent in an HTTP header. Each `service()` call creates an independent +client: different accounts can safely coexist as separate client objects. + +## Behaviour and errors + +- Methods return decoded JSON (including valid empty lists and dictionaries). +- `delete()` returns the API's `deleted` value as a boolean; a missing URL returns `None`. +- URL selectors use exact matching and download the check list on every lookup. + Use tokens for repeated operations. Duplicate URLs raise `ValueError`, so a + write cannot silently select the wrong check. If both are supplied, token wins. +- `downtimes()` returns **one page**, not the full history. Use `params={'page': 2}` + for subsequent pages (100 entries per page). +- Request data dictionaries are never modified by this library. +- Booleans use lowercase form values, lists use `name[]`, and dictionaries use + `name[key]`. An empty list is sent as `name[]=`; omission leaves that field out. + Empty dictionaries are rejected because form encoding is ambiguous. Individual + empty header values remain supported. End-to-end array clearing on the hosted + service has not been verified with an authenticated account. +- HTTP failures raise `updownio.UpDownIoError`, a subclass of `LookupError`, with + `status_code`. Invalid JSON raises the same exception. HTTP 204 returns `None`. + Error messages exclude request and response bodies to avoid exposing secrets. +- Network failures remain `requests.Timeout` / `requests.ConnectionError`. + Requests are not retried automatically, to avoid repeating writes. +- Redirects are not followed, so the API-key header is not forwarded elsewhere. +- The timeout is a positive finite number of seconds, applied to connection and + read inactivity separately; it is not an overall wall-clock deadline. +- `mk_api_call(raw_results=True)` returns the unvalidated Requests response; + the caller must close it, including on errors. + +```python +import requests +import updownio + +client = updownio.service('checks') +try: + checks = client.list() +except updownio.UpDownIoError as error: + print('API failure:', error.status_code) +except requests.Timeout: + print('Request timed out') +``` + +Configuration precedence: explicit argument, environment variable, default. +`UPDOWN_ENDPOINT` identifies the HTTP(S) origin; any path is replaced by +`/api/`, preserving the historical behaviour. Use HTTPS for remote +services. Credentials, query strings and fragments in the endpoint are rejected. + ## Installation @@ -18,7 +87,7 @@ updownio is a free and open-source, it's a python wrapper for the updown.io API. | `UPDOWN_ACCEPT_ENCODING` | HTTP Accept-Encoding request-header | gzip | | `UPDOWN_API_KEY` | API key for authentication | | | `UPDOWN_ENDPOINT` | API Endpoint | https://updown.io | -| `UPDOWN_TIMEOUT ` | Request timeout in seconds | 60 | +| `UPDOWN_TIMEOUT` | Request timeout in seconds | 60 | ## Usage @@ -33,7 +102,7 @@ import updownio ```python updown_checks = updownio.service('checks', api_key = 'xxxxxxxxxxx', - endpoint = 'https://example.org/api', + endpoint = 'https://updown.io', timeout = 3600) ``` @@ -65,7 +134,7 @@ check = updownio.service('checks').show(url = 'https://example.org') Select downtimes by token ```python -check = updownio.service('checks').downtimes(token = 'xxxx' +check = updownio.service('checks').downtimes(token = 'xxxx', params = {'page': 1, 'results': False}) ``` @@ -205,3 +274,58 @@ status_page = updownio.service('status_pages').update(token = 'xxxx', ```python updownio.service('status_pages').delete(token = 'xxxx') ``` + +## Additional endpoints + +```python +all_node_ips = updownio.service('nodes').ips() +pulse = updownio.service('checks').add(data={'type': 'pulse', 'alias': 'backup'}) +``` + +For options such as custom headers, HTTP methods and notification muting, pass +API fields through `data`. Consult the [API reference](https://updown.io/api) for +current values; this client does not duplicate the server's entire schema. + +```python +updated = updownio.service('checks').update( + token='xxxx', + data={'custom_headers': {'X-Example': 'value'}, 'enabled': False}, +) +``` + +## Upgrading from 0.0.7 + +Public service names and existing method arguments remain supported. Changes: + +- Python 2 and Python 3.9 or older are no longer supported. +- Clients no longer share configuration. Code relying on singleton instances must + keep and pass its client explicitly. +- Invalid data types raise an error rather than silently becoming empty data. +- Duplicate URL matches raise an error; use the desired check token. +- Empty responses are accepted; malformed JSON and HTTP errors are distinguished + from connection failures. API error messages no longer contain server bodies. +- Internal registry entries are constructors rather than shared instances; + registration still accepts either a service class or a legacy instance. +- `sonicprobe` is no longer a dependency; standard-library URL utilities suffice. + +## Development + +```sh +python -m pip install -e . build twine -r docs/requirements.txt +python -m unittest discover -s tests -v +python -m build +python -m twine check --strict dist/* +python -m sphinx -W --keep-going -b html docs docs/_build/html +``` + +Tests use simulated responses and a loopback HTTP server. They never create or +delete real updown.io resources. CI tests Python 3.10–3.14, builds distributions +and documentation, then installs the wheel outside the source directory. + +## Releases + +After successful tests on `main`, a new version in `VERSION`, `RELEASE` and +`setup.yml` creates `vX.Y.Z` and publishes that exact revision to PyPI via +Trusted Publishing. Existing versions are not overwritten. The workflow also +supports pushed version tags and a manual retry for an existing tag. +See [release configuration](docs/releases.md). diff --git a/RELEASE b/RELEASE index 5a5831a..6e8bf73 100644 --- a/RELEASE +++ b/RELEASE @@ -1 +1 @@ -0.0.7 +0.1.0 diff --git a/VERSION b/VERSION index 5a5831a..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.7 +0.1.0 diff --git a/docs/conf.py b/docs/conf.py index 028a428..36da16e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,185 +1,10 @@ -# -*- coding: utf-8 -*- -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) -import os +from pathlib import Path import yaml - -root_dir = os.path.abspath('..') -setup_config = os.path.join(root_dir, 'setup.yml') -setup_cfg = yaml.safe_load(open(setup_config, 'r').read()) - -# -- Project information ----------------------------------------------------- - -project = setup_cfg['name'] -copyright = setup_cfg['copyright'] -author = setup_cfg['author'] - -# The short X.Y version -version = setup_cfg['version'] -# The full version, including alpha/beta/rc tags -release = setup_cfg['release'] - - -# -- General configuration --------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'm2r2', - 'sphinx.ext.autodoc', - #'sphinx.ext.napoleon', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -source_suffix = ['.rst', '.md'] -#source_suffix = '.md' - -# The master toctree document. -master_doc = 'index' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = None - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# -# html_sidebars = {} - - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'updowniodoc' - - -# -- Options for LaTeX output ------------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'updownio.tex', u'updownio Documentation', - u'Adrien Delle Cave', 'manual'), -] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'updownio', u'updownio Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'updownio', u'updownio Documentation', - author, 'updownio', 'One line description of project.', - 'Miscellaneous'), -] - - -# -- Options for Epub output ------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] - -# Napoleon settings -#napoleon_google_docstring = True -#napoleon_numpy_docstring = False +config = yaml.safe_load((Path(__file__).resolve().parents[1] / "setup.yml").read_text()) +project = config["name"] +author = config["author"] +copyright = config["copyright"] +version = release = config["version"] +extensions = ["myst_parser"] +exclude_patterns = ["_build"] +html_theme = "alabaster" diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 97d4958..0000000 --- a/docs/index.md +++ /dev/null @@ -1 +0,0 @@ -.. mdinclude:: ../README.md diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..c501ec3 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,8 @@ +updownio documentation +====================== + +.. toctree:: + :maxdepth: 2 + + usage + releases diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..6570de8 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,57 @@ +# Releases and PyPI + +The workflow is `.github/workflows/ci.yml` (Tests, version tag and PyPI). +It runs tests on Python 3.10–3.14, builds source and wheel distributions, and +validates documentation. Pull requests cannot publish or create tags. + +## One-time configuration + +In the existing PyPI project `updownio`, open **Publishing**, then add a GitHub +Trusted Publisher with these exact values: + +| Field | Value | +| --- | --- | +| PyPI project | `updownio` | +| Owner | `decryptus` | +| Repository | `updownio` | +| Workflow filename | `ci.yml` | +| Environment | `pypi` | + +If the project does not yet exist, use PyPI's pending publisher form instead. +In GitHub, configure the `pypi` environment if you want branch restrictions or +reviewer approval. No API token secret is required. Only the publishing job has +`id-token: write`; it downloads the built artifact without checking out code. +Only the tag job has `contents: write`. + +## New version + +1. Update `VERSION`, `RELEASE`, and both version fields in `setup.yml` together. + Add release notes to `CHANGELOG`. Use a stable `X.Y.Z` version. +2. Open a pull request and merge to `main` after CI succeeds. +3. The workflow creates the missing tag after tests and documentation checks. +4. It checks out the selected commit, runs its tests, builds distributions, + validates them with Twine and publishes to PyPI. + +The publish job is part of the same workflow because a tag created with +`GITHUB_TOKEN` does not trigger a second push workflow. Ordinary later commits +with the same version do not move the old tag or publish new contents under it. +Explicitly pushed tags must match the version files and belong to `main` history. + +## Retry + +If Trusted Publishing was not configured, configure it and rerun the failed +publishing job. Alternatively, run the workflow manually **from main** with +`tag` set to the existing tag (for example `v0.1.0`). Its commit must belong to +main history and its version files must match. The tagged source is rebuilt; +artifacts are not built from the current main version by mistake. + +Publication is serialized and existing files are skipped. PyPI does not allow +replacing published files; use a new version for any changed package contents. + +## Local validation + +Build in isolation with `python -m build`, which also rebuilds the wheel from the +source archive. Run `python -m twine check --strict dist/*`. Install the wheel in +a clean environment outside the checkout, then run `python -m pip check`. + +No publication is performed by local tests or builds. diff --git a/docs/requirements.txt b/docs/requirements.txt index e0464a2..a337d85 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ -m2r2 -PyYAML>=3.10 +Sphinx>=7,<9 +myst-parser>=2,<5 +PyYAML>=6 diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..2cb706b --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,4 @@ +```{include} ../README.md +:relative-docs: docs/ +:relative-images: +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f559a0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=68", "wheel", "PyYAML>=6"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt index 663d85c..4d2a412 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -requests>=2.21.0 -sonicprobe>=0.3.51 +requests>=2.31,<3 diff --git a/setup.cfg b/setup.cfg index b88034e..08aedd7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [metadata] -description-file = README.md +description_file = README.md diff --git a/setup.py b/setup.py index 8eb5b26..bb1f8ed 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ author_email = setup_cfg['author_email'], license = setup_cfg['license'], url = setup_cfg['url'], - packages = find_packages(), + packages = find_packages(include=["updownio", "updownio.*"]), install_requires = requirements, python_requires = ', '.join(setup_cfg['python_requires']), classifiers = setup_cfg['classifiers'], diff --git a/setup.yml b/setup.yml index 23569f8..7992e18 100644 --- a/setup.yml +++ b/setup.yml @@ -4,30 +4,14 @@ description: Client library for updown.io API. author: Adrien Delle Cave author_email: pypi@doowan.net copyright: '2023 Adrien Delle Cave' -release: '0.0.7' -version: '0.0.7' +release: '0.1.0' +version: '0.1.0' license: License GPL-3 url: https://github.com/decryptus/updownio python_requires: - - '>=2.7' - - '!=3.0.*' - - '!=3.1.*' - - '!=3.2.*' - - '!=3.3.*' - - '!=3.4.*' - - '!=3.7.*' - - '!=3.9.*' + - '>=3.10' classifiers: - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)' - - 'Natural Language :: English' - - 'Operating System :: Unix' - - 'Programming Language :: Python' - - 'Programming Language :: Python :: 2' - - 'Programming Language :: Python :: 2.7' - 'Programming Language :: Python :: 3' - - 'Programming Language :: Python :: 3.5' - - 'Programming Language :: Python :: 3.6' - - 'Programming Language :: Python :: 3.7' - - 'Programming Language :: Python :: 3.9' + - 'Programming Language :: Python :: 3 :: Only' - 'Topic :: System :: Monitoring' - - 'Topic :: Utilities' diff --git a/setup.yml.j2 b/setup.yml.j2 index 41fad7a..1207cc3 100644 --- a/setup.yml.j2 +++ b/setup.yml.j2 @@ -9,25 +9,9 @@ version: '{{ env('PROJECT_VERSION') }}' license: License GPL-3 url: https://github.com/decryptus/updownio python_requires: - - '>=2.7' - - '!=3.0.*' - - '!=3.1.*' - - '!=3.2.*' - - '!=3.3.*' - - '!=3.4.*' - - '!=3.7.*' - - '!=3.9.*' + - '>=3.10' classifiers: - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)' - - 'Natural Language :: English' - - 'Operating System :: Unix' - - 'Programming Language :: Python' - - 'Programming Language :: Python :: 2' - - 'Programming Language :: Python :: 2.7' - 'Programming Language :: Python :: 3' - - 'Programming Language :: Python :: 3.5' - - 'Programming Language :: Python :: 3.6' - - 'Programming Language :: Python :: 3.7' - - 'Programming Language :: Python :: 3.9' + - 'Programming Language :: Python :: 3 :: Only' - 'Topic :: System :: Monitoring' - - 'Topic :: Utilities' diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..e1ac9ba --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,235 @@ +"""Offline regression tests; never contact a monitoring account.""" +import ast +import copy +import json +import os +from pathlib import Path +import re +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import Mock, patch +from urllib.parse import parse_qs + +import requests +import updownio +from updownio.service import UpDownIoServiceBase, UpDownIoServices + + +class ClientTests(unittest.TestCase): + def setUp(self): + self.env = patch.dict(os.environ, {}, clear=True) + self.env.start() + self.addCleanup(self.env.stop) + self.client = updownio.service('checks', api_key='dummy') + + def response(self, status=200, payload=None, text=None): + response = requests.Response() + response.status_code = status + response._content = (text if text is not None else json.dumps(payload)).encode() + response.close = Mock() + return response + + def test_clients_are_independent(self): + other = updownio.service('checks', api_key='other', timeout=7) + self.assertIsNot(self.client, other) + self.assertEqual(self.client.api_key, 'dummy') + self.assertEqual(self.client.timeout, 60) + self.assertEqual(other.timeout, 7) + + def test_factory_and_registry(self): + with self.assertRaises(ValueError): updownio.service('unknown', api_key='dummy') + with self.assertRaises(TypeError): UpDownIoServices().register(object()) + with self.assertRaises(TypeError): UpDownIoServiceBase() + registry = UpDownIoServices() + registry.register(self.client) + self.assertIs(registry['checks'], type(self.client)) + + def test_environment_and_precedence(self): + with patch.dict(os.environ, {'UPDOWN_API_KEY':'env', 'UPDOWN_TIMEOUT':'2.5'}): + client = updownio.service('nodes') + self.assertEqual(client.timeout, 2.5) + self.assertEqual(client.api_key, 'env') + self.assertEqual(updownio.service('nodes', api_key='arg', timeout=3).timeout, 3) + with self.assertRaises(ValueError): updownio.service('checks') + + def test_bad_timeouts(self): + for timeout in [0, -1, True, 'bad', 'nan', float('inf')]: + with self.subTest(timeout=timeout), self.assertRaises(ValueError): + updownio.service('checks', api_key='dummy', timeout=timeout) + with self.assertRaises(ValueError): self.client.mk_api_call(timeout=0) + + def test_endpoint_and_identifier(self): + for endpoint in ['file:///tmp/test', 'https://user:pass@example.org', 'https://example.org/?secret=x', 'https://example.org/#fragment']: + with self.subTest(endpoint=endpoint), self.assertRaises(ValueError): + updownio.service('checks', api_key='dummy', endpoint=endpoint) + client = updownio.service('checks', api_key='dummy', endpoint='https://example.org/legacy') + with patch('requests.get', return_value=self.response(payload={})) as call: + client.show(token='a/b?c#d') + self.assertEqual(call.call_args.args[0], 'https://example.org/api/checks/a%2Fb%3Fc%23d') + self.assertFalse(call.call_args.kwargs['allow_redirects']) + with self.assertRaises(ValueError): client.show(token='..') + + def test_valid_empty_json(self): + for payload in [[], {}, False, 0, None]: + response = self.response(payload=payload) + with self.subTest(payload=payload), patch('requests.get', return_value=response): + self.assertEqual(self.client.list(), payload) + response.close.assert_called_once() + + def test_http_errors_are_closed_and_redacted(self): + for status in [301, 401, 403, 404, 422, 429, 500]: + response = self.response(status, text='secret response') + with self.subTest(status=status), patch('requests.get', return_value=response): + with self.assertRaises(updownio.UpDownIoError) as caught: self.client.list() + self.assertIsInstance(caught.exception, LookupError) + self.assertEqual(caught.exception.status_code, status) + self.assertNotIn('secret', str(caught.exception)) + response.close.assert_called_once() + + def test_invalid_json_and_no_content(self): + response = self.response(text='not json') + with patch('requests.get', return_value=response), self.assertRaises(updownio.UpDownIoError): self.client.list() + response.close.assert_called_once() + with patch('requests.get', return_value=self.response(204, text='')): self.assertIsNone(self.client.list()) + + def test_raw_response_ownership(self): + response = self.response(500, text='error') + with patch('requests.get', return_value=response): + self.assertIs(self.client.mk_api_call(raw_results=True), response) + response.close.assert_not_called() + response.close() + + def test_network_errors_no_retry(self): + for error in [requests.Timeout, requests.ConnectionError]: + with patch('requests.post', side_effect=error('offline')) as call: + with self.assertRaises(error): self.client.add('https://example.org') + self.assertEqual(call.call_count, 1) + + def test_checks_form_preserves_inputs(self): + data = {'disabled_locations':['fra','syd'], 'recipients':['email:123'], + 'custom_headers':{'X-Api-Key':'example'}, 'enabled':False, 'alias':'A & B'} + original = copy.deepcopy(data) + for method in ['add','update']: + with self.subTest(method=method), patch('requests.' + ('post' if method == 'add' else 'put'), return_value=self.response(payload={})) as call: + if method == 'add': self.client.add('https://example.org', data=data) + else: self.client.update(token='abcd', data=data) + body = requests.Request('POST', 'https://example.org', data=call.call_args.kwargs['data']).prepare().body + parsed = parse_qs(body) + self.assertEqual(parsed['disabled_locations[]'], ['fra','syd']) + self.assertEqual(parsed['recipients[]'], ['email:123']) + self.assertEqual(parsed['custom_headers[X-Api-Key]'], ['example']) + self.assertEqual(parsed['enabled'], ['false']) + self.assertEqual(parsed['alias'], ['A & B']) + self.assertEqual(data, original) + + def test_empty_arrays_are_explicit(self): + with patch('requests.put', return_value=self.response(payload={})) as call: + self.client.update(token='abcd', data={'recipients':[], 'disabled_locations':[]}) + self.assertEqual(call.call_args.kwargs['data'], [('recipients[]',''), ('disabled_locations[]','')]) + + def test_invalid_data(self): + for data in ['wrong', []]: + with self.assertRaises(TypeError): self.client.update(token='abcd', data=data) + for data in [{'recipients':[3]}, {'custom_headers':{'X-Test':3}}, {'custom_headers':{}}]: + with self.assertRaises(ValueError): self.client.update(token='abcd', data=data) + with self.assertRaises(ValueError): self.client.add() + + def test_pulse_check(self): + with patch('requests.post', return_value=self.response(payload={'token':'pulse'})) as call: + self.assertEqual(self.client.add(data={'type':'pulse'})['token'], 'pulse') + self.assertEqual(call.call_args.kwargs['data'], [('type','pulse')]) + + def test_show_by_url_parameters(self): + with patch('requests.get', side_effect=[self.response(payload=[{'url':'https://example.org','token':'abcd'}]), self.response(payload={'metrics':{}})]) as call: + self.assertEqual(self.client.show(url='https://example.org',params={'metrics':True}), {'metrics':{}}) + self.assertEqual(call.call_count,2) + self.assertTrue(call.call_args.args[0].endswith('/abcd')) + self.assertEqual(call.call_args.kwargs['params'], {'metrics':'true'}) + + def test_missing_and_ambiguous_url(self): + for name in ['show','downtimes','metrics','update','delete']: + with self.subTest(name=name), patch('requests.get',return_value=self.response(payload=[])): + self.assertIsNone(getattr(self.client,name)(url='missing')) + with self.assertRaises(ValueError): getattr(self.client,name)() + with patch('requests.get',return_value=self.response(payload=[{'url':'same','token':'a'},{'url':'same','token':'b'}])): + with self.assertRaisesRegex(ValueError,'multiple'): self.client.delete(url='same') + + def test_metrics_and_downtimes_routes(self): + for name in ['metrics','downtimes']: + with self.subTest(name=name), patch('requests.get',return_value=self.response(payload=[])) as call: + getattr(self.client,name)(token='abcd',params={'page':2}) + self.assertTrue(call.call_args.args[0].endswith('/abcd/'+name)) + self.assertEqual(call.call_args.kwargs['params'],{'page':2}) + + def test_all_service_list_routes(self): + for name in ['checks','nodes','recipients','status_pages']: + with self.subTest(name=name), patch('requests.get',return_value=self.response(payload=[])) as call: + updownio.service(name,api_key='dummy').list() + self.assertEqual(call.call_args.args[0],'https://updown.io/api/'+name) + + def test_nodes_routes(self): + client=updownio.service('nodes',api_key='dummy') + for name in ['ips','ipv4','ipv6']: + with self.subTest(name=name), patch('requests.get',return_value=self.response(payload=[])) as call: + getattr(client,name)() + self.assertTrue(call.call_args.args[0].endswith('/nodes/'+name)) + + def test_recipients(self): + client=updownio.service('recipients',api_key='dummy') + data={'selected':False} + with patch('requests.post',return_value=self.response(201,payload={'id':'email:123'})) as call: + client.add('email','a@example.org',data=data) + self.assertEqual(dict(call.call_args.kwargs['data']),{'type':'email','value':'a@example.org','selected':'false'}) + self.assertEqual(data,{'selected':False}) + + def test_status_pages(self): + client=updownio.service('status_pages',api_key='dummy') + with patch('requests.post',return_value=self.response(payload={})) as call: + client.add(['abcd','efgh'],data={'name':'demo'}) + self.assertEqual(call.call_args.kwargs['data'],[('name','demo'),('checks[]','abcd'),('checks[]','efgh')]) + for checks in [[],['abcd'],'abcd']: + data={'checks':checks,'name':'demo'}; original=copy.deepcopy(data) + with patch('requests.put',return_value=self.response(payload={})) as call: + client.update('page',data=data) + self.assertEqual(data,original) + self.assertIn(('checks[]','abcd' if checks else ''),call.call_args.kwargs['data']) + + def test_delete_services(self): + for service,kwargs in [('checks',{'token':'abcd'}),('recipients',{'xid':'email:123'}),('status_pages',{'token':'page'})]: + client=updownio.service(service,api_key='dummy') + for deleted in [True,False]: + with self.subTest(service=service,deleted=deleted), patch('requests.delete',return_value=self.response(payload={'deleted':deleted})): + self.assertEqual(client.delete(**kwargs),deleted) + + def test_readme_python_syntax(self): + for block in re.findall(r'```python\n(.*?)```',Path('README.md').read_text(),re.S): + ast.parse(block) + + +class WireTests(unittest.TestCase): + def test_real_http_encoding_and_redirect(self): + captured=[] + class Handler(BaseHTTPRequestHandler): + def log_message(self,*args): pass + def do_POST(self): + captured.append((self.path,self.headers.get('X-Api-Key'),parse_qs(self.rfile.read(int(self.headers['Content-Length'])).decode(),keep_blank_values=True))) + self.send_response(201);self.end_headers();self.wfile.write(b'{"token":"abcd"}') + def do_GET(self): + captured.append(self.path) + if self.path=='/api/checks': + self.send_response(302);self.send_header('Location','/other') + else:self.send_response(200) + self.end_headers();self.wfile.write(b'[]') + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + client=updownio.service('checks',api_key='dummy',endpoint='http://127.0.0.1:%s'%server.server_port) + self.assertEqual(client.add('https://example.org',data={'recipients':['email:1'],'enabled':False}),{'token':'abcd'}) + self.assertEqual(captured[0],('/api/checks','dummy',{'url':['https://example.org'],'recipients[]':['email:1'],'enabled':['false']})) + with self.assertRaises(updownio.UpDownIoError):client.list() + self.assertNotIn('/other',captured) + finally:server.shutdown();server.server_close();thread.join() + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..5255198 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,70 @@ +"""Exercise release selection in disposable git repositories.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = Path('.github/scripts/release.py').resolve() + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.git('init','-b','main') + self.git('config','user.email','test@example.org') + self.git('config','user.name','Test') + for name in ['VERSION','RELEASE']:(self.root/name).write_text('0.1.0\n') + self.git('add','.') + self.git('commit','-m','initial') + self.sha=self.git('rev-parse','HEAD').strip() + self.git('update-ref','refs/remotes/origin/main',self.sha) + + def git(self,*args): + return subprocess.check_output(['git',*args],cwd=self.root,text=True,stderr=subprocess.DEVNULL) + + def select(self,tag='',ref='refs/heads/main',event='push',success=True): + output=self.root/'output' + output.write_text('') + env=dict(os.environ,GITHUB_OUTPUT=str(output),GITHUB_REF=ref,GITHUB_EVENT_NAME=event,RELEASE_TAG=tag) + proc=subprocess.run([sys.executable,str(SCRIPT)],cwd=self.root,env=env,capture_output=True,text=True) + if success:self.assertEqual(proc.returncode,0,proc.stderr) + else:self.assertNotEqual(proc.returncode,0) + return dict(line.split('=',1) for line in output.read_text().splitlines()) + + def test_new_version_and_same_commit_retry(self): + self.assertEqual(self.select(),{'tag':'v0.1.0','sha':self.sha,'create':'true','publish':'true'}) + self.git('tag','v0.1.0') + self.assertEqual(self.select()['create'],'false') + self.assertEqual(self.select()['publish'],'true') + + def test_later_commit_skips_but_manual_retries_original(self): + self.git('tag','v0.1.0') + (self.root/'README').write_text('later') + self.git('add','.');self.git('commit','-m','docs') + self.assertEqual(self.select()['publish'],'false') + selected=self.select(tag='v0.1.0',event='workflow_dispatch') + self.assertEqual(selected['sha'],self.sha) + self.assertEqual(selected['publish'],'true') + + def test_pushed_tag(self): + self.git('tag','v0.1.0') + self.assertEqual(self.select(ref='refs/tags/v0.1.0')['publish'],'true') + self.select(ref='refs/tags/v0.2.0',success=False) + + def test_invalid_version_and_manual_context(self): + (self.root/'VERSION').write_text('wrong') + self.select(success=False) + self.select(tag='v0.1.0',event='push',success=False) + self.select(tag='bad',event='workflow_dispatch',success=False) + + def test_unmerged_tag_rejected(self): + self.git('checkout','-b','unmerged') + (self.root/'README').write_text('unmerged') + self.git('add','.');self.git('commit','-m','unmerged');self.git('tag','v0.1.0') + self.select(ref='refs/tags/v0.1.0',success=False) + self.git('checkout','main') + self.select(tag='v0.1.0',event='workflow_dispatch',success=False) diff --git a/updownio/__init__.py b/updownio/__init__.py index 80a3fdf..66d4195 100644 --- a/updownio/__init__.py +++ b/updownio/__init__.py @@ -1,17 +1,13 @@ -# -*- coding: utf-8 -*- -# Copyright (C) 2023 Adrien Delle Cave # SPDX-License-Identifier: GPL-3.0-or-later -"""updownio""" +"""Python client for updown.io.""" +from updownio.service import SERVICES, UpDownIoError +from updownio.services import checks, nodes, recipients, status_pages - -import logging - -from updownio.services import * -from updownio.service import SERVICES +__all__ = ["service", "UpDownIoError"] def service(name, **kwargs): + """Create an independent client for a named service.""" if name not in SERVICES: raise ValueError("invalid service: %r" % name) - - return SERVICES[name].init(**kwargs) + return SERVICES[name]().init(**kwargs) diff --git a/updownio/service.py b/updownio/service.py index 3698bdb..4453890 100644 --- a/updownio/service.py +++ b/updownio/service.py @@ -1,160 +1,179 @@ -# -*- coding: utf-8 -*- -# Copyright (C) 2023 Adrien Delle Cave # SPDX-License-Identifier: GPL-3.0-or-later -"""updownio.service""" - +"""Shared HTTP transport and service registry.""" import abc +import math import os +from email.utils import formatdate +from urllib.parse import quote, urlsplit, urlunsplit -from datetime import datetime - -import logging import requests -from sonicprobe.libs import urisup +class UpDownIoError(LookupError): + """API failure; messages deliberately exclude request and response bodies.""" -LOG = logging.getLogger('updownio.service') + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code class UpDownIoServices(dict): def register(self, service): - if not isinstance(service, UpDownIoServiceBase): - raise TypeError("Invalid Service class. (class: %r)" % service) - return dict.__setitem__(self, service.SERVICE_NAME, service) - -SERVICES = UpDownIoServices() - -_DEFAULT_ACCEPT = "application/json" -_DEFAULT_ACCEPT_ENCODING = "gzip" -_DEFAULT_ENDPOINT = "https://updown.io" -_DEFAULT_TIMEOUT = 60 + # Accept legacy instance registration, but store constructors only. + cls = service if isinstance(service, type) else type(service) + if not issubclass(cls, UpDownIoServiceBase): + raise TypeError("Invalid service class") + self[cls.SERVICE_NAME] = cls -_DATE_FORMAT_GMT = '%a, %d %b %Y %H:%M:%S GMT' +SERVICES = UpDownIoServices() -class UpDownIoServiceBase(object): - __metaclass__ = abc.ABCMeta - @abc.abstractproperty - def SERVICE_NAME(self): - return +def copy_data(data): + if data is None: + return {} + if not isinstance(data, dict): + raise TypeError("data must be a dictionary") + return dict(data) + + +def string_array(name, values): + if not isinstance(values, (list, tuple)) or any( + not isinstance(value, str) or not value for value in values + ): + raise ValueError("%s must be a list or tuple of non-empty strings" % name) + return [(name + "[]", value) for value in values] or [(name + "[]", "")] + + +def form_data(data): + """Encode Rails-style arrays/hashes, retaining explicitly empty arrays.""" + result = [] + for key, value in data.items(): + if key in ("recipients", "disabled_locations", "checks") and value is not None: + result.extend(string_array(key, value)) + elif key == "custom_headers" and value is not None and not isinstance(value, dict): + raise ValueError("custom_headers must map strings to strings") + elif isinstance(value, (list, tuple)): + result.extend(string_array(key, value)) + elif isinstance(value, dict): + for subkey, subvalue in value.items(): + if not isinstance(subkey, str) or not isinstance(subvalue, str): + raise ValueError("%s must map strings to strings" % key) + result.append(("%s[%s]" % (key, subkey), subvalue)) + if not value: + raise ValueError("An empty mapping cannot be encoded unambiguously: %s" % key) + elif value is not None: + result.append((key, str(value).lower() if isinstance(value, bool) else value)) + return result + + +def identifier(value): + if not isinstance(value, str) or not value or value in (".", ".."): + raise ValueError("identifier must be a non-empty string") + return quote(value, safe="") + + +def validated_timeout(value): + if isinstance(value, bool): + raise ValueError("timeout must be a positive finite number") + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError("timeout must be a positive finite number") from None + if not math.isfinite(value) or value <= 0: + raise ValueError("timeout must be a positive finite number") + return value + + +class UpDownIoServiceBase(abc.ABC): + SERVICE_NAME = None def __init__(self): - self.api_key = None - self.endpoint = None - self.accept = None + self.api_key = None + self.endpoint = None + self.accept = None self.accept_encoding = None - self.timeout = None + self.timeout = None @staticmethod @abc.abstractmethod def get_default_api_path(): - return + """Return the service path relative to the origin.""" @staticmethod def get_default_accept(): - return _DEFAULT_ACCEPT + return "application/json" @staticmethod def get_default_accept_encoding(): - return _DEFAULT_ACCEPT_ENCODING + return "gzip" @staticmethod def get_default_endpoint(): - return _DEFAULT_ENDPOINT + return "https://updown.io" @staticmethod def get_default_timeout(): - return _DEFAULT_TIMEOUT + return 60 @staticmethod def get_date(): - return datetime.utcnow().strftime(_DATE_FORMAT_GMT) - - def build_api_uri(self, path = None, query = None, fragment = None): - uri = list(urisup.uri_help_split(self.endpoint)) - uri[2:5] = (path, query, fragment) - - return urisup.uri_help_unsplit(uri) - - def mk_api_headers(self, date = None): - if not date: - date = self.get_date() - - return {'Accept': self.accept, - 'Accept-Encoding': self.accept_encoding, - 'Date': date, - 'X-Api-Key': self.api_key} - - def mk_api_call(self, path = "", method = 'GET', raw_results = False, timeout = None, params = None, data = None): - if path: - path = "/%s" % path.strip('/') - else: - path = "" - - r = None - + return formatdate(usegmt=True) + + def build_api_uri(self, path=None, query=None, fragment=None): + uri = urlsplit(self.endpoint) + return urlunsplit((uri.scheme, uri.netloc, path or "", query or "", fragment or "")) + + def mk_api_headers(self, date=None): + return {"Accept": self.accept, "Accept-Encoding": self.accept_encoding, + "Date": date or self.get_date(), "X-Api-Key": self.api_key} + + def mk_api_call(self, path="", method="GET", raw_results=False, + timeout=None, params=None, data=None): + method = method.upper() + if method not in ("GET", "POST", "PUT", "DELETE"): + raise ValueError("unsupported HTTP method") + suffix = "/" + path.strip("/") if path else "" + uri = self.build_api_uri("/" + self.get_default_api_path() + suffix) + timeout = self.timeout if timeout is None else validated_timeout(timeout) + if isinstance(data, dict): + data = form_data(data) + if isinstance(params, dict): + params = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} + # Do not forward the custom API-key header to redirect destinations. + response = getattr(requests, method.lower())( + uri, params=params, data=data, headers=self.mk_api_headers(), + timeout=timeout, allow_redirects=False) + if raw_results: + return response # The caller owns and closes the response. try: - uri = self.build_api_uri("/%s%s" % (self.get_default_api_path(), path)) - - r = getattr(requests, method.lower())(uri, - params = params, - data = data, - headers = self.mk_api_headers(), - timeout = timeout or self.timeout) - - if raw_results: - return r - - if not r or r.status_code not in (200, 201) or not r.text: - LOG.error("unable to call uri: %r. (params: %r, data: %r)", uri, params, data) - raise LookupError("unable to call uri: %r. (response: %r)" % (uri, r.text)) - - res = r.json() - if not res: - raise LookupError("invalid response for %r" % path) - - return res + if not 200 <= response.status_code < 300: + raise UpDownIoError("updown.io API returned HTTP %s" % response.status_code, + response.status_code) + if response.status_code == 204: + return None + try: + return response.json() + except ValueError: + raise UpDownIoError("updown.io API returned invalid JSON", response.status_code) from None finally: - if r: - r.close() - - def init(self, api_key = None, endpoint = None, timeout = None, accept = None, accept_encoding = None): - if api_key: - self.api_key = api_key - elif os.environ.get('UPDOWN_API_KEY'): - self.api_key = os.environ['UPDOWN_API_KEY'] - else: - raise ValueError("missing updown api_key") - - if endpoint: - self.endpoint = endpoint - elif os.environ.get('UPDOWN_ENDPOINT'): - self.endpoint = os.environ['UPDOWN_ENDPOINT'] - else: - self.endpoint = self.get_default_endpoint() - - if accept: - self.accept = accept - elif os.environ.get('UPDOWN_ACCEPT'): - self.accept = os.environ['UPDOWN_ACCEPT'] - else: - self.accept = self.get_default_accept() - - if accept_encoding: - self.accept_encoding = accept_encoding - elif os.environ.get('UPDOWN_ACCEPT_ENCODING'): - self.accept_encoding = os.environ['UPDOWN_ACCEPT_ENCODING'] - else: - self.accept_encoding = self.get_default_accept_encoding() - - if timeout: - self.timeout = timeout - elif os.environ.get('UPDOWN_TIMEOUT'): - self.timeout = os.environ['UPDOWN_TIMEOUT'] - else: - self.timeout = self.get_default_timeout() - + response.close() + + def init(self, api_key=None, endpoint=None, timeout=None, accept=None, accept_encoding=None): + def setting(value, name, default=None): + return value if value is not None else os.environ.get(name, default) + api_key = setting(api_key, "UPDOWN_API_KEY") + if not isinstance(api_key, str) or not api_key.strip() or "\n" in api_key or "\r" in api_key: + raise ValueError("missing or invalid updown api_key") + endpoint = setting(endpoint, "UPDOWN_ENDPOINT", self.get_default_endpoint()) + uri = urlsplit(endpoint) + if uri.scheme not in ("http", "https") or not uri.hostname or uri.username or uri.password or uri.query or uri.fragment: + raise ValueError("endpoint must be an HTTP(S) URL without credentials, query or fragment") + # Legacy endpoint paths are replaced by /api/. + self.api_key = api_key + self.endpoint = endpoint + self.timeout = validated_timeout(setting(timeout, "UPDOWN_TIMEOUT", self.get_default_timeout())) + self.accept = setting(accept, "UPDOWN_ACCEPT", self.get_default_accept()) + self.accept_encoding = setting(accept_encoding, "UPDOWN_ACCEPT_ENCODING", self.get_default_accept_encoding()) return self diff --git a/updownio/services/__init__.py b/updownio/services/__init__.py index 74ec3c2..304e93d 100644 --- a/updownio/services/__init__.py +++ b/updownio/services/__init__.py @@ -1,31 +1 @@ -# -*- coding: utf-8 -*- -# Copyright (C) 2023 Adrien Delle Cave -# SPDX-License-Identifier: GPL-3.0-or-later - -import re as _re -import os as _os - -def _package_path(): - return _os.path.dirname(_os.path.abspath(__file__)) - -def _is_package_child(path, name): - full = _os.path.join(path, name) - if _os.path.isdir(full): - for sub in _os.listdir(full): - if _re.match(r"__init__\.py[a-z]?$", sub): - return True - else: - return False - else: - return _re.search(r"\.py[a-z]*$", name) \ - and '__init__' not in name - -# Python doesn't really want us to do that because of -# compatibility with stupid operating systems, but thanks -# to this function we can do it anyway... :) -def _get_module_list(path): - return list(set([_re.sub(r"\.py[a-z]?$", "", name) - for name in _os.listdir(path) - if _is_package_child(path, name)])) - -__all__ = _get_module_list(_package_path()) +"""API resource clients.""" diff --git a/updownio/services/checks.py b/updownio/services/checks.py index 4bae967..a22ce08 100644 --- a/updownio/services/checks.py +++ b/updownio/services/checks.py @@ -6,7 +6,7 @@ import logging -from updownio.service import UpDownIoServiceBase, SERVICES +from updownio.service import UpDownIoServiceBase, SERVICES, copy_data, form_data, string_array, identifier _DEFAULT_API_PATH = "api/checks" @@ -22,41 +22,22 @@ def get_default_api_path(): return _DEFAULT_API_PATH @staticmethod - def _build_disabled_locations(disabled_locations): - r = [] - - if not isinstance(disabled_locations, (list, tuple)): - return r - - for x in enumerate(disabled_locations): - if isinstance(x, str): - r.append(('disabled_locations[]', x)) - - return r + def _build_disabled_locations(values): + return string_array("disabled_locations", values) @staticmethod - def _build_recipients(recipients): - r = [] - - if not isinstance(recipients, (list, tuple)): - return r - - for x in enumerate(recipients): - if isinstance(x, str): - r.append(('recipients[]', x)) - - return r + def _build_recipients(values): + return string_array("recipients", values) def _match_by_url(self, url): xlist = self.list() if not xlist: return None - for x in xlist: - if x['url'] == url: - return x - - return None + matches = [x for x in xlist if x.get('url') == url] + if len(matches) > 1: + raise ValueError("multiple checks match this URL; use a token") + return matches[0] if matches else None def _fetch_token_from_url(self, url): x = self._match_by_url(url) @@ -73,9 +54,12 @@ def show(self, token = None, url = None, params = None): raise ValueError("missing arguments token and url") if token: - return self.mk_api_call(token, params = params) + return self.mk_api_call(identifier(token), params = params) - return self._match_by_url(url) + match = self._match_by_url(url) + if match is not None and params: + return self.mk_api_call(identifier(match['token']), params=params) + return match def downtimes(self, token = None, url = None, params = None): if not token and not url: @@ -86,7 +70,7 @@ def downtimes(self, token = None, url = None, params = None): if not token: return None - return self.mk_api_call("%s/downtimes" % token, + return self.mk_api_call("%s/downtimes" % identifier(token), params = params) def metrics(self, token = None, url = None, params = None): @@ -98,51 +82,26 @@ def metrics(self, token = None, url = None, params = None): if not token: return None - return self.mk_api_call("%s/metrics" % token, + return self.mk_api_call("%s/metrics" % identifier(token), params = params) - def add(self, url, data = None): - if not isinstance(data, dict): - data = {} - - data['url'] = url - disabled_locations = data.pop('disabled_locations', None) - recipients = data.pop('recipients', None) - data = list(data.items()) - - if disabled_locations: - data.extend(self._build_disabled_locations(disabled_locations)) - - if recipients: - data.extend(self._build_recipients(recipients)) - - return self.mk_api_call(method = 'POST', data = data) - - def update(self, token = None, url = None, data = None): - if not isinstance(data, dict): - data = {} + def add(self, url=None, data=None): + data = copy_data(data) + if url is not None: + data['url'] = url + if not data.get('url') and data.get('type') != 'pulse': + raise ValueError("url is required except for pulse checks") + return self.mk_api_call(method='POST', data=form_data(data)) + def update(self, token=None, url=None, data=None): + data = copy_data(data) if not token and not url: raise ValueError("missing arguments token and url") - - if not token and url: + if not token: token = self._fetch_token_from_url(url) if not token: return None - - disabled_locations = data.pop('disabled_locations', None) - recipients = data.pop('recipients', None) - data = list(data.items()) - - if disabled_locations: - data.extend(self._build_disabled_locations(disabled_locations)) - - if recipients: - data.extend(self._build_recipients(recipients)) - - return self.mk_api_call("%s" % token, - method = 'PUT', - data = data) + return self.mk_api_call(identifier(token), method='PUT', data=form_data(data)) def delete(self, token = None, url = None): if not token and not url: @@ -153,7 +112,7 @@ def delete(self, token = None, url = None): if not token: return None - r = self.mk_api_call("%s" % token, + r = self.mk_api_call(identifier(token), method = 'DELETE') if not r: return False @@ -161,7 +120,4 @@ def delete(self, token = None, url = None): return bool(r.get('deleted')) -if __name__ != "__main__": - def _start(): - SERVICES.register(UpDownIoChecks()) - _start() +SERVICES.register(UpDownIoChecks) diff --git a/updownio/services/nodes.py b/updownio/services/nodes.py index bf50297..622d9d4 100644 --- a/updownio/services/nodes.py +++ b/updownio/services/nodes.py @@ -6,7 +6,7 @@ import logging -from updownio.service import UpDownIoServiceBase, SERVICES +from updownio.service import UpDownIoServiceBase, SERVICES, copy_data, form_data, string_array, identifier _DEFAULT_API_PATH = "api/nodes" @@ -24,6 +24,9 @@ def get_default_api_path(): def list(self): return self.mk_api_call() + def ips(self): + return self.mk_api_call('/ips') + def ipv4(self): return self.mk_api_call('/ipv4') @@ -31,7 +34,4 @@ def ipv6(self): return self.mk_api_call('/ipv6') -if __name__ != "__main__": - def _start(): - SERVICES.register(UpDownIoNodes()) - _start() +SERVICES.register(UpDownIoNodes) diff --git a/updownio/services/recipients.py b/updownio/services/recipients.py index 00c430d..31d75d3 100644 --- a/updownio/services/recipients.py +++ b/updownio/services/recipients.py @@ -6,7 +6,7 @@ import logging -from updownio.service import UpDownIoServiceBase, SERVICES +from updownio.service import UpDownIoServiceBase, SERVICES, copy_data, form_data, string_array, identifier _DEFAULT_API_PATH = "api/recipients" @@ -25,8 +25,7 @@ def list(self): return self.mk_api_call() def add(self, xtype, value, data = None): - if not isinstance(data, dict): - data = {} + data = copy_data(data) data['type'] = xtype data['value'] = value @@ -34,7 +33,7 @@ def add(self, xtype, value, data = None): return self.mk_api_call(method = 'POST', data = data) def delete(self, xid): - r = self.mk_api_call("%s" % xid, + r = self.mk_api_call(identifier(xid), method = 'DELETE') if not r: return False @@ -42,7 +41,4 @@ def delete(self, xid): return bool(r.get('deleted')) -if __name__ != "__main__": - def _start(): - SERVICES.register(UpDownIoRecipients()) - _start() +SERVICES.register(UpDownIoRecipients) diff --git a/updownio/services/status_pages.py b/updownio/services/status_pages.py index 478769e..0952c63 100644 --- a/updownio/services/status_pages.py +++ b/updownio/services/status_pages.py @@ -6,7 +6,7 @@ import logging -from updownio.service import UpDownIoServiceBase, SERVICES +from updownio.service import UpDownIoServiceBase, SERVICES, copy_data, form_data, string_array, identifier _DEFAULT_API_PATH = "api/status_pages" @@ -23,16 +23,9 @@ def get_default_api_path(): @staticmethod def _build_checks(checks): - r = [] - - if not isinstance(checks, (list, tuple)): - return r - - for x in enumerate(checks): - if isinstance(x, str) and x.isalnum(): - r.append(('checks[]', checks)) - - return r + if isinstance(checks, str): + checks = [checks] + return string_array("checks", checks) def list(self): return self.mk_api_call() @@ -41,32 +34,30 @@ def add(self, checks, data = None): if isinstance(checks, str): checks = [checks] - if not isinstance(data, dict): - data = {} + data = copy_data(data) data.pop('checks', None) - data = list(data.items()) + data = form_data(data) data.extend(self._build_checks(checks)) return self.mk_api_call(method = 'POST', data = data) def update(self, token, data = None): - if not isinstance(data, dict): - data = {} + data = copy_data(data) checks = data.pop('checks', None) - data = list(data.items()) + data = form_data(data) - if checks: + if checks is not None: data.extend(self._build_checks(checks)) - return self.mk_api_call("%s" % token, + return self.mk_api_call(identifier(token), method = 'PUT', data = data) def delete(self, token): - r = self.mk_api_call("%s" % token, + r = self.mk_api_call(identifier(token), method = 'DELETE') if not r: return False @@ -74,7 +65,4 @@ def delete(self, token): return bool(r.get('deleted')) -if __name__ != "__main__": - def _start(): - SERVICES.register(UpDownIoStatusPages()) - _start() +SERVICES.register(UpDownIoStatusPages)