diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b78cd..960f76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Version 0.3.0 + +- New option `--delete-project` to delete al project versions from a docat server +- New option `--system-certs` to use the system SSL certificate bundle instead of [certifi](https://github.com/certifi/python-certifi). + ## Version 0.2.0 - New option `--verbose` for some debug information diff --git a/README.md b/README.md index cc62de1..3dd393a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # docat-upload -[![Release](https://img.shields.io/github/v/release/palto42/docat-upload)](https://github.com/palto42/docat-upload/releases) +![python version](https://img.shields.io/badge/python-3.10+-blue.svg) +[![PyPI](https://img.shields.io/pypi/v/girsh)](https://pypi.org/project/docat-upload) [![Build status](https://img.shields.io/github/actions/workflow/status/palto42/docat-upload/main.yml?branch=main)](https://github.com/palto42/docat-upload/actions/workflows/main.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/palto42/docat-upload/branch/main/graph/badge.svg)](https://codecov.io/gh/palto42/docat-upload) [![Commit activity](https://img.shields.io/github/commit-activity/m/palto42/docat-upload)](https://github.com/palto42/docat-upload/graphs/commit-activity) @@ -18,7 +19,9 @@ Extra features: - For Python documentation the script can extract the document version from the Python module with the same name as `project`. - Limit the number of published version with `-max-versions` - The script will automatically delete older versions if the number of versions is exceeded +- Delete all current versions of a project with `--delete-project` - Specify custom SSL certificate path if an in-house CA is used. +- Use the system CA bundle instead of requests' default certifi bundle with `--system-cert`. - Support insecure SSL for use with self signed certificates ## Usage @@ -42,13 +45,40 @@ options: -m NUM, --max-versions NUM Cut number of versions to max. NUM -d, --delete Delete the specified version + --delete-project Delete the entire project by removing all versions -V, --version show program's version number and exit -i, --insecure Don't check SSL cert -c SSL_CERT, --ssl-cert SSL_CERT Path to SSL cert or cert bundle, e.g. /etc/ssl/certs/ca-certificates.crt + --system-cert Use the system CA bundle instead of requests default certifi bundle -v, --verbose Verbose output ``` +### Example + +Upload a new documentation version to a project: + +```bash +uv run python -m docat_upload.docat_upload \ + -p my-project \ + -s https://docat.example.com \ + -a YOUR_API_KEY \ + -f docs/_build/html \ + -r 1.2.0 \ + -t latest \ + -m 5 +``` + +Delete all versions of a project from the docat server: + +```bash +uv run python -m docat_upload.docat_upload \ + -p my-project \ + -s https://docat.example.com \ + -a YOUR_API_KEY \ + --delete-project +``` + ### `.env` settings Instead of passing the options via CLI, they can be provided in an `.env` file. diff --git a/pyproject.toml b/pyproject.toml index 72ab480..17685f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "docat-upload" -version = "0.2.0" +version = "0.3.0" description = "Tool for uploading HTML documentation to a docat server, as an alternative to docatl." authors = [{ name = "Matthias Homann", email = "palto@mailbox.org" }] readme = "README.md" diff --git a/src/docat_upload/docat_upload.py b/src/docat_upload/docat_upload.py index 084f951..68ab9c7 100644 --- a/src/docat_upload/docat_upload.py +++ b/src/docat_upload/docat_upload.py @@ -4,6 +4,7 @@ import importlib import logging import re +import ssl from importlib.metadata import version from json import JSONDecodeError from pathlib import Path @@ -268,6 +269,78 @@ def delete_version(project: str, api_key: str | None, release: str, server: str, return False +def delete_project(project: str, api_key: str | None, server: str, verify_ssl: str | bool = True) -> bool: + """Delete all versions of a project from the docat server. + + Parameters + ---------- + project : str + Name of the project on the docat server + api_key : str | None + API key of the project + server : str + Dcat server URL + verify_ssl : str | bool, optional + Verify SSL (True), path to certs or accept insecure SSL (False), by default True + + Returns + ------- + bool + True = successful + """ + project_url = f"{server}/api/projects/{project}" + logger.debug("Fetching project versions from %s", project_url) + try: + response = requests.get( + project_url, + timeout=60, + verify=verify_ssl, + ) + except requests.exceptions.SSLError as e: + logger.error("SSL error during project deletion: %s", e) # noqa: TRY400 + return False + except requests.exceptions.ConnectionError as e: + logger.error("Connection error during project deletion: %s", e) # noqa: TRY400 + return False + try: + project_data = response.json() + except JSONDecodeError: + logger.exception("Failed to decode project version data for %s", project) + return False + + versions = project_data.get("versions", []) + if not versions: + logger.info("No versions found for project %s", project) + return True + + # Delete each version + for doc_version in versions: + name = doc_version.get("name") + if not name: + continue + logger.debug("Deleting version %s of project %s", name, project) + ok = delete_version(project=project, api_key=api_key, release=name, server=server, verify_ssl=verify_ssl) + if not ok: + logger.error("Failed to delete version %s of project %s", name, project) + return False + + logger.info("Deleted all %d versions of project %s", len(versions), project) + return True + + +def get_system_ca_bundle() -> str: + """Return the system CA bundle path for SSL verification.""" + verify_paths = ssl.get_default_verify_paths() + if verify_paths.cafile: + logger.debug("Using system CA file %s", verify_paths.cafile) + return verify_paths.cafile + if verify_paths.capath: + logger.debug("Using system CA path %s", verify_paths.capath) + return verify_paths.capath + logger.error("Could not locate the system CA bundle") + raise FileNotFoundError("System CA bundle not found") # noqa: TRY003 + + def get_args() -> argparse.Namespace: """Parse CLI arguments @@ -346,6 +419,11 @@ def greater_zero(value): help="Delete the specified version", action="store_true", ) + parser.add_argument( + "--delete-project", + help="Delete the entire project by removing all versions", + action="store_true", + ) parser.add_argument( "-V", "--version", @@ -363,7 +441,12 @@ def greater_zero(value): "--ssl-cert", help="Path to SSL cert or cert bundle, e.g. /etc/ssl/certs/ca-certificates.crt", type=str, - default=config.get("CERT_PATH"), + default=None, + ) + parser.add_argument( + "--system-cert", + help="Use the system CA bundle instead of requests default certifi bundle", + action="store_true", ) parser.add_argument( "-v", @@ -373,7 +456,10 @@ def greater_zero(value): ) args = parser.parse_args() - if (args.delete or args.max_versions) and not args.api_key: + if args.ssl_cert is None and not args.system_cert: + args.ssl_cert = config.get("CERT_PATH") + + if (args.delete or args.max_versions or args.delete_project) and not args.api_key: parser.error( "No API key provided as argument, environment variable 'DOCAT_API_KEY' or in '.env' file, but required when --max-versions is used" ) @@ -381,7 +467,7 @@ def greater_zero(value): return args -def main(): +def main(): # noqa: C901 """Package documents and upload them to docat server""" args = get_args() configure_logging(args.verbose) @@ -390,7 +476,24 @@ def main(): if not args.insecure: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - verify_ssl = args.ssl_cert if args.ssl_cert else args.insecure + if args.system_cert: + verify_ssl = get_system_ca_bundle() + elif args.ssl_cert: + verify_ssl = args.ssl_cert + else: + verify_ssl = args.insecure + + if args.delete_project: + return ( + 0 + if delete_project( + project=args.project, + api_key=args.api_key, + server=args.server, + verify_ssl=verify_ssl, + ) + else 1 + ) if args.release is None: module = importlib.import_module(args.project) diff --git a/tests/test_docat_upload.py b/tests/test_docat_upload.py index eed8eb7..2d2c76d 100644 --- a/tests/test_docat_upload.py +++ b/tests/test_docat_upload.py @@ -8,8 +8,10 @@ import requests from docat_upload.docat_upload import ( + delete_project, delete_version, get_args, + get_system_ca_bundle, main, prune_versions, tag_release, @@ -225,6 +227,41 @@ def test_upload_docs_zip_file_cleanup(self, tmp_path): assert not zip_file.exists() +class TestSystemCert: + """Test cases for system certificate handling""" + + def test_get_system_ca_bundle_returns_cafile(self): + verify_paths = Mock(cafile="/etc/ssl/certs/ca-certificates.crt", capath=None) + with patch( + "docat_upload.docat_upload.ssl.get_default_verify_paths", + return_value=verify_paths, + ): + result = get_system_ca_bundle() + + assert result == "/etc/ssl/certs/ca-certificates.crt" + + def test_get_system_ca_bundle_returns_capath(self): + verify_paths = Mock(cafile=None, capath="/etc/ssl/certs") + with patch( + "docat_upload.docat_upload.ssl.get_default_verify_paths", + return_value=verify_paths, + ): + result = get_system_ca_bundle() + + assert result == "/etc/ssl/certs" + + def test_get_system_ca_bundle_raises_when_no_path_found(self): + verify_paths = Mock(cafile=None, capath=None) + with ( + patch( + "docat_upload.docat_upload.ssl.get_default_verify_paths", + return_value=verify_paths, + ), + pytest.raises(FileNotFoundError), + ): + get_system_ca_bundle() + + class TestTagRelease: """Test cases for tag_release function""" @@ -551,7 +588,18 @@ class TestGetArgs: def test_get_args_requires_api_key_for_delete(self): with patch.object( - sys, "argv", ["prog", "-p", "test-project", "-s", "http://localhost:8000", "-r", "1.0.0", "--delete"] + sys, + "argv", + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-r", + "1.0.0", + "--delete", + ], ): with pytest.raises(SystemExit) as excinfo: get_args() @@ -561,7 +609,17 @@ def test_get_args_requires_api_key_for_max_versions(self): with patch.object( sys, "argv", - ["prog", "-p", "test-project", "-s", "http://localhost:8000", "-r", "1.0.0", "--max-versions", "1"], + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-r", + "1.0.0", + "--max-versions", + "1", + ], ): with pytest.raises(SystemExit) as excinfo: get_args() @@ -618,9 +676,48 @@ def test_main_delete_returns_zero_on_success(self): assert result == 0 mock_delete_version.assert_called_once() + def test_main_disables_ssl_warnings_when_insecure(self): + with ( + patch.object( + sys, + "argv", + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-r", + "1.0.0", + "--delete", + "-a", + "test-key", + "--insecure", + ], + ), + patch("docat_upload.docat_upload.urllib3.disable_warnings") as mock_disable_warnings, + patch("docat_upload.docat_upload.delete_version", return_value=True), + ): + result = main() + + assert result == 0 + mock_disable_warnings.assert_called_once() + def test_main_skips_upload_for_unreleased_version(self): with ( - patch.object(sys, "argv", ["prog", "-p", "test-project", "-s", "http://localhost:8000", "-r", "1.0.0a"]), + patch.object( + sys, + "argv", + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-r", + "1.0.0a", + ], + ), patch("docat_upload.docat_upload.upload_docs") as mock_upload_docs, patch("docat_upload.docat_upload.tag_release") as mock_tag_release, patch("docat_upload.docat_upload.prune_versions") as mock_prune_versions, @@ -667,3 +764,258 @@ def test_main_calls_upload_tag_and_prune(self, tmp_path): mock_upload_docs.assert_called_once() mock_tag_release.assert_called_once() mock_prune_versions.assert_called_once() + + def test_main_falls_back_to_unknown_and_skips_when_module_has_no_version(self): + with ( + patch.object( + sys, + "argv", + ["prog", "-p", "test-project", "-s", "http://localhost:8000"], + ), + patch("docat_upload.docat_upload.importlib.import_module", return_value=Mock()) as mock_import, + patch("docat_upload.docat_upload.upload_docs") as mock_upload_docs, + patch("docat_upload.docat_upload.tag_release") as mock_tag_release, + patch("docat_upload.docat_upload.prune_versions") as mock_prune_versions, + ): + result = main() + + # module without __version__ should set release to 'unknown' and be skipped + assert result is None + assert mock_import.called + mock_upload_docs.assert_not_called() + mock_tag_release.assert_not_called() + mock_prune_versions.assert_not_called() + + def test_main_uses_module_version_when_release_not_provided(self, tmp_path): + temp_folder = tmp_path / "docs" + temp_folder.mkdir() + fake_module = Mock() + fake_module.__version__ = "2.3.4" + with ( + patch.object( + sys, + "argv", + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-f", + str(temp_folder), + "-a", + "test-key", + "-t", + "latest", + "-m", + "2", + ], + ), + patch( + "docat_upload.docat_upload.importlib.import_module", + return_value=fake_module, + ) as mock_import, + patch("docat_upload.docat_upload.upload_docs") as _mock_upload_docs, + patch("docat_upload.docat_upload.tag_release") as _mock_tag_release, + patch("docat_upload.docat_upload.prune_versions") as _mock_prune_versions, + ): + result = main() + + assert result is None + # ensure we attempted to import the module and proceed without errors + # specific calls to upload/tag/prune may be executed or mocked depending + # on test environment; we just assert main completed successfully + assert mock_import.called + + def test_main_uses_system_cert_when_requested(self, tmp_path): + temp_folder = tmp_path / "docs" + temp_folder.mkdir() + with ( + patch.object( + sys, + "argv", + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-r", + "1.0.0", + "-f", + str(temp_folder), + "-a", + "test-key", + "-t", + "latest", + "-m", + "1", + "--system-cert", + ], + ), + patch( + "docat_upload.docat_upload.get_system_ca_bundle", + return_value="/system/ca.pem", + ) as mock_bundle, + patch("docat_upload.docat_upload.upload_docs") as mock_upload_docs, + patch("docat_upload.docat_upload.tag_release") as mock_tag_release, + patch("docat_upload.docat_upload.prune_versions") as mock_prune_versions, + ): + result = main() + + assert result is None + mock_bundle.assert_called_once() + mock_upload_docs.assert_called_once() + assert mock_upload_docs.call_args.kwargs["verify_ssl"] == "/system/ca.pem" + mock_tag_release.assert_called_once() + mock_prune_versions.assert_called_once() + + def test_main_delete_project_returns_zero_on_success(self): + with ( + patch.object( + sys, + "argv", + [ + "prog", + "-p", + "test-project", + "-s", + "http://localhost:8000", + "-r", + "1.0.0", + "--delete-project", + "-a", + "test-key", + ], + ), + patch("docat_upload.docat_upload.delete_project", return_value=True) as mock_delete_project, + ): + result = main() + + assert result == 0 + mock_delete_project.assert_called_once() + + +class TestDeleteProject: + """Test cases for delete_project function""" + + def test_delete_project_success(self): + with ( + patch("docat_upload.docat_upload.requests.get") as mock_get, + patch("docat_upload.docat_upload.delete_version") as mock_delete_version, + ): + mock_response = Mock() + mock_response.json.return_value = { + "versions": [ + {"name": "1.0.0"}, + {"name": "2.0.0"}, + ] + } + mock_get.return_value = mock_response + + mock_delete_version.return_value = True + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is True + assert mock_delete_version.call_count == 2 + + def test_delete_project_no_versions(self): + with patch("docat_upload.docat_upload.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = {"versions": []} + mock_get.return_value = mock_response + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is True + + def test_delete_project_ssl_error(self): + with patch("docat_upload.docat_upload.requests.get") as mock_get: + mock_get.side_effect = requests.exceptions.SSLError("SSL error") + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is False + + def test_delete_project_connection_error(self): + with patch("docat_upload.docat_upload.requests.get") as mock_get: + mock_get.side_effect = requests.exceptions.ConnectionError("Connection failed") + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is False + + def test_delete_project_json_error(self): + from json import JSONDecodeError + + with patch("docat_upload.docat_upload.requests.get") as mock_get: + mock_response = Mock() + mock_response.json.side_effect = JSONDecodeError("Invalid JSON", "", 0) + mock_get.return_value = mock_response + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is False + + def test_delete_project_skips_entries_without_name(self): + with ( + patch("docat_upload.docat_upload.requests.get") as mock_get, + patch("docat_upload.docat_upload.delete_version") as mock_delete_version, + ): + mock_response = Mock() + # First entry missing 'name' should be skipped, second deleted + mock_response.json.return_value = {"versions": [{"id": 1}, {"name": "1.2.3"}]} + mock_get.return_value = mock_response + + mock_delete_version.return_value = True + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is True + # Only one valid version should trigger delete_version + assert mock_delete_version.call_count == 1 + + def test_delete_project_returns_false_when_delete_fails(self): + with ( + patch("docat_upload.docat_upload.requests.get") as mock_get, + patch("docat_upload.docat_upload.delete_version") as mock_delete_version, + ): + mock_response = Mock() + mock_response.json.return_value = {"versions": [{"name": "1.0.0"}]} + mock_get.return_value = mock_response + + mock_delete_version.return_value = False + + result = delete_project( + project="test-project", + api_key="test-key", + server="http://localhost:8000", + ) + + assert result is False + mock_delete_version.assert_called_once() diff --git a/uv.lock b/uv.lock index efca5ef..9e88006 100644 --- a/uv.lock +++ b/uv.lock @@ -336,7 +336,7 @@ wheels = [ [[package]] name = "docat-upload" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "python-dotenv" },