Skip to content

Commit f4df9bc

Browse files
committed
Add PostgresImporter
1 parent 38d8be9 commit f4df9bc

7 files changed

+442
-438
lines changed

hunter/config.py

+24
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from hunter.grafana import GrafanaConfig
1010
from hunter.graphite import GraphiteConfig
11+
from hunter.postgres import PostgresConfig
1112
from hunter.slack import SlackConfig
1213
from hunter.test_config import TestConfig, create_test_config
1314
from hunter.util import merge_dict_list
@@ -20,6 +21,7 @@ class Config:
2021
tests: Dict[str, TestConfig]
2122
test_groups: Dict[str, List[TestConfig]]
2223
slack: SlackConfig
24+
postgres: PostgresConfig
2325

2426

2527
@dataclass
@@ -110,6 +112,27 @@ def load_config_from(config_file: Path) -> Config:
110112
bot_token=config["slack"]["token"],
111113
)
112114

115+
postgres_config = None
116+
if config.get("postgres") is not None:
117+
if not config["postgres"]["hostname"]:
118+
raise ValueError("postgres.hostname")
119+
if not config["postgres"]["port"]:
120+
raise ValueError("postgres.port")
121+
if not config["postgres"]["username"]:
122+
raise ValueError("postgres.username")
123+
if not config["postgres"]["password"]:
124+
raise ValueError("postgres.password")
125+
if not config["postgres"]["database"]:
126+
raise ValueError("postgres.database")
127+
128+
postgres_config = PostgresConfig(
129+
hostname=config["postgres"]["hostname"],
130+
port=config["postgres"]["port"],
131+
username=config["postgres"]["username"],
132+
password=config["postgres"]["password"],
133+
database=config["postgres"]["database"],
134+
)
135+
113136
templates = load_templates(config)
114137
tests = load_tests(config, templates)
115138
groups = load_test_groups(config, tests)
@@ -118,6 +141,7 @@ def load_config_from(config_file: Path) -> Config:
118141
graphite=graphite_config,
119142
grafana=grafana_config,
120143
slack=slack_config,
144+
postgres=postgres_config,
121145
tests=tests,
122146
test_groups=groups,
123147
)

hunter/importer.py

