Skip to content

jmcore.version

jmcore.version

Centralized version management for JoinMarket NG.

This is the single source of truth for the project version. All components inherit their version from here.

Attributes

GITHUB_RELEASES_URL = 'https://github.com/joinmarket-ng/joinmarket-ng/releases/latest' module-attribute

GITHUB_RELEASE_TAG_PATH = '/joinmarket-ng/joinmarket-ng/releases/tag/' module-attribute

VERSION = __version__ module-attribute

__version__ = '0.39.0' module-attribute

logger = logging.getLogger(__name__) module-attribute

Classes

UpdateCheckResult dataclass

Result of a GitHub update check.

Source code in jmcore/src/jmcore/version.py
166
167
168
169
170
171
@dataclass(frozen=True)
class UpdateCheckResult:
    """Result of a GitHub update check."""

    latest_version: str
    is_newer: bool
Attributes
is_newer: bool instance-attribute
latest_version: str instance-attribute

Functions:

check_for_updates_from_github(socks_proxy: str | None = None, timeout: float = 30.0) -> UpdateCheckResult | None async

Check GitHub for the latest release and compare with the local version.

This function requests GitHub's latest-release permalink through the provided Tor SOCKS5 proxy. It skips the check when no proxy is provided or the proxy transport cannot be configured. A clearnet request is never attempted.

Privacy note: This contacts GitHub, which sees the Tor exit node IP. Only call this when the user has explicitly opted in via check_for_updates.

Args: socks_proxy: Tor SOCKS5 proxy URL (e.g. "socks5h://127.0.0.1:9050"). When omitted, the update check is skipped. timeout: HTTP request timeout in seconds.

Returns: UpdateCheckResult with the latest version and whether it is newer, or None if the check failed for any reason.

Source code in jmcore/src/jmcore/version.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
async def check_for_updates_from_github(
    socks_proxy: str | None = None,
    timeout: float = 30.0,
) -> UpdateCheckResult | None:
    """Check GitHub for the latest release and compare with the local version.

    This function requests GitHub's latest-release permalink through the provided
    Tor SOCKS5 proxy. It skips the check when no proxy is provided or the proxy
    transport cannot be configured. A clearnet request is never attempted.

    **Privacy note**: This contacts GitHub, which sees the Tor exit node IP. Only
    call this when the user has explicitly opted in via ``check_for_updates``.

    Args:
        socks_proxy: Tor SOCKS5 proxy URL (e.g. "socks5h://127.0.0.1:9050").
            When omitted, the update check is skipped.
        timeout: HTTP request timeout in seconds.

    Returns:
        UpdateCheckResult with the latest version and whether it is newer,
        or None if the check failed for any reason.
    """
    import httpx

    if not socks_proxy:
        logger.warning("Skipping GitHub update check: a Tor SOCKS proxy is required")
        return None

    try:
        from httpx_socks import AsyncProxyTransport

        from jmcore.tor_isolation import normalize_proxy_url

        # python-socks does not support the socks5h:// scheme directly.
        # normalize_proxy_url converts socks5h:// -> socks5:// + rdns=True
        # so that DNS resolution is performed by Tor.
        normalized = normalize_proxy_url(socks_proxy)

        transport = AsyncProxyTransport.from_url(normalized.url, rdns=normalized.rdns)
        logger.debug("Update check configured with SOCKS proxy (rdns=%s)", normalized.rdns)
    except Exception:
        logger.warning("Skipping update check: unable to configure the Tor SOCKS proxy")
        return None

    try:
        async with httpx.AsyncClient(
            timeout=timeout,
            follow_redirects=False,
            transport=transport,
        ) as client:
            response = await client.head(GITHUB_RELEASES_URL)
            if response.status_code in {405, 501}:
                response = await client.get(GITHUB_RELEASES_URL)
            if not 300 <= response.status_code < 400:
                response.raise_for_status()

        location = response.headers.get("location")
        if location is None:
            msg = "GitHub latest-release response did not include a redirect"
            raise ValueError(msg)
        latest = _parse_latest_release_location(location)
        current = get_version_tuple()
        latest_str = f"{latest[0]}.{latest[1]}.{latest[2]}"

        logger.debug("Update check: current=%s, latest=%s", __version__, latest_str)
        return UpdateCheckResult(latest_version=latest_str, is_newer=latest > current)

    except httpx.HTTPStatusError as exc:
        logger.warning(
            "GitHub update check unavailable (HTTP %d); continuing without version information",
            exc.response.status_code,
        )
        return None
    except Exception as exc:
        logger.warning("Failed to check for updates from GitHub: %s", exc)
        return None

