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 )