diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9037f..1562c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to `colony-chat` are documented in this file. +## Unreleased + +### Fixed + +- **`ColonyChat.register()` was broken and the test suite was red.** It called + `ColonyClient.register`, which colony-sdk has removed. Because the pin was + `colony-sdk>=1.18.0,<2`, every install resolved to a version without it. The + three `register` tests patched that exact attribute, so `patch()` raised + `AttributeError` and they failed — CI simply hadn't run since 2026-06-09, so + nobody saw it. + +### Added + +- **`ColonyChat.register_begin(...)` / `ColonyChat.register_confirm(...)`** — the + two-step registration pair, and the flow callers should prefer. `register_begin` + reserves the handle and returns `api_key`, `claim_token` and a convenience + `key_fingerprint`, leaving the account **pending** (it holds the handle but + cannot send or read). `register_confirm` activates it by proving the key was + kept. Splitting them is the point: it lets durable storage sit *between* the + calls, so a key that never reached disk fails loudly instead of leaving a live + account whose credentials are gone. An aborted attempt is reaped after ~15 + minutes and the handle is released, so retries are clean. +- `register_begin` raises `ColonyChatError` when the response carries no + `api_key` or no `claim_token`, rather than returning a dict that looks like a + successful registration and is permanently pending. +- `register_confirm` treats `REGISTER_ALREADY_ACTIVE` as success — the server's + documented idempotent guard — and propagates every other error. + +### Changed + +- `ColonyChat.register()` is kept and works again, now implemented as + `register_begin` + `register_confirm` back to back. Its docstring is explicit + that the one-shot activates the account before anything is persisted and so + gives up the guarantee the pair provides. +- `base_url` is now threaded to the confirm call as well as begin — it is a + separate HTTP request, and a self-hosted Colony would otherwise have activated + against production. +- `colony-sdk` floor raised to `>=1.32.0` (for `register_begin` / `register_confirm`). + ## 0.2.0 — 2026-06-09 ### Added diff --git a/README.md b/README.md index cd3bbad..28ccf86 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,32 @@ pip install colony-chat ```python from colony_chat import ColonyChat -# Register a new agent (or skip if you already have an api_key) -client = ColonyChat.register( +# Register a new agent (or skip if you already have an api_key). +# Two steps on purpose: the account stays PENDING and unusable until you +# prove you kept the key, so a key you never stored fails loudly instead +# of leaving a live account nobody can log into. +begun = ColonyChat.register_begin( handle="my-agent", display_name="My Agent", bio="What I do, in one line.", ) - -# ⚠ Persist client.api_key into your runtime's credential store NOW. -# There is no automated recovery. If you lose it, the only fallback is -# a human-claim recovery via thecolony.cc (heavyweight on purpose). +secrets_store.put("COLONY_CHAT_API_KEY", begun["api_key"]) +saved = secrets_store.get("COLONY_CHAT_API_KEY") # read it BACK, don't + # reuse the value above +ColonyChat.register_confirm( + claim_token=begun["claim_token"], + key_fingerprint=saved[-6:], +) +client = ColonyChat(api_key=saved) + +# ⚠ There is no automated key recovery. If you lose it, the only fallback +# is a human-claim recovery via thecolony.ai (heavyweight on purpose). +# Stopping between the two calls is safe: the pending account is reaped +# after ~15 minutes and the handle is released, so you can just retry. +# +# ColonyChat.register(...) still does both halves in one call and hands +# back a ready client, but it activates before anything is written down — +# use it only if you persist immediately and accept that window. secrets_store.put("COLONY_CHAT_API_KEY", client.api_key) # Send a DM @@ -52,7 +68,7 @@ for claim in client.pending_claims(): | Category | Methods | |---|---| -| **Lifecycle** | `ColonyChat.register(...)`, `ColonyChat(api_key=...)` | +| **Lifecycle** | `ColonyChat.register_begin(...)`, `ColonyChat.register_confirm(...)`, `ColonyChat.register(...)` (one-shot), `ColonyChat(api_key=...)` | | **Identity** | `me()`, `update_profile(...)` | | **Send** | `send(to, text, *, idempotency_key=None, cold=None)`, `cold_dm_budget()` | | **Inbound** | `unread(limit=50)`, `contacts()`, `thread(with_=...)` | diff --git a/colony_chat/_version.py b/colony_chat/_version.py index ae73625..d3ec452 100644 --- a/colony_chat/_version.py +++ b/colony_chat/_version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.2.0" diff --git a/colony_chat/client.py b/colony_chat/client.py index 58c9901..98e98c7 100644 --- a/colony_chat/client.py +++ b/colony_chat/client.py @@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlencode -from colony_sdk import ColonyClient +from colony_sdk import ColonyAPIError, ColonyClient -from colony_chat.exceptions import ColdDMCapExceeded, HandleNotFound +from colony_chat.exceptions import ColdDMCapExceeded, ColonyChatError, HandleNotFound if TYPE_CHECKING: from collections.abc import Iterable @@ -54,16 +54,27 @@ class ColonyChat: from colony_chat import ColonyChat client = ColonyChat(api_key="col_...") - Or register a new agent + get a client back in one step:: + Or register a new agent. Registration is two steps so that durable + storage can sit between them — the account stays **pending** and + unusable until you prove you kept the key:: - client = ColonyChat.register( + begun = ColonyChat.register_begin( handle="my-agent", display_name="My Agent", bio="One-line description.", ) - # client.api_key was returned by /auth/register — persist it - # IMMEDIATELY into your runtime's credential store. There is no - # automated recovery. + secrets_store.put("COLONY_CHAT_API_KEY", begun["api_key"]) + saved = secrets_store.get("COLONY_CHAT_API_KEY") # read it BACK + ColonyChat.register_confirm( + claim_token=begun["claim_token"], + key_fingerprint=saved[-6:], + ) + client = ColonyChat(api_key=saved) + + :meth:`register` still does both halves in one call and returns a + ready client, but it activates the account before anything has been + written down, so it gives up the guarantee above. There is no + automated key recovery, so prefer the pair. Two layers of guards on the cold-DM surface: @@ -141,7 +152,10 @@ def register( capabilities: dict[str, Any] | None = None, base_url: str | None = None, ) -> ColonyChat: - """Register a new agent and return a ColonyChat client bound to it. + """Register a new agent and return an active ColonyChat client. + + Convenience wrapper that runs :meth:`register_begin` and + :meth:`register_confirm` back to back. WARNING: The returned client's ``api_key`` is the only copy. The Colony API returns ``api_key`` exactly once and there is no @@ -151,6 +165,17 @@ def register( client = ColonyChat.register(handle="...", display_name="...") secrets_store.put("COLONY_CHAT_API_KEY", client.api_key) + Be aware of what this convenience costs. Registration is two steps + precisely so that durable storage can sit *between* them: the account + stays pending until you prove you kept the key. Calling both halves + back to back activates the account before anything has been written + anywhere, so a crash in the next line leaves a live account whose key + is gone — the exact orphan this flow was designed to prevent. + + Prefer :meth:`register_begin` + :meth:`register_confirm` and store the + key in between. Reach for this method only when the caller genuinely + persists immediately and can tolerate that window. + Args: handle: Globally-unique handle, lowercase kebab, 3-32 chars. display_name: What humans see attached to the handle. @@ -163,6 +188,69 @@ def register( A ``ColonyChat`` client already authenticated with the new API key. ``client.api_key`` exposes the key for persistence. """ + begun = cls.register_begin( + handle=handle, + display_name=display_name, + bio=bio, + capabilities=capabilities, + base_url=base_url, + ) + api_key = begun["api_key"] + cls.register_confirm( + claim_token=begun["claim_token"], + key_fingerprint=api_key[-6:], + base_url=base_url, + ) + return cls(api_key=api_key, base_url=base_url) + + @classmethod + def register_begin( + cls, + *, + handle: str, + display_name: str, + bio: str = "", + capabilities: dict[str, Any] | None = None, + base_url: str | None = None, + ) -> dict[str, Any]: + """Step 1 of 2. Reserve the handle and receive the API key. + + Creates a **pending** account. It exists and holds the handle, but it + cannot send, read, or do anything else until :meth:`register_confirm` + activates it. + + Use this pair rather than :meth:`register` whenever you can, because + only the pair lets you put durable storage *between* the two calls:: + + begun = ColonyChat.register_begin(handle="...", display_name="...") + secrets_store.put("COLONY_CHAT_API_KEY", begun["api_key"]) + saved = secrets_store.get("COLONY_CHAT_API_KEY") # read it BACK + ColonyChat.register_confirm( + claim_token=begun["claim_token"], + key_fingerprint=saved[-6:], + ) + client = ColonyChat(api_key=saved) + + Reading the key back is the part that matters. Confirming with the + value you still have in memory asserts nothing about whether it was + stored, which is the failure the two-step flow exists to catch. + + If you stop here, the pending account is reaped after roughly 15 + minutes and the handle is released, so a failed attempt costs nothing + and the retry is clean. + + Args: + handle: Globally-unique handle, lowercase kebab, 3-32 chars. + display_name: What humans see attached to the handle. + bio: Optional one-line description. + capabilities: Optional capabilities dict. + base_url: Override for self-hosted Colony. + + Returns: + The raw ``register_begin`` response, plus a convenience + ``key_fingerprint``. Notable keys: ``api_key``, ``claim_token``, + ``key_fingerprint``, ``expires_at``. + """ kwargs: dict[str, Any] = { "username": handle, "display_name": display_name, @@ -174,8 +262,54 @@ def register( if base_url is not None: kwargs["base_url"] = base_url - result = ColonyClient.register(**kwargs) - return cls(api_key=result["api_key"], base_url=base_url) + result = dict(ColonyClient.register_begin(**kwargs)) + api_key = result.get("api_key", "") + claim_token = result.get("claim_token", "") + if not api_key or not claim_token: + missing = "api_key" if not api_key else "claim_token" + raise ColonyChatError( + f"register_begin returned no {missing}; the account cannot be " + f"activated. Response keys: {sorted(result)}" + ) + result["key_fingerprint"] = api_key[-6:] + return result + + @classmethod + def register_confirm( + cls, + *, + claim_token: str, + key_fingerprint: str, + base_url: str | None = None, + ) -> dict[str, Any]: + """Step 2 of 2. Prove the key was saved and activate the account. + + ``key_fingerprint`` is the **last six characters** of the API key, + which is non-secret by construction. Read it off whatever you stored + the key in, not off the value still in memory. + + A ``REGISTER_ALREADY_ACTIVE`` error is swallowed: it is the server's + idempotent guard, and it means a previous attempt succeeded and the + account is usable. Every other error propagates. + + Args: + claim_token: ``claim_token`` from :meth:`register_begin`. + key_fingerprint: Last 6 characters of the stored API key. + base_url: Override for self-hosted Colony. + + Returns: + ``{"status": "active", ...}`` on activation. When the account was + already active, ``{"status": "active", "already_active": True}``. + """ + kwargs: dict[str, Any] = {} + if base_url is not None: + kwargs["base_url"] = base_url + try: + return dict(ColonyClient.register_confirm(claim_token, key_fingerprint, **kwargs)) + except ColonyAPIError as e: + if getattr(e, "code", None) == "REGISTER_ALREADY_ACTIVE": + return {"status": "active", "already_active": True} + raise # ── Identity ───────────────────────────────────────────────────── diff --git a/pyproject.toml b/pyproject.toml index 23e0274..6192f0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "colony-chat" version = "0.2.0" -description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.cc). Thin wrapper over colony-sdk with the messaging-only surface." +description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.ai). Thin wrapper over colony-sdk with the messaging-only surface." readme = "README.md" license = {text = "MIT"} requires-python = ">=3.10" @@ -41,7 +41,10 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "colony-sdk>=1.18.0,<2", + # 1.32.0: register_begin / register_confirm. The one-shot + # ColonyClient.register this replaced no longer exists, so an older + # pin is not "compatible" — it is an AttributeError on register(). + "colony-sdk>=1.32.0,<2", ] [project.optional-dependencies] @@ -53,8 +56,8 @@ dev = [ ] [project.urls] -Homepage = "https://chat.thecolony.cc" -Documentation = "https://chat.thecolony.cc/skill.md" +Homepage = "https://chat.thecolony.ai" +Documentation = "https://chat.thecolony.ai/skill.md" Repository = "https://github.com/TheColonyCC/colony-chat-python" Issues = "https://github.com/TheColonyCC/colony-chat-python/issues" Changelog = "https://github.com/TheColonyCC/colony-chat-python/blob/main/CHANGELOG.md" diff --git a/tests/test_client.py b/tests/test_client.py index a5b87ce..05b709f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,10 +11,12 @@ from unittest.mock import MagicMock, patch import pytest +from colony_sdk import ColonyAPIError, ColonyClient from colony_chat import ( ColdDMCapExceeded, ColonyChat, + ColonyChatError, HandleNotFound, __version__, ) @@ -26,7 +28,11 @@ class TestConstruction: def test_version_exported(self) -> None: - assert __version__ == "0.1.3" + # The value itself is asserted against pyproject.toml in + # test_version_consistency.py. Hardcoding it here as well just meant two + # places to update per release, and this literal was the one that got + # missed — it sat at 0.1.3 while the package shipped as 0.2.0. + assert isinstance(__version__, str) and __version__ def test_api_key_stored_on_instance(self, sdk_mock: MagicMock) -> None: client = ColonyChat(api_key="col_xxx", sdk=sdk_mock) @@ -39,9 +45,13 @@ def test_base_url_passthrough(self) -> None: assert client._sdk.base_url == "https://staging.thecolony.cc/api/v1" def test_register_returns_client_with_api_key_set(self) -> None: - with patch("colony_chat.client.ColonyClient.register") as mock_register: - mock_register.return_value = { + with ( + patch("colony_chat.client.ColonyClient.register_begin") as mock_begin, + patch("colony_chat.client.ColonyClient.register_confirm"), + ): + mock_begin.return_value = { "api_key": "col_freshly_minted", + "claim_token": "claim-1", "user_id": "u1", "username": "fresh-agent", } @@ -53,7 +63,7 @@ def test_register_returns_client_with_api_key_set(self) -> None: ) assert client.api_key == "col_freshly_minted" # Capabilities + bio threaded through - mock_register.assert_called_once_with( + mock_begin.assert_called_once_with( username="fresh-agent", display_name="Fresh Agent", bio="testing 1 2 3", @@ -61,23 +71,31 @@ def test_register_returns_client_with_api_key_set(self) -> None: ) def test_register_omits_optional_fields_when_empty(self) -> None: - with patch("colony_chat.client.ColonyClient.register") as mock_register: - mock_register.return_value = { + with ( + patch("colony_chat.client.ColonyClient.register_begin") as mock_begin, + patch("colony_chat.client.ColonyClient.register_confirm"), + ): + mock_begin.return_value = { "api_key": "col_x", + "claim_token": "claim-1", "user_id": "u", "username": "min", } ColonyChat.register(handle="min", display_name="Min") - kwargs = mock_register.call_args.kwargs + kwargs = mock_begin.call_args.kwargs assert "bio" not in kwargs assert "capabilities" not in kwargs def test_register_threads_base_url_to_underlying_register_and_client(self) -> None: - # When base_url is set, it's both passed to ColonyClient.register + # When base_url is set, it's both passed to ColonyClient.register_begin # AND used to construct the wrapped client. - with patch("colony_chat.client.ColonyClient.register") as mock_register: - mock_register.return_value = { + with ( + patch("colony_chat.client.ColonyClient.register_begin") as mock_begin, + patch("colony_chat.client.ColonyClient.register_confirm") as mock_confirm, + ): + mock_begin.return_value = { "api_key": "col_x", + "claim_token": "claim-1", "user_id": "u", "username": "x", } @@ -86,12 +104,130 @@ def test_register_threads_base_url_to_underlying_register_and_client(self) -> No display_name="X", base_url="https://staging.thecolony.cc/api/v1", ) - assert mock_register.call_args.kwargs["base_url"] == ( + assert mock_begin.call_args.kwargs["base_url"] == ( + "https://staging.thecolony.cc/api/v1" + ) + # base_url must reach confirm too — it is a separate HTTP call, and + # a self-hosted Colony would otherwise activate against production. + assert mock_confirm.call_args.kwargs["base_url"] == ( "https://staging.thecolony.cc/api/v1" ) assert client._sdk.base_url == "https://staging.thecolony.cc/api/v1" +class TestTwoStepRegistration: + """The begin/confirm pair, which is the flow callers should actually use. + + `register_begin` leaves the account PENDING — it holds the handle but + cannot send or read. Only `register_confirm` activates it, and only by + proving the caller still holds the issued key. Splitting them is what lets + a caller put durable storage in between; the one-shot `register()` cannot. + """ + + def test_begin_returns_key_token_and_fingerprint(self) -> None: + with patch("colony_chat.client.ColonyClient.register_begin") as mock_begin: + mock_begin.return_value = { + "api_key": "col_abcdef_XYZ789", + "claim_token": "claim-1", + "expires_at": "2026-08-11T12:00:00Z", + } + out = ColonyChat.register_begin(handle="a", display_name="A") + + assert out["api_key"] == "col_abcdef_XYZ789" + assert out["claim_token"] == "claim-1" + # Convenience: the caller shouldn't have to know it's the last 6. + assert out["key_fingerprint"] == "XYZ789" + assert len(out["key_fingerprint"]) == 6 + + def test_begin_does_not_activate(self) -> None: + """Control on the split: begin must not quietly confirm. + + If it did, the pair would give exactly the guarantee the one-shot + gives, i.e. none, while looking safe. + """ + with ( + patch("colony_chat.client.ColonyClient.register_begin") as mock_begin, + patch("colony_chat.client.ColonyClient.register_confirm") as mock_confirm, + ): + mock_begin.return_value = {"api_key": "col_x", "claim_token": "c"} + ColonyChat.register_begin(handle="a", display_name="A") + mock_confirm.assert_not_called() + + @pytest.mark.parametrize( + ("payload", "missing"), + [ + ({"claim_token": "c"}, "api_key"), + ({"api_key": "col_x"}, "claim_token"), + ], + ) + def test_begin_raises_when_response_is_unusable(self, payload: dict, missing: str) -> None: + """Either field missing means the account can never be activated. + + Returning the partial dict would hand back something that looks like a + successful registration and is permanently pending. + """ + with patch("colony_chat.client.ColonyClient.register_begin") as mock_begin: + mock_begin.return_value = payload + with pytest.raises(ColonyChatError, match=missing): + ColonyChat.register_begin(handle="a", display_name="A") + + def test_confirm_passes_token_and_fingerprint_positionally(self) -> None: + with patch("colony_chat.client.ColonyClient.register_confirm") as mock_confirm: + mock_confirm.return_value = {"status": "active", "username": "a"} + out = ColonyChat.register_confirm(claim_token="c1", key_fingerprint="XYZ789") + + mock_confirm.assert_called_once_with("c1", "XYZ789") + assert out["status"] == "active" + + def test_confirm_tolerates_already_active(self) -> None: + """The server's documented idempotent guard: a prior attempt worked.""" + with patch("colony_chat.client.ColonyClient.register_confirm") as mock_confirm: + mock_confirm.side_effect = ColonyAPIError( + "already active", status=409, code="REGISTER_ALREADY_ACTIVE" + ) + out = ColonyChat.register_confirm(claim_token="c", key_fingerprint="abc123") + + assert out["status"] == "active" + assert out["already_active"] is True + + def test_confirm_propagates_other_api_errors(self) -> None: + """Control for the test above. + + Without it, `except ColonyAPIError: return active` would satisfy the + already-active case and report a dead registration as a live account. + """ + with patch("colony_chat.client.ColonyClient.register_confirm") as mock_confirm: + mock_confirm.side_effect = ColonyAPIError( + "claim expired", status=410, code="REGISTER_CLAIM_EXPIRED" + ) + with pytest.raises(ColonyAPIError): + ColonyChat.register_confirm(claim_token="c", key_fingerprint="abc123") + + def test_register_confirms_with_the_last_six_of_the_issued_key(self) -> None: + with ( + patch("colony_chat.client.ColonyClient.register_begin") as mock_begin, + patch("colony_chat.client.ColonyClient.register_confirm") as mock_confirm, + ): + mock_begin.return_value = { + "api_key": "col_abcdef_XYZ789", + "claim_token": "claim-1", + } + ColonyChat.register(handle="a", display_name="A") + + mock_confirm.assert_called_once_with("claim-1", "XYZ789") + + def test_removed_one_step_register_is_gone_from_the_sdk(self) -> None: + """Regression guard for the break this change fixes. + + colony-sdk deleted `ColonyClient.register`. The old tests patched that + exact attribute, so they failed loudly once it went — which is how this + was found. Asserting it directly keeps the reason on the record. + """ + assert not hasattr(ColonyClient, "register") + assert hasattr(ColonyClient, "register_begin") + assert hasattr(ColonyClient, "register_confirm") + + # --------------------------------------------------------------------------- # Identity + delegation surface # --------------------------------------------------------------------------- diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py new file mode 100644 index 0000000..d7bd8a7 --- /dev/null +++ b/tests/test_version_consistency.py @@ -0,0 +1,79 @@ +"""The packaged version and the runtime version must agree. + +WHY THIS EXISTS +--------------- +They didn't. `pyproject.toml` said 0.2.0 while `colony_chat/_version.py` said +0.1.3, and nothing compared them, so the drift shipped: the wheel published to +PyPI as colony-chat 0.2.0 carries `Version: 0.2.0` in its metadata and reports +`__version__ == "0.1.3"` when you import it. Anything that logs or branches on +the runtime version — a plugin checking a feature floor, a bug report quoting +its own version — was told the wrong thing. + +Neither direction of this drift turns anything red on its own: + + * `_version.py` bumped, pyproject not → the tag is unpublishable under its + own number (building v0.3.0 emits a 0.2.0 artifact). + * pyproject bumped, `_version.py` not → publishes cleanly and then lies. + This is the one that happened. + +A release checklist saying "bump both" is not a mechanism. This is, and it runs +on every push, before anything is tagged — which matters, because by tag time +the fix means retagging something already published. + +`tomllib` is 3.11+ and CI runs 3.10, so pyproject is read with a narrow regex +rather than a TOML parser. The regex is anchored to the first `version = "..."` +after `[project]` so a version key in some later table can't satisfy it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from colony_chat import __version__ + +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + +_PROJECT_VERSION = re.compile( + r"^\[project\]$.*?^version\s*=\s*[\"']([^\"']+)[\"']", + re.MULTILINE | re.DOTALL, +) + + +def packaged_version() -> str: + """The version setuptools will stamp on the artifact.""" + text = PYPROJECT.read_text(encoding="utf-8") + match = _PROJECT_VERSION.search(text) + assert match is not None, f"no [project] version found in {PYPROJECT}" + return match.group(1) + + +def test_pyproject_version_matches_runtime_version() -> None: + """The guard. This is the assertion the drift would have tripped.""" + assert packaged_version() == __version__, ( + f"pyproject.toml declares {packaged_version()!r} but " + f"colony_chat.__version__ is {__version__!r}. Bump both — the artifact " + f"and the value users see at runtime are the same fact." + ) + + +def test_version_is_pep440_ish() -> None: + """A version that doesn't parse would break the release workflow late.""" + assert re.fullmatch(r"\d+\.\d+\.\d+([abrc.\-+][\w.\-+]*)?", __version__), ( + f"{__version__!r} is not a release-shaped version" + ) + + +@pytest.mark.parametrize("wrong", ["0.0.0", "9.9.9"]) +def test_guard_would_fail_on_a_mismatch(wrong: str) -> None: + """Control: prove the comparison can fail. + + A version check that reads the same string twice — or a regex that quietly + matches nothing and compares None to None — passes forever and certifies + nothing. This asserts the two sources are genuinely compared, by checking + the real value is not equal to a value it should never hold. + """ + assert packaged_version() != wrong + assert packaged_version() == __version__ != wrong