get_build_ref() -> str | None

Return the branch or tag the package was built from, if known.

Populated by setup.py from the JOINMARKET_BUILD_REF environment variable (set by install.sh) or, as a fallback, the local git branch/tag when building from a working tree. Non-editable installs that lack both leave this as None; callers should treat that as "unknown" rather than "stable".

Source code in jmcore/src/jmcore/version.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def get_build_ref() -> str | None:
    """Return the branch or tag the package was built from, if known.

    Populated by ``setup.py`` from the ``JOINMARKET_BUILD_REF`` environment
    variable (set by ``install.sh``) or, as a fallback, the local ``git``
    branch/tag when building from a working tree. Non-editable installs
    that lack both leave this as ``None``; callers should treat that as
    "unknown" rather than "stable".
    """
    build_info = _get_build_info_module()
    if build_info is not None:
        ref = getattr(build_info, "REF", "") or ""
        ref = ref.strip()
        if ref:
            return ref
    return None

get_commit_hash() -> str | None

Return the short git commit hash, or None if unavailable.

Resolution order:

  1. jmcore._build_info.COMMIT -- written at wheel build time by setup.py. This is the only source that survives non-editable installs (pip install git+..., Docker, release wheels) where the package directory has no .git.
  2. Live git rev-parse --short HEAD from the package directory. Works for editable installs (pip install -e) where the source tree retains its working .git.
  3. None when neither source produced a hash.
Source code in jmcore/src/jmcore/version.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def get_commit_hash() -> str | None:
    """Return the short git commit hash, or None if unavailable.

    Resolution order:

    1. ``jmcore._build_info.COMMIT`` -- written at wheel build time by
       ``setup.py``. This is the only source that survives non-editable
       installs (``pip install git+...``, Docker, release wheels) where
       the package directory has no ``.git``.
    2. Live ``git rev-parse --short HEAD`` from the package directory.
       Works for editable installs (``pip install -e``) where the source
       tree retains its working ``.git``.
    3. ``None`` when neither source produced a hash.
    """
    build_info = _get_build_info_module()
    if build_info is not None:
        commit = getattr(build_info, "COMMIT", "") or ""
        commit = commit.strip()
        if commit:
            return commit

    import subprocess
    from pathlib import Path

    try:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True,
            text=True,
            timeout=5,
            cwd=Path(__file__).parent,
        )
        if result.returncode == 0:
            return result.stdout.strip() or None
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        pass
    return None

get_version() -> str

Return the current version string.

Source code in jmcore/src/jmcore/version.py
29
30
31
def get_version() -> str:
    """Return the current version string."""
    return __version__

get_version_info() -> dict[str, str | int]

Return version information as a dictionary.

Source code in jmcore/src/jmcore/version.py
117
118
119
120
121
122
123
124
125
def get_version_info() -> dict[str, str | int]:
    """Return version information as a dictionary."""
    major, minor, patch = get_version_tuple()
    return {
        "version": __version__,
        "major": major,
        "minor": minor,
        "patch": patch,
    }

get_version_tuple() -> tuple[int, int, int]

Return the version as a tuple of (major, minor, patch).

Source code in jmcore/src/jmcore/version.py
111
112
113
114
def get_version_tuple() -> tuple[int, int, int]:
    """Return the version as a tuple of (major, minor, patch)."""
    parts = __version__.split(".")
    return (int(parts[0]), int(parts[1]), int(parts[2]))