toolforge-logs-cli 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: toolforge-logs-cli
3
+ Version: 0.0.3
4
+ Summary: Toolforge logs API client
5
+ License: GPLv3
6
+ Author: David Caro
7
+ Author-email: dcaro@wikimedia.org
8
+ Requires-Python: >=3.13
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3.15
14
+ Requires-Dist: click (>=8.5.0,<9.0.0)
15
+ Requires-Dist: requests (>=2.34.2,<3.0.0)
16
+ Requires-Dist: toolforge-weld (>=1.6.15,<2.0.0)
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "toolforge-logs-cli"
3
+ version = "0.0.3"
4
+ description = "Toolforge logs API client"
5
+ authors = [
6
+ {name = "David Caro",email = "dcaro@wikimedia.org"}
7
+ ]
8
+ license = {text = "GPLv3"}
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "click (>=8.5.0,<9.0.0)",
12
+ "requests (>=2.34.2,<3.0.0)",
13
+ "toolforge-weld (>=1.6.15,<2.0.0)"
14
+ ]
15
+
16
+ [tool.poetry.plugins."console_scripts"]
17
+ "toolforge-logs" = "toolforge_logs_cli.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
21
+ build-backend = "poetry.core.masonry.api"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest (>=9.1.1,<10.0.0)",
26
+ "pre-commit (>=4.6.2,<5.0.0)",
27
+ "ruff (>=0.16.7,<0.17.0)",
28
+ "mypy (>=2.3.1,<3.0.0)",
29
+ "pytest-cov (>=7.1.0,<8.0.0)"
30
+ ]
31
+
32
+ [tool.coverage.run]
33
+ include = ["*/toolforge_components_cli/*"]
34
+
35
+ [tool.ruff.lint]
36
+ # add isort
37
+ extend-select = ["I"]
38
+
39
+ [tool.mypy]
40
+ exclude = [
41
+ "^debian/", # debian packaging files
42
+ "^.cache",
43
+ "^build",
44
+ "^dist",
45
+ "^.tox",
46
+ ]
@@ -0,0 +1,176 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+ from urllib.parse import urlencode, urljoin
9
+
10
+ import click
11
+ from toolforge_weld.errors import (
12
+ ToolforgeError,
13
+ ToolforgeUserError,
14
+ print_error_context,
15
+ )
16
+
17
+ from toolforge_logs_cli.config import get_loaded_config
18
+ from toolforge_logs_cli.logs import (
19
+ LogsClient,
20
+ LogsClientError,
21
+ UnknownLogFormat,
22
+ log_to_string,
23
+ )
24
+
25
+ LOGGER = logging.getLogger("toolforge" if __name__ == "__main__" else __name__)
26
+
27
+ ERROR_REPORT_MSG = "Please report this issue to the Toolforge admins if it persists: https://w.wiki/6Zuu"
28
+
29
+
30
+ def handle_error(e: Exception, debug: bool = False) -> None:
31
+ user_error = isinstance(e, ToolforgeUserError)
32
+ prefix = "Error: " if user_error else f"{e.__class__.__name__}: "
33
+ click.echo(click.style(f"{prefix}{e}", fg="red"), err=True)
34
+
35
+ if debug:
36
+ LOGGER.exception(e)
37
+ if isinstance(e, ToolforgeError):
38
+ print_error_context(e)
39
+ elif not user_error:
40
+ click.echo(click.style(ERROR_REPORT_MSG, fg="red"), err=True)
41
+
42
+
43
+ def _should_prompt() -> bool:
44
+ return sys.stdin.isatty()
45
+
46
+
47
+ @click.version_option(prog_name="Toolforge Logs CLI")
48
+ @click.group(name="toolforge-logs", help="Toolforge Logs command line interface")
49
+ @click.option(
50
+ "-v",
51
+ "--verbose",
52
+ help="Show extra verbose output. NOTE: Do not rely on the format of the verbose output.",
53
+ is_flag=True,
54
+ default=(os.environ.get("TOOLFORGE_VERBOSE", "0") == "1"),
55
+ hidden=(os.environ.get("TOOLFORGE_CLI", "0") == "1"),
56
+ )
57
+ @click.option(
58
+ "-d",
59
+ "--debug",
60
+ help="Show logs to debug the toolforge-logs-* packages. For extra verbose output see --verbose.",
61
+ is_flag=True,
62
+ default=(os.environ.get("TOOLFORGE_DEBUG", "0") == "1"),
63
+ hidden=(os.environ.get("TOOLFORGE_CLI", "0") == "1"),
64
+ )
65
+ @click.pass_context
66
+ def toolforge_logs(ctx, verbose: bool, debug: bool) -> None:
67
+ ctx.ensure_object(dict)
68
+ ctx.obj.update(
69
+ {
70
+ "verbose": verbose,
71
+ "debug": debug,
72
+ "config": get_loaded_config(),
73
+ "logs_api": LogsClient.from_config(config=get_loaded_config()),
74
+ }
75
+ )
76
+
77
+
78
+ @toolforge_logs.command(name="get", help="Get tool logs.")
79
+ @click.option(
80
+ "--json",
81
+ "as_json",
82
+ is_flag=True,
83
+ help="If set, will output in JSON format",
84
+ )
85
+ @click.pass_context
86
+ def get_logs(ctx, as_json: bool) -> None:
87
+ logs_api: LogsClient = ctx.obj["logs_api"]
88
+
89
+ for log in _get_logs_nofollow(logs_api=logs_api):
90
+ if not as_json:
91
+ try:
92
+ log_string = log_to_string(log=log)
93
+ print(f"{log_string}")
94
+ except UnknownLogFormat as error:
95
+ click.echo(
96
+ message=click.style(f"Unable to parse log: {error}", fg="red"),
97
+ err=True,
98
+ )
99
+ else:
100
+ print(json.dumps(log))
101
+
102
+
103
+ def _get_logs_page(
104
+ logs_api: LogsClient,
105
+ url: str,
106
+ ):
107
+ logs = []
108
+ # We’re not using the params kwarg of api.get to avoid overwriting pagination URL params from replies.
109
+ # Pagination URLs already include all necessary params, set by the server based on what we sent first.
110
+ response = logs_api.get(url, timeout=None)
111
+ try:
112
+ entries = response["data"]["logs"]
113
+ logs.extend(entries)
114
+ except KeyError:
115
+ raise LogsClientError("unexpected response format", {"response": response})
116
+
117
+ try:
118
+ next_url = response["data"]["next"]
119
+ except KeyError:
120
+ next_url = None # no more pages
121
+ try:
122
+ cursor_url = next_url["url"] # ty: ignore[not-subscriptable]
123
+ except (KeyError, TypeError): # TypeError raised by None["url"]
124
+ # logs-api’s OpenAPI spec technically allows next=null
125
+ if next_url is not None:
126
+ raise LogsClientError("unexpected cursor format", {"cursor": next_url})
127
+ next_url = None
128
+ if not entries and next_url is not None:
129
+ raise LogsClientError(
130
+ "bad server: got a pagination cursor but no logs",
131
+ {"response": response},
132
+ )
133
+
134
+ if next_url is not None:
135
+ try:
136
+ next_url = urljoin(url, cursor_url)
137
+ except Exception: # noqa: BLE001
138
+ raise LogsClientError("bad server: invalid cursor URL", {"url": next_url})
139
+ if next_url == url:
140
+ raise LogsClientError(
141
+ "bad server: identical pagination cursor",
142
+ {"cursor": cursor_url, "url": url},
143
+ )
144
+ return next_url, logs
145
+
146
+
147
+ def _get_logs_nofollow(logs_api: LogsClient) -> Iterator[dict[str, Any]]:
148
+ # logs-api returns log entries in pages of bounded size (500 entries at the time of writing).
149
+ # We get all the logs, not filtering yet.
150
+ url = "/logs?" + urlencode({"direction": "oldest_first"})
151
+ while url:
152
+ url, log_batch = _get_logs_page(logs_api=logs_api, url=url)
153
+ yield from log_batch
154
+
155
+
156
+ def main() -> int:
157
+ debug = False
158
+ try:
159
+ args = sys.argv[1:]
160
+ debug = "-d" in args or "--debug" in args
161
+ logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
162
+ toolforge_logs(standalone_mode=False)
163
+ return 0
164
+ except click.exceptions.ClickException as e:
165
+ e.show()
166
+ return e.exit_code
167
+ except subprocess.CalledProcessError as e:
168
+ handle_error(e, debug=debug)
169
+ return e.returncode
170
+ except Exception as e: # noqa: BLE001
171
+ handle_error(e, debug=debug)
172
+ return 1
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass, field
2
+ from functools import cache
3
+ from typing import Any
4
+
5
+ from toolforge_weld.config import Config, Section, load_config
6
+
7
+
8
+ @dataclass
9
+ class LogsConfig(Section):
10
+ _NAME_: str = field(default="logs", init=False)
11
+ logs_endpoint: str = "/logs/v1"
12
+
13
+ @classmethod
14
+ def from_dict(cls, my_dict: dict[str, Any]):
15
+ params = {}
16
+ if "logs_endpoint" in my_dict:
17
+ params["logs_endpoint"] = my_dict["logs_endpoint"]
18
+ return cls(**params)
19
+
20
+
21
+ @cache
22
+ def get_loaded_config() -> Config:
23
+ return load_config(client_name="logs", extra_sections=[LogsConfig])
@@ -0,0 +1,183 @@
1
+ import socket
2
+ from typing import Any
3
+
4
+ import requests
5
+ from toolforge_weld.api_client import ConnectionError, ToolforgeClient
6
+ from toolforge_weld.errors import ToolforgeError, ToolforgeUserError
7
+ from toolforge_weld.kubernetes_config import Kubeconfig
8
+
9
+ from toolforge_logs_cli.config import Config
10
+
11
+
12
+ class LogsClientError(ToolforgeError):
13
+ """Raised when an HTTP request fails."""
14
+
15
+ def __init__(self, message: str, context: dict[str, Any] | None = None) -> None:
16
+ super().__init__(message)
17
+
18
+ if context:
19
+ # property is defined in parent class
20
+ self.context = context
21
+
22
+
23
+ class LogsClientUserError(LogsClientError, ToolforgeUserError):
24
+ """Raised when an HTTP request fails with a 4xx status code."""
25
+
26
+
27
+ def handle_http_exception(e: requests.exceptions.HTTPError) -> Exception:
28
+ if e.response is None:
29
+ return LogsClientError(message="Got no response", context={})
30
+
31
+ error_class: type[LogsClientError | LogsClientUserError]
32
+ if 400 <= e.response.status_code <= 499:
33
+ error_class = LogsClientUserError
34
+ else:
35
+ error_class = LogsClientError
36
+
37
+ context = {}
38
+ message = e.response.text
39
+ try:
40
+ data = e.response.json()
41
+ if isinstance(data, dict):
42
+ if "messages" in data and "error" in data["messages"]:
43
+ message = data["messages"]["error"][0]
44
+ context = {"messages": data["messages"]}
45
+ elif isinstance(data, str):
46
+ message = data
47
+ except requests.exceptions.InvalidJSONError:
48
+ pass
49
+
50
+ return error_class(message=message, context=context)
51
+
52
+
53
+ def handle_connection_error(e: ConnectionError) -> Exception:
54
+ context = {}
55
+ if isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
56
+ context["body"] = e.response.text
57
+
58
+ return LogsClientError(
59
+ message="The logs service seems to be down – please retry in a few minutes.",
60
+ context=context,
61
+ )
62
+
63
+
64
+ class LogsClient(ToolforgeClient):
65
+ def __init__(
66
+ self,
67
+ kubeconfig: Kubeconfig,
68
+ server: str,
69
+ endpoint_prefix: str,
70
+ user_agent: str,
71
+ ):
72
+ super().__init__(
73
+ kubeconfig=kubeconfig,
74
+ server=server + endpoint_prefix,
75
+ user_agent=user_agent,
76
+ exception_handler=handle_http_exception,
77
+ connect_exception_handler=handle_connection_error,
78
+ )
79
+
80
+ @classmethod
81
+ def from_config(cls, config: Config):
82
+ host = socket.gethostname()
83
+ kubeconfig = Kubeconfig.load()
84
+ namespace = kubeconfig.current_namespace
85
+ user_agent = f"{namespace}@{host}:logs-cli"
86
+ tool_name = namespace[len("tool-") :]
87
+ full_endpoint_prefix = f"{config.logs.logs_endpoint}/tool/{tool_name}"
88
+ return cls(
89
+ endpoint_prefix=full_endpoint_prefix,
90
+ kubeconfig=kubeconfig,
91
+ server=config.api_gateway.url,
92
+ user_agent=user_agent,
93
+ )
94
+
95
+
96
+ class LogError(Exception):
97
+ pass
98
+
99
+
100
+ class UnknownLogFormat(LogError):
101
+ pass
102
+
103
+
104
+ def log_to_string(log: dict[str, Any]) -> str:
105
+ if "source" not in log:
106
+ raise UnknownLogFormat(f"No `source` found in log event: {log}")
107
+
108
+ source = log["source"]
109
+
110
+ match source:
111
+ case "run-logs":
112
+ return _format_run_log(log=log)
113
+ case "jobs":
114
+ return _format_job_event(event=log)
115
+ case "deployments":
116
+ return _format_deployment_event(event=log)
117
+ case _:
118
+ raise UnknownLogFormat(f"Unknown source {source} for log: {log}")
119
+
120
+
121
+ def _format_run_log(log: dict[str, Any]) -> str:
122
+ try:
123
+ return f"{log['datetime']} [{log['job_name']}] [{log['pod']}|{log['container']}] {log['message']}"
124
+ except KeyError as error:
125
+ return f"ERROR: unable to parse run-log, missing property {error}\n original log:\n{log}"
126
+
127
+
128
+ def _format_deployment_event(event: dict[str, Any]) -> str:
129
+ if "event_type" not in event:
130
+ raise UnknownLogFormat(f"No `event_type` found in deployment event: {event}")
131
+
132
+ event_type = event["event_type"]
133
+ match event_type:
134
+ case "created":
135
+ return (
136
+ f"{event['datetime']} "
137
+ f"DEPLOYMENT CREATED: deployment '{event['deployment_id']}' ({event['description'] or 'no description'}) "
138
+ f"created by user '{event['user_name']}', "
139
+ f"affected builds are {event['builds']} and affected jobs are {event['jobs']}"
140
+ )
141
+ case "failed":
142
+ return (
143
+ f"{event['datetime']} "
144
+ f"DEPLOYMENT FAILED: deployment '{event['deployment_id']}' failed ({event['description'] or 'no description'}), "
145
+ f"affected builds are {event['builds']} and affected jobs are {event['jobs']}. Message (format: {event['message_format']}): '{event['message']}'"
146
+ )
147
+ case "succeeded":
148
+ return (
149
+ f"{event['datetime']} "
150
+ f"DEPLOYMENT SUCCEEDED: deployment '{event['deployment_id']}' succeeded ({event['description'] or 'no description'}), "
151
+ f"affected builds are {event['builds']} and affected jobs are {event['jobs']}"
152
+ )
153
+ case _:
154
+ raise UnknownLogFormat(f"Unknown format {event_type} for event: {event}")
155
+
156
+ return ""
157
+
158
+
159
+ def _format_job_event(event: dict[str, Any]) -> str:
160
+ if "event_type" not in event:
161
+ raise UnknownLogFormat(f"No `event_type` found in job event: {event}")
162
+
163
+ event_type = event["event_type"]
164
+ match event_type:
165
+ case "created" | "deleted" | "updated":
166
+ return _format_job_event_with_message(event=event)
167
+ case "restarted":
168
+ return _format_job_event_without_message(event=event)
169
+ case _:
170
+ raise UnknownLogFormat(f"Unknown format {event_type} for event: {event}")
171
+
172
+
173
+ def _format_job_event_without_message(event: dict[str, Any]) -> str:
174
+ return (
175
+ f"{event['datetime']} "
176
+ f"JOB {event['event_type'].upper()}: "
177
+ f"job '{event['job_name']}' {event['event_type']} by user '{event['user_name']}'"
178
+ )
179
+
180
+
181
+ def _format_job_event_with_message(event: dict[str, Any]) -> str:
182
+ prefix = _format_job_event_without_message(event=event)
183
+ return f"{prefix}, with message (format: {event['message_format']}) '{event['message']}'"