shadoof 0.1.0__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.
shadoof-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shadoof
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command line utility for writing to hilltop from xmls and dsns of xmls
|
|
5
|
+
License: GNU General Public License v3
|
|
6
|
+
Author: SIrvine1994
|
|
7
|
+
Author-email: sam.irvine@horizons.govt.nz
|
|
8
|
+
Requires-Python: >=3.14.1,<4.0
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Dist: pyodbc (>=5.3.0,<6.0.0)
|
|
12
|
+
Requires-Dist: pywin32 (>=312,<313)
|
|
13
|
+
Requires-Dist: typer (>=0.27.1,<0.28.0)
|
|
14
|
+
Requires-Dist: whurl (>=0.2.1,<0.3.0)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shadoof"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Command line utility for writing to hilltop from xmls and dsns of xmls"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "SIrvine1994", email = "sam.irvine@horizons.govt.nz"}
|
|
7
|
+
]
|
|
8
|
+
license = "GNU General Public License v3"
|
|
9
|
+
requires-python = ">=3.14.1,<4.0"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer (>=0.27.1,<0.28.0)",
|
|
12
|
+
"whurl (>=0.2.1,<0.3.0)",
|
|
13
|
+
"pyodbc (>=5.3.0,<6.0.0)",
|
|
14
|
+
"pywin32 (>=312,<313)"
|
|
15
|
+
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
shadoof = "shadoof.cli:app"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
23
|
+
build-backend = "poetry.core.masonry.api"
|
|
24
|
+
|
|
25
|
+
[tool.bumpversion]
|
|
26
|
+
current_version = "0.1.0"
|
|
27
|
+
commit = true
|
|
28
|
+
tag = true
|
|
29
|
+
tag_name = "{new_version}"
|
|
30
|
+
|
|
31
|
+
[[tool.bumpversion.files]]
|
|
32
|
+
filename = "pyproject.toml"
|
|
33
|
+
search = "{current_version}"
|
|
34
|
+
replace = "{new_version}"
|
|
35
|
+
|
|
36
|
+
[[tool.bumpversion.files]]
|
|
37
|
+
filename = "src/shadoof/__init__.py"
|
|
38
|
+
search = "__version__ = \"{current_version}\""
|
|
39
|
+
replace = "__version__ = \"{new_version}\""
|
|
40
|
+
|
|
41
|
+
[[tool.bumpversion.files]]
|
|
42
|
+
filename = "readme.rst"
|
|
43
|
+
search = "{current_version}"
|
|
44
|
+
replace = "{new_version}"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Adds an audit trail for a dsn copy consistent with copying the individual xml/hts files."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import getpass
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pyodbc
|
|
9
|
+
import whurl
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_audit_dict(whurl_measurement):
|
|
13
|
+
"""Get audit details in desired dict format."""
|
|
14
|
+
return {
|
|
15
|
+
"site": whurl_measurement.site_name,
|
|
16
|
+
"data_source": whurl_measurement.data_source.name,
|
|
17
|
+
"ts_type": whurl_measurement.data_source.ts_type,
|
|
18
|
+
"start_date": whurl_measurement.data.timeseries.index[0],
|
|
19
|
+
"end_date": whurl_measurement.data.timeseries.index[-1],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_whurl_audit(whurl_root):
|
|
24
|
+
"""Get the details from a whurl object, returns details for audit."""
|
|
25
|
+
details = []
|
|
26
|
+
for meas in whurl_root.measurement:
|
|
27
|
+
details.append(get_audit_dict(meas))
|
|
28
|
+
return details
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_audit_destination(hts_destination):
|
|
32
|
+
"""Gets the audit destination from an hts file."""
|
|
33
|
+
audit_destination = str(Path(hts_destination).with_suffix(".accdb"))
|
|
34
|
+
if not Path(audit_destination).exists():
|
|
35
|
+
raise FileNotFoundError(f"The audit file {audit_destination} does not exist.")
|
|
36
|
+
return audit_destination
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_to_access_for_single_copy(source, destination):
|
|
40
|
+
"""Adds an audit trail for copying an individual xml/hts file."""
|
|
41
|
+
root = whurl.schemas.responses.GetDataResponse.from_xml(source)
|
|
42
|
+
for transaction in get_whurl_audit(root):
|
|
43
|
+
write_access_row(
|
|
44
|
+
get_audit_destination(destination),
|
|
45
|
+
transaction["site"],
|
|
46
|
+
transaction["data_source"],
|
|
47
|
+
transaction["ts_type"],
|
|
48
|
+
transaction["start_date"],
|
|
49
|
+
transaction["end_date"],
|
|
50
|
+
source,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_access_row(
|
|
55
|
+
access_file, site, data_source, ts_type, start_date, end_date, source
|
|
56
|
+
):
|
|
57
|
+
"""Write a single access audit entry."""
|
|
58
|
+
connection_string = (
|
|
59
|
+
"DRIVER={Microsoft Access Driver (*.mdb, *.accdb)};DBQ=" + access_file + ";"
|
|
60
|
+
)
|
|
61
|
+
cnxn = pyodbc.connect(connection_string)
|
|
62
|
+
|
|
63
|
+
insert_query = """
|
|
64
|
+
INSERT INTO Audit ("Date","UserName","Process","Site","DataSource","TsType","StartDate","EndDate","Comment")
|
|
65
|
+
VALUES (?,?,?,?,?,?,?,?,?);
|
|
66
|
+
"""
|
|
67
|
+
values = (
|
|
68
|
+
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
69
|
+
getpass.getuser(),
|
|
70
|
+
"Copy from Site",
|
|
71
|
+
site,
|
|
72
|
+
data_source,
|
|
73
|
+
ts_type,
|
|
74
|
+
start_date,
|
|
75
|
+
end_date,
|
|
76
|
+
source,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
cursor = cnxn.cursor()
|
|
80
|
+
print(values) # Actually good to have some feedback - not just a debug print
|
|
81
|
+
cursor.execute(insert_query, values)
|
|
82
|
+
cursor.commit()
|
|
83
|
+
|
|
84
|
+
cursor.close()
|
|
85
|
+
cnxn.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_to_access_for_dsn(source_dsn, destination):
|
|
89
|
+
"""Parse dsn or other file to turn it into auditing instructions."""
|
|
90
|
+
with open(source_dsn) as file:
|
|
91
|
+
dsn_text = file.read()
|
|
92
|
+
regex = re.compile(r'File\d*="(.*)"')
|
|
93
|
+
source_file = regex.findall(dsn_text)
|
|
94
|
+
for path in source_file:
|
|
95
|
+
if Path(path).suffix == ".dsn":
|
|
96
|
+
write_to_access_for_dsn(path, destination)
|
|
97
|
+
else:
|
|
98
|
+
write_to_access_for_single_copy(path, destination)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Write data to a Hilltop file."""
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pythoncom
|
|
6
|
+
import pywintypes
|
|
7
|
+
import win32com.client
|
|
8
|
+
from win32com.client import VARIANT
|
|
9
|
+
|
|
10
|
+
import whurl
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import shadoof.auditor as audit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _data_date_converter(df_row, info):
|
|
19
|
+
if info == "D":
|
|
20
|
+
return pd.to_datetime(df_row).to_pydatetime().replace(tzinfo=UTC)
|
|
21
|
+
else:
|
|
22
|
+
if pd.isna(df_row):
|
|
23
|
+
return ""
|
|
24
|
+
else:
|
|
25
|
+
return df_row
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tstype_converter(ts_type: str) -> int:
|
|
29
|
+
"""
|
|
30
|
+
Convert tstype strings into ints for COM interpretation.
|
|
31
|
+
|
|
32
|
+
standard = 1
|
|
33
|
+
quality = 2
|
|
34
|
+
check = 3
|
|
35
|
+
"""
|
|
36
|
+
match ts_type:
|
|
37
|
+
case "StdSeries":
|
|
38
|
+
return 1
|
|
39
|
+
case "StdQualSeries":
|
|
40
|
+
return 2
|
|
41
|
+
case "CheckSeries":
|
|
42
|
+
return 3
|
|
43
|
+
case _:
|
|
44
|
+
raise ValueError(f"Unrecognised ts_type: {ts_type}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ToHilltop:
|
|
48
|
+
"""
|
|
49
|
+
Hilltop COM writer.
|
|
50
|
+
|
|
51
|
+
Writes directly into a Hilltop file.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, output_hts):
|
|
55
|
+
self.dput = win32com.client.Dispatch("Hilltop.DataInput")
|
|
56
|
+
if not self.dput.open(output_hts):
|
|
57
|
+
raise RuntimeError(f"No data file. Error is: {self.dput.errormsg}")
|
|
58
|
+
|
|
59
|
+
# Variants for receiving the date and values They have to be initialised, not just declared.
|
|
60
|
+
# We give a dummy date to get started
|
|
61
|
+
self.vtTime = VARIANT(pythoncom.VT_DATE, pywintypes.Time(datetime(2024, 1, 1)))
|
|
62
|
+
self.vtValue = VARIANT(pythoncom.VT_R4, 0.0)
|
|
63
|
+
self.vtValues = VARIANT(
|
|
64
|
+
pythoncom.VT_BYREF | pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, []
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def close(self):
|
|
68
|
+
"""
|
|
69
|
+
Close the file - maybe.
|
|
70
|
+
|
|
71
|
+
It appears to just call a bool, which shouldn't have an effect. However, there isn't enough documentation to
|
|
72
|
+
say that this doesn't have a side effect that prevents a memory leak - so leaving this be for now. COM object
|
|
73
|
+
bad. Returning it so that the linter doesn't complain.
|
|
74
|
+
"""
|
|
75
|
+
return self.dput.Close
|
|
76
|
+
|
|
77
|
+
def putData(self, data_source_name, ts_type, item_info, site_name, data):
|
|
78
|
+
data.attrs["timeSeriesType"] = tstype_converter(ts_type) # time series type
|
|
79
|
+
data.attrs["featureOfInterest"] = site_name # Site
|
|
80
|
+
data.attrs["procedure"] = data_source_name # Datasource
|
|
81
|
+
if tstype_converter(ts_type) == 2:
|
|
82
|
+
# Quality doesn't have item_info normally
|
|
83
|
+
item_info = ["I"]
|
|
84
|
+
|
|
85
|
+
self._put(data, item_info)
|
|
86
|
+
|
|
87
|
+
# Write a Pandas Frame
|
|
88
|
+
def put_std(self, df):
|
|
89
|
+
"""Insert Standard data into Hilltop COM."""
|
|
90
|
+
df.attrs["timeSeriesType"] = 1
|
|
91
|
+
self._put(df, ["F"])
|
|
92
|
+
|
|
93
|
+
def put_qual(self, df):
|
|
94
|
+
"""Insert Quality data into hilltop COM."""
|
|
95
|
+
df.attrs["timeSeriesType"] = 2
|
|
96
|
+
self._put(df, ["I"])
|
|
97
|
+
|
|
98
|
+
def put_check(self, df, data_type_list):
|
|
99
|
+
"""Insert check data into hilltop COM."""
|
|
100
|
+
|
|
101
|
+
df.attrs["timeSeriesType"] = 3
|
|
102
|
+
self._put(df, data_type_list)
|
|
103
|
+
|
|
104
|
+
def _put(self, df, data_type_list):
|
|
105
|
+
"""Put data of arbitrary type to Hilltop via COM."""
|
|
106
|
+
|
|
107
|
+
"""Insert check data into hilltop COM."""
|
|
108
|
+
if not self.dput.PutNew2(
|
|
109
|
+
df.attrs["featureOfInterest"],
|
|
110
|
+
df.attrs["procedure"],
|
|
111
|
+
df.attrs["timeSeriesType"],
|
|
112
|
+
):
|
|
113
|
+
raise RuntimeError(f"{self.dput.ErrorMsg}")
|
|
114
|
+
|
|
115
|
+
if df.attrs["timeSeriesType"] not in [1, 2, 3]:
|
|
116
|
+
raise ValueError(f"Invalid time series value {df.attrs['timeSeriesType']}")
|
|
117
|
+
|
|
118
|
+
for data_type in data_type_list:
|
|
119
|
+
known_data_types = ["F", "D", "S", "I"] # float, date, string, integer
|
|
120
|
+
if data_type not in known_data_types:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Invalid data_type: {data_type}. Valid data types are {known_data_types}."
|
|
123
|
+
)
|
|
124
|
+
if len(data_type_list) != len(df.columns):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Received different number of data types and data columns. There are "
|
|
127
|
+
f"{len(data_type_list)} data types given and {len(df.columns)} values in the data frame."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
for row in df.itertuples():
|
|
131
|
+
tm = row.Index.to_pydatetime()
|
|
132
|
+
tm = tm.replace(tzinfo=UTC) # Remove the time zone win32com hates it
|
|
133
|
+
self.vtTime = tm
|
|
134
|
+
self.vtValues = [
|
|
135
|
+
_data_date_converter(data, info)
|
|
136
|
+
for (data, info) in zip(row[1:], data_type_list, strict=True)
|
|
137
|
+
]
|
|
138
|
+
self.dput.PutArray(self.vtTime, self.vtValues)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_hilltop_xml(xml):
|
|
142
|
+
with open(xml, "r") as file:
|
|
143
|
+
xml_content = file.read()
|
|
144
|
+
root = whurl.schemas.responses.GetDataResponse.from_xml(xml_content)
|
|
145
|
+
outputs = []
|
|
146
|
+
for meas in root.measurements:
|
|
147
|
+
meas_dict = {
|
|
148
|
+
"data_source_name": meas.data_source.name,
|
|
149
|
+
"ts_type": meas.data_source.ts_type,
|
|
150
|
+
"item_info": [m.item_format for m in meas.data_source.item_info],
|
|
151
|
+
"site_name": meas.site_name,
|
|
152
|
+
"data": meas.data.timeseries,
|
|
153
|
+
}
|
|
154
|
+
audit_dict = audit.get_audit_dict(meas)
|
|
155
|
+
outputs.append((meas_dict, audit_dict))
|
|
156
|
+
return outputs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_hilltop_dsn(dsn):
|
|
160
|
+
"""Parse dsn to turn it into write instructions."""
|
|
161
|
+
with open(dsn) as file:
|
|
162
|
+
dsn_text = file.read()
|
|
163
|
+
regex = re.compile(r'File\d*="(.*)"')
|
|
164
|
+
source_file = regex.findall(dsn_text)
|
|
165
|
+
outputs = []
|
|
166
|
+
for path in source_file:
|
|
167
|
+
print(path)
|
|
168
|
+
if Path(path).suffix == ".dsn":
|
|
169
|
+
outputs += read_hilltop_dsn(path)
|
|
170
|
+
else:
|
|
171
|
+
outputs.append(read_hilltop_xml(path))
|
|
172
|
+
return outputs
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def write_to_hilltop(input_file, destination):
|
|
176
|
+
"""Write a data file to a given Hilltop file."""
|
|
177
|
+
|
|
178
|
+
if os.path.splitext(destination)[1] != ".hts":
|
|
179
|
+
raise ValueError(f"Destination must be a .hts file, path is {destination}")
|
|
180
|
+
outputs = None
|
|
181
|
+
match os.path.splitext(input_file)[1]:
|
|
182
|
+
case ".xml":
|
|
183
|
+
outputs = read_hilltop_xml(input_file)
|
|
184
|
+
case ".dsn":
|
|
185
|
+
outputs = read_hilltop_dsn(input_file)
|
|
186
|
+
case _:
|
|
187
|
+
raise ValueError(f"Unrecognised input file type '{os.path.splitext(input_file)[1]}'. Full path is {input_file}.")
|
|
188
|
+
|
|
189
|
+
hts_com = ToHilltop(destination)
|
|
190
|
+
for o in outputs:
|
|
191
|
+
print(o[1])
|
|
192
|
+
audit.write_access_row(audit.get_audit_destination(destination), source=input_file, **o[1])
|
|
193
|
+
hts_com.putData(**o[0])
|
|
194
|
+
hts_com.close()
|