+115
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
from hunter.config import Config
1010
from hunter.data_selector import DataSelector
1111
from hunter.graphite import DataPoint, Graphite, GraphiteError
12+
from hunter.postgres import Postgres
1213
from hunter.series import Metric, Series
1314
from hunter.test_config import (
1415
CsvMetric,
1516
CsvTestConfig,
1617
GraphiteTestConfig,
1718
HistoStatTestConfig,
19+
PostgresMetric,
20+
PostgresTestConfig,
1821
TestConfig,
1922
)
2023
from hunter.util import (
@@ -431,17 +434,122 @@ def fetch_all_metric_names(self, test: HistoStatTestConfig) -> List[str]:
431434
return metric_names
432435

433436

437+
class PostgresImporter:
438+
__postgres: Postgres
439+
440+
def __init__(self, postgres: Postgres):
441+
self.__postgres = postgres
442+
443+
@staticmethod
444+
def __selected_metrics(
445+
defined_metrics: Dict[str, PostgresMetric], selected_metrics: Optional[List[str]]
446+
) -> Dict[str, PostgresMetric]:
447+
448+
if selected_metrics is not None:
449+
return {name: defined_metrics[name] for name in selected_metrics}
450+
else:
451+
return defined_metrics
452+
453+
def fetch_data(self, test_conf: TestConfig, selector: DataSelector = DataSelector()) -> Series:
454+
if not isinstance(test_conf, PostgresTestConfig):
455+
raise ValueError("Expected PostgresTestConfig")
456+
457+
if selector.branch:
458+
raise ValueError("Postgres tests don't support branching yet")
459+
460+
since_time = selector.since_time
461+
until_time = selector.until_time
462+
if since_time.timestamp() > until_time.timestamp():
463+
raise DataImportError(
464+
f"Invalid time range: ["
465+
f"{format_timestamp(int(since_time.timestamp()))}, "
466+
f"{format_timestamp(int(until_time.timestamp()))}]"
467+
)
468+
metrics = self.__selected_metrics(test_conf.metrics, selector.metrics)
469+
470+
columns, rows = self.__postgres.fetch_data(test_conf.query)
471+
472+
# Decide which columns to fetch into which components of the result:
473+
try:
474+
time_index: int = columns.index(test_conf.time_column)
475+
attr_indexes: List[int] = [columns.index(c) for c in test_conf.attributes]
476+
metric_names = [m.name for m in metrics.values()]
477+
metric_columns = [m.column for m in metrics.values()]
478+
metric_indexes: List[int] = [columns.index(c) for c in metric_columns]
479+
except ValueError as err:
480+
raise DataImportError(f"Column not found {err.args[0]}")
481+
482+
time: List[int] = []
483+
data: Dict[str, List[float]] = {}
484+
for n in metric_names:
485+
data[n] = []
486+
attributes: Dict[str, List[str]] = {}
487+
for i in attr_indexes:
488+
attributes[columns[i]] = []
489+
490+
for row in rows:
491+
ts: datetime = row[time_index]
492+
if since_time is not None and ts < since_time:
493+
continue
494+
if until_time is not None and ts >= until_time:
495+
continue
496+
time.append(int(ts.timestamp()))
497+
498+
# Read metric values. Note we can still fail on conversion to float,
499+
# because the user is free to override the column selection and thus
500+
# they may select a column that contains non-numeric data:
501+
for (name, i) in zip(metric_names, metric_indexes):
502+
try:
503+
data[name].append(float(row[i]))
504+
except ValueError as err:
505+
raise DataImportError(
506+
"Could not convert value in column " + headers[i] + ": " + err.args[0]
507+
)
508+
509+
# Attributes are just copied as-is, with no conversion:
510+
for i in attr_indexes:
511+
attributes[columns[i]].append(row[i])
512+
513+
# Convert metrics to series.Metrics
514+
metrics = {m.name: Metric(m.direction, m.scale) for m in metrics.values()}
515+
516+
# Leave last n points:
517+
time = time[-selector.last_n_points :]
518+
tmp = data
519+
data = {}
520+
for k, v in tmp.items():
521+
data[k] = v[-selector.last_n_points :]
522+
tmp = attributes
523+
attributes = {}
524+
for k, v in tmp.items():
525+
attributes[k] = v[-selector.last_n_points :]
526+
527+
return Series(
528+
test_conf.name,
529+
branch=None,
530+
time=time,
531+
metrics=metrics,
532+
data=data,
533+
attributes=attributes,
534+
)
535+
536+
def fetch_all_metric_names(self, test_conf: PostgresTestConfig) -> List[str]:
537+
return [m for m in test_conf.metrics.keys()]
538+
539+
434540
class Importers:
435541
__config: Config
436542
__csv_importer: Optional[CsvImporter]
437543
__graphite_importer: Optional[GraphiteImporter]
438544
__histostat_importer: Optional[HistoStatImporter]
545+
__postgres_importer: Optional[PostgresImporter]
439546

440547
def __init__(self, config: Config):
441548
self.__config = config
442549
self.__csv_importer = None
443550
self.__graphite_importer = None
444551
self.__histostat_importer = None
552+
self.__postgres_importer = None
445553

446554
def csv_importer(self) -> CsvImporter:
447555
if self.__csv_importer is None:
@@ -458,12 +566,19 @@ def histostat_importer(self) -> HistoStatImporter:
458566
self.__histostat_importer = HistoStatImporter()
459567
return self.__histostat_importer
460568

569+
def postgres_importer(self) -> PostgresImporter:
570+
if self.__postgres_importer is None:
571+
self.__postgres_importer = PostgresImporter(Postgres(self.__config.postgres))
572+
return self.__postgres_importer
573+
461574
def get(self, test: TestConfig) -> Importer:
462575
if isinstance(test, CsvTestConfig):
463576
return self.csv_importer()
464577
elif isinstance(test, GraphiteTestConfig):
465578
return self.graphite_importer()
466579
elif isinstance(test, HistoStatTestConfig):
467580
return self.histostat_importer()
581+
elif isinstance(test, PostgresTestConfig):
582+
return self.postgres_importer()
468583
else:
469584
raise ValueError(f"Unsupported test type {type(test)}")

hunter/postgres.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from dataclasses import dataclass
2+
3+
import psycopg2
4+
5+
6+
@dataclass
7+
class PostgresConfig:
8+
hostname: str
9+
port: int
10+
username: str
11+
password: str
12+
database: str
13+
14+
15+
class Postgres:
16+
__conn = None
17+
__config = None
18+
19+
def __init__(self, config: PostgresConfig):
20+
self.__config = config
21+
22+
def __get_conn(self) -> psycopg2.extensions.connection:
23+
if self.__conn is None:
24+
self.__conn = psycopg2.connect(
25+
host=self.__config.hostname,
26+
port=self.__config.port,
27+
user=self.__config.username,
28+
password=self.__config.password,
29+
database=self.__config.database,
30+
)
31+
return self.__conn
32+
33+
def fetch_data(self, query: str):
34+
cursor = self.__get_conn().cursor()
35+
cursor.execute(query)
36+
columns = [c.name for c in cursor.description]
37+
return (columns, cursor.fetchall())

hunter/test_config.py

+65-1
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,45 @@ def fully_qualified_metric_names(self):
121121
return HistoStatImporter().fetch_all_metric_names(self)
122122

123123

124+
@dataclass
125+
class PostgresMetric:
126+
name: str
127+
direction: int
128+
scale: float
129+
column: str
130+
131+
132+
@dataclass
133+
class PostgresTestConfig(TestConfig):
134+
query: str
135+
time_column: str
136+
attributes: List[str]
137+
metrics: Dict[str, PostgresMetric]
138+
139+
def __init__(
140+
self,
141+
name: str,
142+
query: str,
143+
time_column: str = "time",
144+
metrics: List[PostgresMetric] = None,
145+
attributes: List[str] = None,
146+
):
147+
self.name = name
148+
self.query = query
149+
self.time_column = time_column
150+
self.metrics = {m.name: m for m in metrics} if metrics else {}
151+
self.attributes = attributes
152+
153+
def fully_qualified_metric_names(self) -> List[str]:
154+
return list(self.metrics.keys())
155+
156+
124157
def create_test_config(name: str, config: Dict) -> TestConfig:
125158
"""
126159
Loads properties of a test from a dictionary read from hunter's config file
127160
This dictionary must have the `type` property to determine the type of the test.
128161
Other properties depend on the type.
129-
Currently supported test types are `fallout`, `graphite` and `csv`.
162+
Currently supported test types are `fallout`, `graphite`, `csv`, and `psql`.
130163
"""
131164
test_type = config.get("type")
132165
if test_type == "csv":
@@ -135,6 +168,8 @@ def create_test_config(name: str, config: Dict) -> TestConfig:
135168
return create_graphite_test_config(name, config)
136169
elif test_type == "histostat":
137170
return create_histostat_test_config(name, config)
171+
elif test_type == "postgres":
172+
return create_postgres_test_config(name, config)
138173
elif test_type is None:
139174
raise TestConfigError(f"Test type not set for test {name}")
140175
else:
@@ -226,3 +261,32 @@ def create_histostat_test_config(name: str, test_info: Dict) -> HistoStatTestCon
226261
f"Configuration referenced histostat file which does not exist: {file}"
227262
)
228263
return HistoStatTestConfig(name, file)
264+
265+
266+
def create_postgres_test_config(test_name: str, test_info: Dict) -> PostgresTestConfig:
267+
try:
268+
time_column = test_info.get("time_column", "time")
269+
attributes = test_info.get("attributes", [])
270+
metrics_info = test_info.get("metrics")
271+
query = test_info["query"]
272+
273+
metrics = []
274+
if isinstance(metrics_info, List):
275+
for name in metrics_info:
276+
metrics.append(CsvMetric(name, 1, 1.0))
277+
elif isinstance(metrics_info, Dict):
278+
for (metric_name, metric_conf) in metrics_info.items():
279+
metrics.append(
280+
PostgresMetric(
281+
name=metric_name,
282+
column=metric_conf.get("column", metric_name),
283+
direction=int(metric_conf.get("direction", "1")),
284+
scale=float(metric_conf.get("scale", "1")),
285+
)
286+
)
287+
else:
288+
raise TestConfigError(f"Metrics of the test {test_name} must be a list or dictionary")
289+
290+
return PostgresTestConfig(test_name, query, time_column, metrics, attributes)
291+
except KeyError as e:
292+
raise TestConfigError(f"Configuration key not found in test {test_name}: {e.args[0]}")

0 commit comments

Comments
 (0)