Source code for suricata_check._version

  1import datetime
  2import json
  3import logging
  4import os
  5import re
  6import subprocess
  7import sys
  8from functools import lru_cache
  9from importlib.metadata import PackageNotFoundError, distribution, version
 10from typing import Optional
 11
 12import requests
 13from packaging.version import Version
 14
 15SURICATA_CHECK_DIR = os.path.dirname(__file__)
 16UPDATE_CHECK_CACHE_PATH = os.path.expanduser("~/.suricata_check_version_check.json")
 17UPDATE_CHECK_FREQUENCY = 1
 18
 19_logger = logging.getLogger(__name__)
 20
 21
 22def __get_git_revision_short_hash() -> str:
 23    return (
 24        subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
 25        .decode("ascii")
 26        .strip()
 27    )
 28
 29
 30def get_version() -> str:
 31    v = "unknown"
 32
 33    git_dir = os.path.join(SURICATA_CHECK_DIR, "..", ".git")
 34    if os.path.exists(git_dir):
 35        try:
 36            import setuptools_git_versioning  # noqa: RUF100, PLC0415
 37
 38            v = str(
 39                setuptools_git_versioning.get_version(
 40                    root=os.path.join(SURICATA_CHECK_DIR, ".."),
 41                ),
 42            )
 43            _logger.debug(
 44                "Detected suricata-check version using setuptools_git_versioning: %s",
 45                v,
 46            )
 47        except:  # noqa: E722
 48            v = __get_git_revision_short_hash()
 49            _logger.debug("Detected suricata-check version using git: %s", v)
 50    else:
 51        try:
 52            v = version("suricata-check")
 53            _logger.debug("Detected suricata-check version using importlib: %s", v)
 54        except PackageNotFoundError:
 55            _logger.debug("Failed to detect suricata-check version: %s", v)
 56
 57    return v
 58
 59
 60__version__: str = get_version()
 61"""The version of the `suricata-check` module being executed."""
 62
 63__user_agent = f"suricata-check/{__version__} (+https://suricata-check.teuwen.net/)"
 64
 65
 66def get_dependency_versions() -> dict:
 67    d = {}
 68
 69    requirements: Optional[list[str]] = None
 70    try:
 71        requirements = __get_importlib_requirements()
 72    except PackageNotFoundError:
 73        pass
 74    if requirements is None:
 75        requirements = __get_pyproject_requirements()
 76
 77    if requirements is None:
 78        _logger.debug("Failed to detect suricata-check requirements")
 79        return d
 80
 81    for requirement in requirements:
 82        match = re.compile(r"""^([^=<>\[\]]+)(.*)$""").match(requirement)
 83        if match is None:
 84            _logger.debug("Failed to parse requirement: %s", requirement)
 85            continue
 86        required_package, _ = match.groups()
 87        try:
 88            d[required_package] = version(required_package)
 89        except PackageNotFoundError:
 90            d[required_package] = "unknown"
 91
 92    return d
 93
 94
 95def __get_importlib_requirements() -> Optional[list[str]]:
 96    dist = distribution("suricata-check")
 97    requirements = dist.requires
 98
 99    if requirements is None:
