diff --git a/kopf/__init__.py b/kopf/__init__.py index 4e6c9d66..bcdebced 100644 --- a/kopf/__init__.py +++ b/kopf/__init__.py @@ -34,6 +34,9 @@ MultiProgressStorage, SmartProgressStorage, ) +from kopf._cogs.helpers.versions import ( + version as __version__, +) from kopf._cogs.structs.bodies import ( RawEventType, RawEvent, diff --git a/kopf/_cogs/clients/auth.py b/kopf/_cogs/clients/auth.py index 4b4af342..fe0344bd 100644 --- a/kopf/_cogs/clients/auth.py +++ b/kopf/_cogs/clients/auth.py @@ -9,6 +9,7 @@ import aiohttp from kopf._cogs.clients import errors +from kopf._cogs.helpers import versions from kopf._cogs.structs import credentials # Per-operator storage and exchange point for authentication methods. @@ -186,7 +187,7 @@ def __init__( auth = None # It is a good practice to self-identify a bit. - headers['User-Agent'] = f'kopf/unknown' # TODO: add version someday + headers['User-Agent'] = f'kopf/{versions.version or "unknown"}' # Generic aiohttp session based on the constructed credentials. self.session = aiohttp.ClientSession( diff --git a/kopf/_cogs/helpers/versions.py b/kopf/_cogs/helpers/versions.py new file mode 100644 index 00000000..bd7e895f --- /dev/null +++ b/kopf/_cogs/helpers/versions.py @@ -0,0 +1,25 @@ +""" +Detecting the framework's own version. + +The codebase does not contain the version directly, as it would require +code changes on every release. Kopf's releases depend on tagging rather +than in-code version bumps (Kopf's authour believes that versions belong +to the versioning system, not to the codebase). + +The version is determined only once at startup when the code is loaded. +""" +from typing import Optional + +version: Optional[str] = None + +try: + import pkg_resources +except ImportError: + pass +else: + try: + name, *_ = __name__.split('.') # usually "kopf", unless renamed/forked. + dist: pkg_resources.Distribution = pkg_resources.get_distribution(name) + version = dist.version + except Exception: + pass # installed as an egg, from git, etc. diff --git a/kopf/_core/reactor/running.py b/kopf/_core/reactor/running.py index f629e3ca..9583f73e 100644 --- a/kopf/_core/reactor/running.py +++ b/kopf/_core/reactor/running.py @@ -9,6 +9,7 @@ from kopf._cogs.aiokits import aioadapters, aiobindings, aiotasks, aiotoggles, aiovalues from kopf._cogs.clients import auth from kopf._cogs.configs import configuration +from kopf._cogs.helpers import versions from kopf._cogs.structs import credentials, ephemera, references, reviews from kopf._core.actions import execution, lifecycles from kopf._core.engines import activities, admission, daemons, indexing, peering, posting, probing @@ -488,6 +489,7 @@ async def _startup_cleanup_activities( Beside calling the startup/cleanup handlers, it performs few operator-scoped cleanups too (those that cannot be handled by garbage collection). """ + logger.debug(f"Starting Kopf {versions.version or '(unknown version)'}.") # Execute the startup activity before any root task starts running (due to readiness flag). try: diff --git a/tests/conftest.py b/tests/conftest.py index 2ff76cc4..3e543a7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -411,6 +411,7 @@ def fake_vault(mocker, hostname): try: yield vault finally: + # await vault.close() # TODO: but it runs in a different loop, w/ wrong contextvar. auth.vault_var.reset(token) # diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 00000000..f9b07c5a --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,38 @@ +import json +from typing import Any, Dict + +import pytest + +from kopf._cogs.clients.auth import APIContext, reauthenticated_request + + +def test_package_version(): + import kopf + assert hasattr(kopf, '__version__') + assert kopf.__version__ # not empty, not null + + +@pytest.mark.parametrize('version, useragent', [ + ('1.2.3', 'kopf/1.2.3'), + ('1.2rc', 'kopf/1.2rc'), + (None, 'kopf/unknown'), +]) +async def test_http_user_agent_version( + aresponses, hostname, fake_vault, mocker, version, useragent): + + mocker.patch('kopf._cogs.helpers.versions.version', version) + + @reauthenticated_request + async def get_it(url: str, *, context: APIContext) -> Dict[str, Any]: + response = await context.session.get(url) + return await response.json() + + async def responder(request): + return aresponses.Response( + content_type='application/json', + text=json.dumps(dict(request.headers))) + + aresponses.add(hostname, '/', 'get', responder) + returned_headers = await get_it(f"http://{hostname}/") + assert returned_headers['User-Agent'] == useragent + await fake_vault.close() # to prevent ResourceWarnings for unclosed connectors