pthelma 0.99.3.dev0__cp312-cp312-win_amd64.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,252 @@
1
+ from copy import copy
2
+ from io import StringIO
3
+ from urllib.parse import urljoin
4
+
5
+ try:
6
+ from zoneinfo import ZoneInfo
7
+ except ImportError:
8
+ from backports.zoneinfo import ZoneInfo
9
+
10
+ import iso8601
11
+ import requests
12
+
13
+ from htimeseries import HTimeseries
14
+
15
+
16
+ class EnhydrisApiClient:
17
+ def __init__(self, base_url, token=None):
18
+ self.base_url = base_url
19
+ self.token = token
20
+ self.session = requests.Session()
21
+ if token is not None:
22
+ self.session.headers.update({"Authorization": f"token {self.token}"})
23
+
24
+ # My understanding from requests' documentation is that when I make a post
25
+ # request, it shouldn't be necessary to specify Content-Type:
26
+ # application/x-www-form-urlencoded, and that requests adds the header
27
+ # automatically. However, when running in Python 3, apparently requests does not
28
+ # add the header (although it does convert the post data to
29
+ # x-www-form-urlencoded format). This is why I'm specifying it explicitly.
30
+ self.session.headers.update(
31
+ {"Content-Type": "application/x-www-form-urlencoded"}
32
+ )
33
+
34
+ def __enter__(self):
35
+ self.session.__enter__()
36
+ return self
37
+
38
+ def __exit__(self, *args):
39
+ self.session.__exit__(*args)
40
+
41
+ def check_response(self, expected_status_code=None):
42
+ try:
43
+ self._raise_HTTPError_on_error(expected_status_code=expected_status_code)
44
+ except requests.HTTPError as e:
45
+ if self.response.text:
46
+ raise requests.HTTPError(
47
+ f"{str(e)}. Server response: {self.response.text}"
48
+ )
49
+
50
+ def _raise_HTTPError_on_error(self, expected_status_code):
51
+ self._check_status_code_is_nonerror()
52
+ self._check_status_code_is_the_one_expected(expected_status_code)
53
+
54
+ def _check_status_code_is_nonerror(self):
55
+ self.response.raise_for_status()
56
+
57
+ def _check_status_code_is_the_one_expected(self, expected_status_code):
58
+ if expected_status_code and self.response.status_code != expected_status_code:
59
+ raise requests.HTTPError(
60
+ f"Expected status code {expected_status_code}; "
61
+ f"got {self.response.status_code} instead"
62
+ )
63
+
64
+ def get_token(self, username, password):
65
+ if not username:
66
+ return
67
+
68
+ # Get a csrftoken
69
+ login_url = urljoin(self.base_url, "api/auth/login/")
70
+ data = f"username={username}&password={password}"
71
+ self.response = self.session.post(login_url, data=data, allow_redirects=False)
72
+ self.check_response()
73
+ key = self.response.json()["key"]
74
+ self.session.headers.update({"Authorization": f"token {key}"})
75
+ return key
76
+
77
+ def get_station(self, station_id):
78
+ url = urljoin(self.base_url, f"api/stations/{station_id}/")
79
+ self.response = self.session.get(url)
80
+ self.check_response()
81
+ return self.response.json()
82
+
83
+ def post_station(self, data):
84
+ self.response = self.session.post(
85
+ urljoin(self.base_url, "api/stations/"), data=data
86
+ )
87
+ self.check_response()
88
+ return self.response.json()["id"]
89
+
90
+ def put_station(self, station_id, data):
91
+ self.response = self.session.put(
92
+ urljoin(self.base_url, f"api/stations/{station_id}/"), data=data
93
+ )
94
+ self.check_response()
95
+
96
+ def patch_station(self, station_id, data):
97
+ self.response = self.session.patch(
98
+ urljoin(self.base_url, f"api/stations/{station_id}/"), data=data
99
+ )
100
+ self.check_response()
101
+
102
+ def delete_station(self, station_id):
103
+ url = urljoin(self.base_url, f"api/stations/{station_id}/")
104
+ self.response = self.session.delete(url)
105
+ self.check_response(expected_status_code=204)
106
+
107
+ def get_timeseries_group(self, station_id, timeseries_group_id):
108
+ url = urljoin(
109
+ self.base_url,
110
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/",
111
+ )
112
+ self.response = self.session.get(url)
113
+ self.check_response()
114
+ return self.response.json()
115
+
116
+ def post_timeseries_group(self, station_id, data):
117
+ url = urljoin(self.base_url, f"api/stations/{station_id}/timeseriesgroups/")
118
+ self.response = self.session.post(url, data=data)
119
+ self.check_response()
120
+ return self.response.json()["id"]
121
+
122
+ def put_timeseries_group(self, station_id, timeseries_group_id, data):
123
+ url = urljoin(
124
+ self.base_url,
125
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/",
126
+ )
127
+ self.response = self.session.put(url, data=data)
128
+ self.check_response()
129
+ return self.response.json()["id"]
130
+
131
+ def patch_timeseries_group(self, station_id, timeseries_group_id, data):
132
+ url = urljoin(
133
+ self.base_url,
134
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/",
135
+ )
136
+ self.response = self.session.patch(url, data=data)
137
+ self.check_response()
138
+
139
+ def delete_timeseries_group(self, station_id, timeseries_group_id):
140
+ url = urljoin(
141
+ self.base_url,
142
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/",
143
+ )
144
+ self.response = self.session.delete(url)
145
+ self.check_response(expected_status_code=204)
146
+
147
+ def list_timeseries(self, station_id, timeseries_group_id):
148
+ url = urljoin(
149
+ self.base_url,
150
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
151
+ "timeseries/",
152
+ )
153
+ self.response = self.session.get(url)
154
+ self.check_response()
155
+ return self.response.json()["results"]
156
+
157
+ def get_timeseries(self, station_id, timeseries_group_id, timeseries_id):
158
+ url = urljoin(
159
+ self.base_url,
160
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
161
+ f"timeseries/{timeseries_id}/",
162
+ )
163
+ self.response = self.session.get(url)
164
+ self.check_response()
165
+ return self.response.json()
166
+
167
+ def post_timeseries(self, station_id, timeseries_group_id, data):
168
+ self.response = self.session.post(
169
+ urljoin(
170
+ self.base_url,
171
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
172
+ "timeseries/",
173
+ ),
174
+ data=data,
175
+ )
176
+ self.check_response()
177
+ return self.response.json()["id"]
178
+
179
+ def delete_timeseries(self, station_id, timeseries_group_id, timeseries_id):
180
+ url = urljoin(
181
+ self.base_url,
182
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
183
+ f"timeseries/{timeseries_id}/",
184
+ )
185
+ self.response = self.session.delete(url)
186
+ self.check_response(expected_status_code=204)
187
+
188
+ def read_tsdata(
189
+ self,
190
+ station_id,
191
+ timeseries_group_id,
192
+ timeseries_id,
193
+ start_date=None,
194
+ end_date=None,
195
+ timezone=None,
196
+ ):
197
+ url = urljoin(
198
+ self.base_url,
199
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
200
+ f"timeseries/{timeseries_id}/data/",
201
+ )
202
+ params = {"fmt": "hts"}
203
+ tzinfo = ZoneInfo(timezone) if timezone else None
204
+ dates_are_aware = (start_date is None or start_date.tzinfo is not None) and (
205
+ end_date is None or end_date.tzinfo is not None
206
+ )
207
+ if not dates_are_aware:
208
+ raise ValueError("start_date and end_date must be aware")
209
+ params["start_date"] = start_date and start_date.isoformat()
210
+ params["end_date"] = end_date and end_date.isoformat()
211
+ params["timezone"] = timezone
212
+ self.response = self.session.get(url, params=params)
213
+ self.check_response()
214
+ if self.response.text:
215
+ return HTimeseries(StringIO(self.response.text), default_tzinfo=tzinfo)
216
+ else:
217
+ return HTimeseries()
218
+
219
+ def post_tsdata(self, station_id, timeseries_group_id, timeseries_id, ts):
220
+ f = StringIO()
221
+ data = copy(ts.data)
222
+ try:
223
+ data.index = data.index.tz_convert("UTC")
224
+ except AttributeError:
225
+ assert data.empty
226
+ data.to_csv(f, header=False)
227
+ url = urljoin(
228
+ self.base_url,
229
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
230
+ f"timeseries/{timeseries_id}/data/",
231
+ )
232
+ self.response = self.session.post(
233
+ url, data={"timeseries_records": f.getvalue(), "timezone": "UTC"}
234
+ )
235
+ self.check_response()
236
+ return self.response.text
237
+
238
+ def get_ts_end_date(
239
+ self, station_id, timeseries_group_id, timeseries_id, timezone=None
240
+ ):
241
+ url = urljoin(
242
+ self.base_url,
243
+ f"api/stations/{station_id}/timeseriesgroups/{timeseries_group_id}/"
244
+ f"timeseries/{timeseries_id}/bottom/",
245
+ )
246
+ self.response = self.session.get(url, params={"timezone": timezone})
247
+ self.check_response()
248
+ try:
249
+ datestring = self.response.text.strip().split(",")[0]
250
+ return iso8601.parse_date(datestring, default_timezone=None)
251
+ except (IndexError, iso8601.ParseError):
252
+ return None
@@ -0,0 +1,5 @@
1
+ from .enhydris_cache import * # NOQA
2
+
3
+ __author__ = """Antonis Christofides"""
4
+ __email__ = "antonis@antonischristofides.com"
5
+ __version__ = "0.1.0.dev0"
enhydris_cache/cli.py ADDED
@@ -0,0 +1,150 @@
1
+ import configparser
2
+ import datetime as dt
3
+ import logging
4
+ import os
5
+ import sys
6
+ import traceback
7
+
8
+ import click
9
+
10
+ from enhydris_cache import TimeseriesCache
11
+ from pthelma._version import __version__
12
+
13
+
14
+ class WrongValueError(configparser.Error):
15
+ pass
16
+
17
+
18
+ class App:
19
+ def __init__(self, configfilename):
20
+ self.configfilename = configfilename
21
+
22
+ def run(self):
23
+ self.config = AppConfig(self.configfilename)
24
+ self.config.read()
25
+ self._setup_logger()
26
+ self._execute_with_error_handling()
27
+
28
+ def _execute_with_error_handling(self):
29
+ self.logger.info("Starting enhydris-cache, " + dt.datetime.today().isoformat())
30
+ try:
31
+ self._execute()
32
+ except Exception as e:
33
+ self.logger.error(str(e))
34
+ self.logger.debug(traceback.format_exc())
35
+ self.logger.info(
36
+ "enhydris-cache terminated with error, "
37
+ + dt.datetime.today().isoformat()
38
+ )
39
+ raise click.ClickException(str(e))
40
+ else:
41
+ self.logger.info(
42
+ "Finished enhydris-cache, " + dt.datetime.today().isoformat()
43
+ )
44
+
45
+ def _setup_logger(self):
46
+ self.logger = logging.getLogger("spatialize")
47
+ self._set_logger_handler()
48
+ self.logger.setLevel(self.config.loglevel.upper())
49
+
50
+ def _set_logger_handler(self):
51
+ if getattr(self.config, "logfile", None):
52
+ self.logger.addHandler(logging.FileHandler(self.config.logfile))
53
+ else:
54
+ self.logger.addHandler(logging.StreamHandler())
55
+
56
+ def _execute(self):
57
+ os.chdir(self.config.cache_dir)
58
+ self.cache = TimeseriesCache(self.config.timeseries_group)
59
+ self.cache.update()
60
+
61
+
62
+ class AppConfig:
63
+ config_file_general_options = {
64
+ "logfile": {"fallback": ""},
65
+ "loglevel": {"fallback": "warning"},
66
+ "cache_dir": {"fallback": os.getcwd()},
67
+ }
68
+ config_file_timeseries_options = {
69
+ "base_url": {},
70
+ "station_id": {},
71
+ "timeseries_id": {},
72
+ "timeseries_group_id": {},
73
+ "auth_token": {"fallback": None},
74
+ "file": {},
75
+ }
76
+
77
+ def __init__(self, configfilename):
78
+ self.configfilename = configfilename
79
+
80
+ def read(self):
81
+ try:
82
+ self._parse_config()
83
+ except (OSError, configparser.Error) as e:
84
+ sys.stderr.write(str(e))
85
+ raise click.ClickException(str(e))
86
+
87
+ def _parse_config(self):
88
+ self._read_config_file()
89
+ self._parse_general_section()
90
+ self._parse_timeseries_sections()
91
+
92
+ def _read_config_file(self):
93
+ self.config = configparser.ConfigParser(interpolation=None)
94
+ with open(self.configfilename) as f:
95
+ self.config.read_file(f)
96
+
97
+ def _parse_general_section(self):
98
+ options = {
99
+ opt: self.config.get("General", opt, **kwargs)
100
+ for opt, kwargs in self.config_file_general_options.items()
101
+ }
102
+ for key, value in options.items():
103
+ setattr(self, key, value)
104
+ self._parse_log_level()
105
+
106
+ def _parse_log_level(self):
107
+ log_levels = ("ERROR", "WARNING", "INFO", "DEBUG")
108
+ self.loglevel = self.loglevel.upper()
109
+ if self.loglevel not in log_levels:
110
+ raise WrongValueError("loglevel must be one of " + ", ".join(log_levels))
111
+
112
+ def _parse_timeseries_sections(self):
113
+ self.timeseries_group = []
114
+ for section in self.config:
115
+ if section in ("General", "DEFAULT"):
116
+ continue
117
+ item = self._read_section(section)
118
+ self.timeseries_group.append(item)
119
+
120
+ def _read_section(self, section):
121
+ options = {
122
+ opt: self.config.get(section, opt, **kwargs)
123
+ for opt, kwargs in self.config_file_timeseries_options.items()
124
+ }
125
+ options["name"] = section
126
+ try:
127
+ options["station_id"] = int(options["station_id"])
128
+ options["timeseries_group_id"] = int(options["timeseries_group_id"])
129
+ options["timeseries_id"] = int(options["timeseries_id"])
130
+ except ValueError:
131
+ raise WrongValueError(
132
+ '"{}" or "{}" or "{}" is not a valid integer'.format(
133
+ options["station_id"],
134
+ options["timeseries_group_id"],
135
+ options["timeseries_id"],
136
+ )
137
+ )
138
+ return options
139
+
140
+
141
+ @click.command()
142
+ @click.argument("configfile")
143
+ @click.version_option(
144
+ version=__version__, message="%(prog)s from pthelma v.%(version)s"
145
+ )
146
+ def main(configfile):
147
+ """Spatial integration"""
148
+
149
+ app = App(configfile)
150
+ app.run()
@@ -0,0 +1,69 @@
1
+ import datetime as dt
2
+
3
+ import pandas as pd
4
+
5
+ from enhydris_api_client import EnhydrisApiClient
6
+ from htimeseries import HTimeseries
7
+
8
+
9
+ class TimeseriesCache(object):
10
+ def __init__(self, timeseries_group):
11
+ self.timeseries_group = timeseries_group
12
+
13
+ def update(self):
14
+ for item in self.timeseries_group:
15
+ self.base_url = item["base_url"]
16
+ if self.base_url[-1] != "/":
17
+ self.base_url += "/"
18
+ self.station_id = item["station_id"]
19
+ self.timeseries_group_id = item["timeseries_group_id"]
20
+ self.timeseries_id = item["timeseries_id"]
21
+ self.auth_token = item["auth_token"]
22
+ self.filename = item["file"]
23
+ self._update_for_one_timeseries()
24
+
25
+ def _update_for_one_timeseries(self):
26
+ cached_ts = self._read_timeseries_from_cache_file()
27
+ end_date = self._get_timeseries_end_date(cached_ts)
28
+ start_date = end_date + dt.timedelta(minutes=1)
29
+ new_ts = self._append_newer_timeseries(start_date, cached_ts)
30
+ with open(self.filename, "w", encoding="utf-8") as f:
31
+ new_ts.write(f, format=HTimeseries.FILE)
32
+
33
+ def _read_timeseries_from_cache_file(self):
34
+ try:
35
+ with open(self.filename, newline="\n") as f:
36
+ return HTimeseries(f)
37
+ except (FileNotFoundError, ValueError):
38
+ # If file is corrupted or nonexistent, continue with empty time series
39
+ return HTimeseries()
40
+
41
+ def _get_timeseries_end_date(self, timeseries):
42
+ try:
43
+ end_date = timeseries.data.index[-1]
44
+ except IndexError:
45
+ # Timeseries is totally empty; no start and end date
46
+ end_date = dt.datetime(1, 1, 1, 0, 0, tzinfo=dt.timezone.utc)
47
+ return end_date
48
+
49
+ def _append_newer_timeseries(self, start_date, old_ts):
50
+ with EnhydrisApiClient(self.base_url, token=self.auth_token) as api_client:
51
+ ts = api_client.read_tsdata(
52
+ self.station_id,
53
+ self.timeseries_group_id,
54
+ self.timeseries_id,
55
+ start_date=start_date,
56
+ )
57
+ new_data = ts.data
58
+
59
+ # For appending to work properly, both time series need to have the same
60
+ # tz.
61
+ if len(old_ts.data):
62
+ new_data.index = new_data.index.tz_convert(old_ts.data.index.tz)
63
+ else:
64
+ old_ts.data.index = old_ts.data.index.tz_convert(new_data.index.tz)
65
+
66
+ ts.data = pd.concat(
67
+ [old_ts.data, new_data], verify_integrity=True, sort=False
68
+ )
69
+ return ts
@@ -0,0 +1,4 @@
1
+ from .evaporation import * # NOQA
2
+
3
+ __author__ = """Antonis Christofides"""
4
+ __email__ = "antonis@antonischristofides.com"