Skip to content

Commit 2785a26

Browse files
authored
Merge pull request #58 from Flip-Liquid/flip-liquid/http_headers
Add support for arbitrary http headers
2 parents 8145022 + f1599ee commit 2785a26

4 files changed

Lines changed: 132 additions & 0 deletions

File tree

clickhouse_cli/cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def __init__(
6464
vi_mode,
6565
cookie,
6666
insecure,
67+
headers=None,
6768
):
6869
self.config = None
6970

@@ -73,6 +74,7 @@ def __init__(
7374
self.password = password
7475
self.database = database
7576
self.cookie = cookie
77+
self.headers = headers or {}
7678
self.settings = {k: v[0] for k, v in parse_qs(settings).items()}
7779
self.format = format
7880
self.format_stdin = format_stdin
@@ -108,6 +110,7 @@ def connect(self):
108110
self.conn_timeout_retry,
109111
self.conn_timeout_retry_delay,
110112
not self.insecure,
113+
headers=self.headers,
111114
)
112115

113116
self.echo.print("Connecting to {host}:{port}".format(host=self.host, port=self.port))
@@ -213,6 +216,12 @@ def load_config(self):
213216
config_settings.update(arg_settings)
214217
self.settings = config_settings
215218

219+
config_headers = {}
220+
if self.config.has_section("headers"):
221+
config_headers = dict(self.config.items("headers"))
222+
config_headers.update(self.headers)
223+
self.headers = config_headers
224+
216225
self.echo.colors = self.highlight
217226