100        return None
101
102    for extra in dist.metadata.get_all("Provides-Extra") or []:
103        extra_requirements = dist.metadata.get_all(f"Requires-Dist-{extra}")
104        if extra_requirements:
105            requirements.extend(  # pyright: ignore[reportOptionalMemberAccess]
106                extra_requirements,
107            )
108
109    _logger.debug("Detected suricata-check requirements using importlib")
110
111    return requirements
112
113
114def __get_pyproject_requirements() -> Optional[list[str]]:
115    pyproject_path = os.path.join(SURICATA_CHECK_DIR, "..", "pyproject.toml")
116    if not os.path.exists(pyproject_path):
117        return None
118    if sys.version_info < (3, 11):
119        return None
120
121    import tomllib  # noqa: PLC0415
122
123    with open(pyproject_path, "rb") as f:
124        toml_content = tomllib.load(f)
125        requirements = toml_content.get("project", {}).get("dependencies", [])
126        for extra_requirements in toml_content.get("project", {}).get(
127            "optional-dependencies",
128            [],
129        ):
130            requirements.extend(  # pyright: ignore[reportOptionalMemberAccess]
131                extra_requirements,
132            )
133
134    _logger.debug("Detected suricata-check requirements using pyproject.toml")
135
136    return requirements
137
138
139def __get_latest_version() -> Optional[str]:
140    cached_data = __get_saved_check_update()
141    headers = {"User-Agent": __user_agent}
142    if cached_data is not None:
143        if (
144            "response_headers" in cached_data
145            and "etag" in cached_data["response_headers"]
146        ):
147            headers["If-None-Match"] = cached_data["response_headers"]["etag"]
148        if "last_checked" in cached_data:
149            headers["If-Modified-Since"] = datetime.datetime.fromisoformat(
150                cached_data["last_checked"],
151            ).strftime("%a, %d %b %Y %H:%M:%S GMT")
152
153    try:
154        response = requests.get(
155            "https://pypi.org/pypi/suricata-check/json",
156            headers=headers,
157            timeout=5,
158        )
159
160        if response.status_code == requests.codes.ok:
161            pypi_json = response.json()
162            __save_check_update(
163                pypi_json,
164                {k.lower(): v for k, v in response.headers.items()},
165            )
166            return pypi_json["info"]["version"]
167
168        if response.status_code == requests.codes.not_modified:
169            assert cached_data is not None
170            _logger.debug("Using cached PyPI response data for update check.")
171            return cached_data["pypi_json"]["info"]["version"]
172    except requests.RequestException:
173        _logger.warning("Failed to perform update check.")
174    return None
175
176
177@lru_cache(maxsize=1)
178def __get_saved_check_update() -> Optional[dict]:
179    if not os.path.exists(UPDATE_CHECK_CACHE_PATH):
180        return None
181
182    try:
183        with open(UPDATE_CHECK_CACHE_PATH) as f:
184            data = json.load(f)
185    except OSError:
186        _logger.warning("Failed to read last date version was checked from cache file.")
187        os.remove(UPDATE_CHECK_CACHE_PATH)
188        return None
189    except json.JSONDecodeError:
190        _logger.warning(
191            "Failed to decode cache file to determine last date version was checked.",
192        )
193        return None
194
195    if not isinstance(data, dict):
196        _logger.warning(
197            "Cache file documenting the last date version was checked is malformed.",
198        )
199        os.remove(UPDATE_CHECK_CACHE_PATH)
200        return None
201
202    return data
203
204
205def __should_check_update() -> bool:
206    current_version = __version__
207    if current_version == "unknown":
208        _logger.warning(
209            "Skipping update check because current version cannot be determined.",
210        )
211        return False
212    if "dirty" in current_version:
213        _logger.warning("Skipping update check because local changes are detected.")
214        return False
215
216    if not os.path.exists(UPDATE_CHECK_CACHE_PATH):
217        return True
218
219    data = __get_saved_check_update()
220    if data is None:
221        return True
222
223    try:
224        last_checked = datetime.datetime.fromisoformat(data["last_checked"])
225        if (
226            datetime.datetime.now(tz=datetime.timezone.utc) - last_checked
227        ).days < UPDATE_CHECK_FREQUENCY:
228            return False
229    except KeyError:
230        _logger.warning(
231            "Cache file documenting the last date version was checked is malformed.",
232        )
233
234    return True
235
236
237def __save_check_update(pypi_json: dict, response_headers: dict) -> None:
238    try:
239        with open(UPDATE_CHECK_CACHE_PATH, "w") as f:
240            json.dump(
241                {
242                    "last_checked": datetime.datetime.now(
243                        tz=datetime.timezone.utc,
244                    ).isoformat(),
245                    "pypi_json": pypi_json,
246                    "response_headers": response_headers,
247                },
248                f,
249            )
250    except OSError:
251        _logger.warning("Failed to write current date to cache file for update checks.")
252
253
[docs] 254def check_for_update() -> None: 255 """Checks whether there is a new version of `suricata-check` available. 256 257 Makes an HTTPS request to PyPI. 258 """ 259 if not __should_check_update(): 260 return 261 262 current_version = __version__ 263 latest_version = __get_latest_version() 264 265 if latest_version is None: 266 _logger.warning("Failed to check for updates of suricata-check.") 267 return 268 269 if Version(latest_version) > Version(current_version): 270 _logger.warning( 271 "A new version of suricata-check is available: %s (you have %s)", 272 latest_version, 273 current_version, 274 ) 275 _logger.warning("Run `pip install --upgrade suricata-check` to update.") 276 _logger.warning( 277 "You can find the full changelog of what has changed here: %s", 278 "https://github.com/Koen1999/suricata-check/releases", 279 ) 280 return 281 282 _logger.info( 283 "You are using the latest version of suricata-check (%s).", 284 __version__, 285 )