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,4 @@
1
+ """Top-level package for GeoGlyph."""
2
+
3
+ __author__ = """Sam Irvine"""
4
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ if __name__ == "__main__":
2
+ from shadoof.cli import app
3
+ app()
@@ -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,13 @@
1
+ """Command line client."""
2
+
3
+ import typer
4
+
5
+ from shadoof.uploader import write_to_hilltop
6
+
7
+
8
+ app = typer.Typer()
9
+ app.command()(write_to_hilltop)
10
+
11
+
12
+ if __name__ == "__main__":
13
+ app()
@@ -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()