218227
def run(self, query, data):
@@ -564,6 +573,12 @@ def progress_print(self, message, percents):
564573
@click.option("--database", "-d", help="Database")
565574
@click.option("--settings", "-s", help="Query string to be sent with every query")
566575
@click.option("--cookie", "-c", help="Cookie header to be sent with every query")
576+
@click.option(
577+
"--header",
578+
"-H",
579+
multiple=True,
580+
help="Additional HTTP header to send with every request (format: 'Name: Value'). Can be specified multiple times.",
581+
)
567582
@click.option("--query", "-q", help="Query to execute")
568583
@click.option(
569584
"--insecure",
@@ -593,6 +608,7 @@ def run_cli(
593608
stacktrace,
594609
vi_mode,
595610
cookie,
611+
header,
596612
version,
597613
files,
598614
insecure,
@@ -608,6 +624,18 @@ def run_cli(
608624
elif password:
609625
password = click.prompt("Password", hide_input=True, show_default=False, type=str)
610626

627+
# Parse --header values from "Name: Value" format into a dict.
628+
# Later entries with the same name win.
629+
headers = {}
630+
for h in header:
631+
if ": " not in h:
632+
raise click.BadParameter(
633+
"Headers must be in 'Name: Value' format, got: {!r}".format(h),
634+
param_hint="'--header' / '-H'",
635+
)
636+
name, _, value = h.partition(": ")
637+
headers[name] = value
638+
611639
data_input = ()
612640

613641
# Read from STDIN if non-interactive mode
@@ -634,6 +662,7 @@ def run_cli(
634662
vi_mode,
635663
cookie,
636664
insecure,
665+
headers=headers,
637666
)
638667
cli.run(query, data_input)
639668
return 0

clickhouse_cli/clickhouse-cli.rc.sample

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,13 @@ conn_timeout_retry_delay = 0.5
9292
# You can place the server-side settings here!
9393

9494
# max_memory_usage = 20000000000
95+
96+
97+
[headers]
98+
# Arbitrary HTTP headers to send with every request.
99+
# Each key/value pair becomes one header. These can also be supplied at
100+
# runtime with the --header / -H flag ("Name: Value" format); CLI flags
101+
# take precedence over values set here.
102+
103+
# X-My-Custom-Header = some-value
104+
# Authorization = Bearer my-token

clickhouse_cli/clickhouse/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,14 @@ def __init__(
7272
timeout_retry=0,
7373
timeout_retry_delay=0.0,
7474
verify=True,
75+
headers=None,
7576
):
7677
self.url = url
7778
self.user = user
7879
self.password = password or ""
7980
self.database = database
8081
self.cookie = cookie
82+
self.headers = headers or {}
8183
self.session_id = str(uuid.uuid4())
8284
self.cli_settings = {}
8385
self.stacktrace = stacktrace
@@ -113,6 +115,8 @@ def _query(
113115
if self.cookie:
114116
headers["Cookie"] = self.cookie
115117

118+
headers.update(self.headers)
119+
116120
response = None
117121

118122
if not query.endswith("\n"):

tests/test_cli.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,99 @@
11
import pytest
2+
from unittest.mock import MagicMock, patch
3+
4+
from click.testing import CliRunner
25

36
from clickhouse_cli.cli import run_cli
7+
from clickhouse_cli.clickhouse.client import Client
48

59

610
def test_main_help():
711
# Call with the --help option as a basic sanity check.
812
with pytest.raises(SystemExit) as exinfo:
913
run_cli(["--help", ])
1014
assert exinfo.value.code == 0
15+
16+
17+
def test_custom_headers_sent_with_request():
18+
"""tests -H --header arguments on CLI"""
19+
runner = CliRunner()
20+
21+
mock_response = MagicMock()
22+
mock_response.status_code = 200
23+
mock_response.elapsed.total_seconds.return_value = 0.1
24+
mock_response.text = ""
25+
26+
captured = {}
27+
28+
def fake_request(method, url, **kwargs):
29+
captured["headers"] = kwargs.get("headers", {})
30+
return mock_response
31+
32+
with patch("requests.Session.request", side_effect=fake_request):
33+
runner.invoke(run_cli, [
34+
"-h", "localhost",
35+
"-p", "8123",
36+
"-H", "X-My-Header: test-value",
37+
"-H", "X-Another: hello",
38+
"-q", "SELECT 1",
39+
])
40+
41+
assert captured["headers"].get("X-My-Header") == "test-value"
42+
assert captured["headers"].get("X-Another") == "hello"
43+
44+
45+
def test_custom_headers_stored_on_client():
46+
"""tests headers suppliet for client object"""
47+
client = Client(
48+
url="http://localhost:8123/",
49+
user="default",
50+
password="",
51+
database="default",
52+
cookie=None,
53+
headers={"X-Custom": "value"},
54+
)
55+
assert client.headers == {"X-Custom": "value"}
56+
57+
mock_response = MagicMock()
58+
mock_response.status_code = 200
59+
mock_response.elapsed.total_seconds.return_value = 0.0
60+
mock_response.text = ""
61+
62+
captured = {}
63+
64+
def fake_request(method, url, **kwargs):
65+
captured["headers"] = kwargs.get("headers", {})
66+
return mock_response
67+
68+
with patch("requests.Session.request", side_effect=fake_request):
69+
client._query("GET", "SELECT 1", {}, fmt="Null", stream=False)
70+
71+
assert captured["headers"].get("X-Custom") == "value"
72+
73+
74+
def test_custom_headers_override_defaults():
75+
"""user supplied headers override defaults"""
76+
client = Client(
77+
url="http://localhost:8123/",
78+
user="default",
79+
password="",
80+
database="default",
81+
cookie=None,
82+
headers={"User-Agent": "my-custom-agent"},
83+
)
84+
85+
mock_response = MagicMock()
86+
mock_response.status_code = 200
87+
mock_response.elapsed.total_seconds.return_value = 0.0
88+
mock_response.text = ""
89+
90+
captured = {}
91+
92+
def fake_request(method, url, **kwargs):
93+
captured["headers"] = kwargs.get("headers", {})
94+
return mock_response
95+
96+
with patch("requests.Session.request", side_effect=fake_request):
97+
client._query("GET", "SELECT 1", {}, fmt="Null", stream=False)
98+
99+
assert captured["headers"]["User-Agent"] == "my-custom-agent"

0 commit comments

Comments
 (0)