hapiclient 0.2.8__tar.gz → 0.3.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.
- {hapiclient-0.2.8 → hapiclient-0.3.0}/CHANGES.txt +9 -0
- {hapiclient-0.2.8/hapiclient.egg-info → hapiclient-0.3.0}/PKG-INFO +1 -1
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/__init__.py +1 -1
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/cache.py +7 -28
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/capabilities.py +11 -4
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/data.py +1 -1
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/get.py +56 -40
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/hapi.py +10 -9
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/hapitime.py +2 -1
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/log.py +15 -7
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/util.py +73 -21
- {hapiclient-0.2.8 → hapiclient-0.3.0/hapiclient.egg-info}/PKG-INFO +1 -1
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient.egg-info/SOURCES.txt +1 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/setup.py +1 -1
- hapiclient-0.3.0/test/test_cache.py +119 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_chunking.py +1 -1
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_hapi_data_requests.py +31 -20
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_hapi_metadata_requests.py +1 -2
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_hapitime2datetime.py +3 -3
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_logging.py +4 -28
- {hapiclient-0.2.8 → hapiclient-0.3.0}/LICENSE.txt +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/MANIFEST.in +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/README.md +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/catalog.py +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/info.py +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient/servers.py +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient.egg-info/dependency_links.txt +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient.egg-info/requires.txt +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/hapiclient.egg-info/top_level.txt +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/setup.cfg +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_datetime2hapitime.py +0 -0
- {hapiclient-0.2.8 → hapiclient-0.3.0}/test/test_hapitime_reformat.py +0 -0
|
@@ -120,3 +120,12 @@ v0.2.8b2:
|
|
|
120
120
|
2026-05-20 -- Refactor
|
|
121
121
|
v0.2.8:
|
|
122
122
|
2026-06-04 -- Compat. fixes for hapiplot package.
|
|
123
|
+
v0.2.8b0:
|
|
124
|
+
2026-06-08 -- Show time parsing error message
|
|
125
|
+
v0.2.9:
|
|
126
|
+
2026-06-08 -- 93-occasional-crashes-due-to-undefined-variable-dt-in-get_csv-hapiclient-version-028
|
|
127
|
+
v0.3.0:
|
|
128
|
+
2026-06-22 -- Try numpy if pandas csv read fails. https://github.com/hapi-server/client-python/pull/98
|
|
129
|
+
2026-06-22 -- Fix logging broken by refactor. https://github.com/hapi-server/client-python/pull/96
|
|
130
|
+
2026-06-22 -- Fix so test/test_logging.py passes (issue with using tox)
|
|
131
|
+
2026-07-08 -- 94-cache-files-can-be-corrupted-by-interrupted-writes
|
|
@@ -125,10 +125,9 @@ def meta_cache_write(meta, SERVER, DATASET, opts):
|
|
|
125
125
|
"""Write metadata to JSON and PKL cache files."""
|
|
126
126
|
|
|
127
127
|
import os
|
|
128
|
-
import json
|
|
129
|
-
import pickle
|
|
130
128
|
|
|
131
129
|
from hapiclient.log import log
|
|
130
|
+
from hapiclient.util import write_atomic
|
|
132
131
|
|
|
133
132
|
if not opts["cache"]:
|
|
134
133
|
return
|
|
@@ -136,17 +135,11 @@ def meta_cache_write(meta, SERVER, DATASET, opts):
|
|
|
136
135
|
paths = meta_cache_paths(SERVER, DATASET, opts['cachedir'])
|
|
137
136
|
fnamejson, fnamepkl = paths['json'], paths['pkl']
|
|
138
137
|
|
|
139
|
-
server_dir = cachedir(opts["cachedir"], SERVER)
|
|
140
|
-
os.makedirs(server_dir, exist_ok=True)
|
|
141
|
-
|
|
142
138
|
log('Writing %s ' % os.path.basename(fnamejson))
|
|
143
|
-
|
|
144
|
-
json.dump(meta, f, indent=4)
|
|
139
|
+
write_atomic(fnamejson, meta)
|
|
145
140
|
|
|
146
141
|
log('Writing %s ' % os.path.basename(fnamepkl))
|
|
147
|
-
|
|
148
|
-
# protocol=2 used for Python 2.7 compatibility.
|
|
149
|
-
pickle.dump(meta, f, protocol=2)
|
|
142
|
+
write_atomic(fnamepkl, meta)
|
|
150
143
|
|
|
151
144
|
|
|
152
145
|
def data_cache_paths(SERVER, DATASET, PARAMETERS, START, STOP, cachedir):
|
|
@@ -171,7 +164,7 @@ def data_cache_read_metax(SERVER, DATASET, PARAMETERS, START, STOP, opts):
|
|
|
171
164
|
from hapiclient.log import log
|
|
172
165
|
|
|
173
166
|
if not opts["usecache"]:
|
|
174
|
-
log('Not checking
|
|
167
|
+
log('Not checking subsetted metadata cache because usecache is False.')
|
|
175
168
|
return None
|
|
176
169
|
|
|
177
170
|
fnamepklx = data_cache_paths(SERVER, DATASET, PARAMETERS, START, STOP, opts['cachedir'])['pkl']
|
|
@@ -215,11 +208,9 @@ def data_cache_write(data_result, meta, SERVER, DATASET, PARAMETERS, START, STOP
|
|
|
215
208
|
"""
|
|
216
209
|
|
|
217
210
|
import os
|
|
218
|
-
import pickle
|
|
219
|
-
import warnings
|
|
220
|
-
import numpy as np
|
|
221
211
|
|
|
222
212
|
from hapiclient.log import log
|
|
213
|
+
from hapiclient.util import write_atomic
|
|
223
214
|
|
|
224
215
|
data_paths = data_cache_paths(SERVER, DATASET, PARAMETERS, START, STOP, opts['cachedir'])
|
|
225
216
|
fnamecsv, fnamebin, fnamenpy, fnamepklx = data_paths['csv'], data_paths['bin'], data_paths['npy'], data_paths['pkl']
|
|
@@ -236,20 +227,8 @@ def data_cache_write(data_result, meta, SERVER, DATASET, PARAMETERS, START, STOP
|
|
|
236
227
|
# Need to return after meta is updated.
|
|
237
228
|
return
|
|
238
229
|
|
|
239
|
-
server_dir = cachedir(opts["cachedir"], SERVER)
|
|
240
|
-
os.makedirs(server_dir, exist_ok=True)
|
|
241
|
-
|
|
242
230
|
log('Writing %s' % os.path.basename(fnamepklx))
|
|
243
|
-
|
|
244
|
-
pickle.dump(meta, f, protocol=2)
|
|
231
|
+
write_atomic(fnamepklx, meta)
|
|
245
232
|
|
|
246
233
|
log('Writing %s' % os.path.basename(fnamenpy))
|
|
247
|
-
|
|
248
|
-
# Ignore warning that occurs when saving Unicode data.
|
|
249
|
-
kwargs = {
|
|
250
|
-
'message': r"Stored array in format 3\.0.*",
|
|
251
|
-
'category': UserWarning,
|
|
252
|
-
'module': r"numpy\.lib\.format"
|
|
253
|
-
}
|
|
254
|
-
warnings.filterwarnings("ignore", **kwargs)
|
|
255
|
-
np.save(fnamenpy, data_result)
|
|
234
|
+
write_atomic(fnamenpy, data_result)
|
|
@@ -31,12 +31,19 @@ def get_format(SERVER, format):
|
|
|
31
31
|
|
|
32
32
|
if format != 'csv':
|
|
33
33
|
caps = capabilities(SERVER)
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
if "outputFormats" not in caps:
|
|
35
|
+
return 'csv'
|
|
36
|
+
|
|
37
|
+
formats = caps.get("outputFormats", []) # Server formats
|
|
38
|
+
if len(formats) == 0:
|
|
39
|
+
return 'csv'
|
|
40
|
+
|
|
41
|
+
if format not in formats:
|
|
36
42
|
msg = 'Requested transport format "%s" not avaiable from %s. Will use "csv". Available options: %s'
|
|
37
|
-
warning(msg % (format, SERVER, ', '.join(
|
|
43
|
+
warning(msg % (format, SERVER, ', '.join(formats)))
|
|
38
44
|
format = 'csv'
|
|
39
|
-
|
|
45
|
+
|
|
46
|
+
if 'binary' not in formats:
|
|
40
47
|
format = 'csv'
|
|
41
48
|
|
|
42
49
|
return format
|
|
@@ -22,7 +22,7 @@ def data(SERVER, DATASET, PARAMETERS, START, STOP, opts):
|
|
|
22
22
|
log('STOP was given as None. Getting stopDate for dataset.')
|
|
23
23
|
meta = info(SERVER, DATASET, None, opts)
|
|
24
24
|
STOP = meta['stopDate']
|
|
25
|
-
log('Using STOP = {STOP}')
|
|
25
|
+
log(f'Using STOP = {STOP}')
|
|
26
26
|
|
|
27
27
|
tic_totalTime = time.time()
|
|
28
28
|
|
|
@@ -174,12 +174,9 @@ def get_csv(meta, SERVER, DATASET, PARAMETERS, START, STOP, opts):
|
|
|
174
174
|
if opts["cache"]:
|
|
175
175
|
tic0 = time.time()
|
|
176
176
|
urlretrieve(urlcsv, fnamecsv)
|
|
177
|
-
toc0 = time.time() - tic0
|
|
178
177
|
log('Reading and parsing %s' % os.path.basename(fnamecsv))
|
|
179
|
-
tic = time.time()
|
|
180
178
|
if os.path.getsize(fnamecsv) == 0:
|
|
181
179
|
file_empty = True
|
|
182
|
-
data = np.array([], dtype=dt)
|
|
183
180
|
else:
|
|
184
181
|
from io import StringIO
|
|
185
182
|
log('Writing %s to buffer' % urlcsv)
|
|
@@ -188,50 +185,64 @@ def get_csv(meta, SERVER, DATASET, PARAMETERS, START, STOP, opts):
|
|
|
188
185
|
fnamecsv.seek(0, os.SEEK_END)
|
|
189
186
|
if fnamecsv.tell() == 0:
|
|
190
187
|
file_empty = True
|
|
191
|
-
data = np.array([], dtype=dt)
|
|
192
188
|
else:
|
|
193
189
|
fnamecsv.seek(0)
|
|
194
|
-
toc0 = time.time() - tic0
|
|
195
190
|
log('Parsing StringIO buffer.')
|
|
196
|
-
tic = time.time()
|
|
197
191
|
|
|
198
|
-
|
|
192
|
+
toc0 = time.time() - tic0
|
|
193
|
+
|
|
194
|
+
tic1 = time.time()
|
|
195
|
+
|
|
196
|
+
if file_empty:
|
|
197
|
+
log("Response is empty. Returning empty data array.")
|
|
198
|
+
dt, _, _, _, _ = _compute_dt(meta, opts)
|
|
199
|
+
data = np.array([], dtype=dt)
|
|
200
|
+
else:
|
|
199
201
|
if missing_length(meta, opts):
|
|
200
202
|
data = _parse_csv_missing_length(fnamecsv, meta, opts, urlcsv)
|
|
201
203
|
else:
|
|
202
204
|
data = _parse_csv(fnamecsv, meta, opts, urlcsv)
|
|
203
205
|
|
|
204
|
-
|
|
206
|
+
toc1 = time.time() - tic1
|
|
205
207
|
|
|
206
|
-
return data, toc0,
|
|
208
|
+
return data, toc0, toc1
|
|
207
209
|
|
|
208
210
|
|
|
209
211
|
def _parse_csv(fnamecsv, meta, opts, urlcsv):
|
|
210
|
-
|
|
212
|
+
|
|
211
213
|
# All string and isotime parameters have a length in metadata.
|
|
212
|
-
|
|
214
|
+
|
|
215
|
+
dt, cols, psizes, pnames, ptypes = _compute_dt(meta, opts)
|
|
216
|
+
|
|
217
|
+
kwargs_numpy = {
|
|
218
|
+
'dtype': dt,
|
|
219
|
+
'delimiter': ',',
|
|
220
|
+
'replace_space': ' ',
|
|
221
|
+
'deletechars': '',
|
|
222
|
+
'encoding': 'utf-8'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
kwargs_pandas = {
|
|
226
|
+
'sep': ',',
|
|
227
|
+
'header': None,
|
|
228
|
+
'encoding': 'utf-8',
|
|
229
|
+
'skipinitialspace': True,
|
|
230
|
+
'keep_default_na': False,
|
|
231
|
+
'na_values': ['NaN', 'nan', 'Nan', 'naN', ' "NaN"', ' "nan"', ' "Nan"', ' "naN"', '"NaN"', '"nan"', '"Nan"', '"naN"']
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
def _numpy(fname_csv):
|
|
213
235
|
try:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
'delimiter': ',',
|
|
217
|
-
'replace_space': ' ',
|
|
218
|
-
'deletechars': '',
|
|
219
|
-
'encoding': 'utf-8'
|
|
220
|
-
}
|
|
221
|
-
data = np.genfromtxt(fnamecsv, **kwargs)
|
|
236
|
+
data = np.genfromtxt(fnamecsv, **kwargs_numpy)
|
|
237
|
+
return data
|
|
222
238
|
except Exception as e:
|
|
223
239
|
error('np.genfromtxt({}) gave {} using data from {}'.format(fnamecsv, e, urlcsv))
|
|
224
240
|
|
|
241
|
+
if opts['method'] == 'numpy':
|
|
242
|
+
return _numpy(fnamecsv)
|
|
243
|
+
|
|
225
244
|
if opts['method'] == '' or opts['method'] == 'pandas':
|
|
226
245
|
# Read file into Pandas DataFrame
|
|
227
|
-
kwargs = {
|
|
228
|
-
'sep': ',',
|
|
229
|
-
'header': None,
|
|
230
|
-
'encoding': 'utf-8',
|
|
231
|
-
'skipinitialspace': True,
|
|
232
|
-
'keep_default_na': False,
|
|
233
|
-
'na_values': ['NaN', 'nan', 'Nan', 'naN', ' "NaN"', ' "nan"', ' "Nan"', ' "naN"', '"NaN"', '"nan"', '"Nan"', '"naN"']
|
|
234
|
-
}
|
|
235
246
|
"""
|
|
236
247
|
Note that this does not handle trailing whitespace after
|
|
237
248
|
any of the na_values. (There is no skiptrailingspace option).
|
|
@@ -246,21 +257,26 @@ def _parse_csv(fnamecsv, meta, opts, urlcsv):
|
|
|
246
257
|
csv_kwargs["converters"] = {i: strip_field for i in range(ncols)}
|
|
247
258
|
"""
|
|
248
259
|
try:
|
|
249
|
-
df = pandas.read_csv(fnamecsv, **
|
|
260
|
+
df = pandas.read_csv(fnamecsv, **kwargs_pandas)
|
|
250
261
|
except Exception as e:
|
|
251
|
-
error('
|
|
262
|
+
error('\npandas.read_csv("{}")\nwith kwargs\n {}\ngave\n "{}"\nusing data from\n {}'.format(fnamecsv, kwargs_pandas, e, urlcsv))
|
|
252
263
|
|
|
253
|
-
# Allocate output N-D array (It is not possible to pass dtype=dt
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
+
# Allocate output N-D array (It is not possible to pass dtype=dt as computed
|
|
265
|
+
# to pandas.read_csv; pandas dtypes are different from numpy's dtypes.)
|
|
266
|
+
try:
|
|
267
|
+
data = np.ndarray(shape=(len(df)), dtype=dt)
|
|
268
|
+
# Insert data from dataframe 'df' columns into N-D array 'data'
|
|
269
|
+
for i in range(0, len(pnames)):
|
|
270
|
+
shape = np.append(len(data), psizes[i])
|
|
271
|
+
datap = df.values[:, np.arange(cols[i][0], cols[i][1] + 1)]
|
|
272
|
+
data[pnames[i]] = np.squeeze(np.reshape(datap, shape))
|
|
273
|
+
except Exception as e:
|
|
274
|
+
try:
|
|
275
|
+
data = _numpy(fnamecsv)
|
|
276
|
+
except Exception as e2:
|
|
277
|
+
emsg = '\nError converting CSV response read using\n pandas.read_csv("{}")\nwith\n kwargs = {}\nto a ndarray:\n "{}".'
|
|
278
|
+
emsg += '\nAlso,\n np.genfromtxt("{}")\nwith kwargs\n {}\ngave\n "{}".'
|
|
279
|
+
error(emsg.format(urlcsv, kwargs_pandas, e, fnamecsv, kwargs_numpy, e2))
|
|
264
280
|
|
|
265
281
|
return data
|
|
266
282
|
|
|
@@ -7,7 +7,7 @@ from hapiclient.info import info
|
|
|
7
7
|
from hapiclient.data import data
|
|
8
8
|
|
|
9
9
|
# Backward compatibility: older code imports request2path from hapiclient.hapi
|
|
10
|
-
from hapiclient.cache import request2path
|
|
10
|
+
from hapiclient.cache import request2path # Do not remove.
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
|
|
@@ -45,7 +45,7 @@ def hapiopts():
|
|
|
45
45
|
def hapi(*args, **kwargs):
|
|
46
46
|
"""Request data from a HAPI server.
|
|
47
47
|
|
|
48
|
-
Version: 0.
|
|
48
|
+
Version: 0.3.0
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
Examples
|
|
@@ -66,9 +66,9 @@ def hapi(*args, **kwargs):
|
|
|
66
66
|
stop: str or None
|
|
67
67
|
The end time of the requested data; end times are exclusive - the
|
|
68
68
|
last data record returned by a HAPI server should have a timestamp
|
|
69
|
-
before `
|
|
69
|
+
before `stop`. If `None`, `stopDate` is used.
|
|
70
70
|
options: dict
|
|
71
|
-
`logging` (``False``) -
|
|
71
|
+
`logging` (``False``) - If ``True``, add a stdout handler to the Python ``logging`` module and set log level to INFO. If the Python ``logging`` module has already been configured for the ``hapiclient`` logger (external handlers or log level set), the ``logging`` kwarg is ignored.
|
|
72
72
|
|
|
73
73
|
`cache` (``True``) - Save responses and processed responses in cachedir
|
|
74
74
|
|
|
@@ -210,13 +210,14 @@ def hapi(*args, **kwargs):
|
|
|
210
210
|
# TODO: Consider warning.
|
|
211
211
|
STOP = STOP + 'Z'
|
|
212
212
|
|
|
213
|
-
# Override defaults
|
|
214
|
-
opts = setopts(hapiopts(), kwargs)
|
|
215
213
|
|
|
216
|
-
|
|
217
|
-
|
|
214
|
+
if 'logging' in kwargs and not isinstance(kwargs['logging'], bool):
|
|
215
|
+
raise ValueError("logging keyword must be True or False")
|
|
216
|
+
|
|
217
|
+
configure_logging(kwargs.get('logging', None))
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
# Override defaults
|
|
220
|
+
opts = setopts(hapiopts(), kwargs)
|
|
220
221
|
|
|
221
222
|
assert (opts['cache'] in [True, False]), "cache keyword must be True of False"
|
|
222
223
|
assert (opts['usecache'] in [True, False]), "usecache keyword must be True of False"
|
|
@@ -274,7 +274,8 @@ def hapitime2datetime(Time, **kwargs):
|
|
|
274
274
|
toc = time.time() - tic
|
|
275
275
|
log("Pandas processing time = %.4fs, first time = %s" % (toc, Timeo))
|
|
276
276
|
return Time
|
|
277
|
-
except:
|
|
277
|
+
except Exception as e:
|
|
278
|
+
log("Pandas processing failed with error: " + str(e))
|
|
278
279
|
log("Pandas processing failed, first time = %s" % Time[0])
|
|
279
280
|
|
|
280
281
|
|
|
@@ -5,12 +5,16 @@ _logger = _logging.getLogger("hapiclient")
|
|
|
5
5
|
_INTERNAL_HANDLER_ATTR = "_hapiclient_internal_handler"
|
|
6
6
|
_INTERNAL_LEVEL_ATTR = "_hapiclient_internal_level"
|
|
7
7
|
|
|
8
|
+
# Disable propagation by default so hapiclient logs don't bubble up
|
|
9
|
+
# to root logger (e.g., pytest's logger). Can be re-enabled by user.
|
|
10
|
+
_logger.propagate = False
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
|
|
13
|
+
def configure_logging(logging):
|
|
10
14
|
"""Configure the hapiclient logger based on opts['logging'].
|
|
11
15
|
|
|
12
16
|
If the hapiclient logger has been configured externally (level != NOTSET
|
|
13
|
-
or handlers present), the logging kwarg.
|
|
17
|
+
or handlers present) and logging=False, the logging kwarg is ignored.
|
|
14
18
|
"""
|
|
15
19
|
has_user_level = _logger.level != _logging.NOTSET and \
|
|
16
20
|
_logger.level != getattr(_logger, _INTERNAL_LEVEL_ATTR, None)
|
|
@@ -19,22 +23,26 @@ def configure_logging(opts):
|
|
|
19
23
|
for handler in _logger.handlers
|
|
20
24
|
)
|
|
21
25
|
|
|
22
|
-
if
|
|
26
|
+
if logging is True:
|
|
23
27
|
_logger.setLevel(_logging.INFO)
|
|
24
28
|
setattr(_logger, _INTERNAL_LEVEL_ATTR, _logging.INFO)
|
|
25
29
|
_logger.propagate = False
|
|
26
|
-
|
|
30
|
+
_has_internal = any(getattr(h, _INTERNAL_HANDLER_ATTR, False) for h in _logger.handlers)
|
|
31
|
+
if not _has_internal:
|
|
27
32
|
import sys
|
|
28
33
|
_handler = _logging.StreamHandler(sys.stdout)
|
|
29
34
|
_handler.setFormatter(_logging.Formatter("%(message)s"))
|
|
30
35
|
setattr(_handler, _INTERNAL_HANDLER_ATTR, True)
|
|
31
36
|
_logger.addHandler(_handler)
|
|
32
|
-
|
|
37
|
+
if logging is False:
|
|
33
38
|
if has_user_level or has_user_handlers:
|
|
39
|
+
#from .util import warning
|
|
34
40
|
if has_user_handlers:
|
|
35
|
-
|
|
41
|
+
pass
|
|
42
|
+
#warning("Ignoring logging=False because standard Python logger for 'hapiclient' already configured with handlers.")
|
|
36
43
|
else:
|
|
37
|
-
|
|
44
|
+
pass
|
|
45
|
+
#warning("Ignoring logging=False because standard Python logger for 'hapiclient' already configured with log_level != NOTSET.")
|
|
38
46
|
else:
|
|
39
47
|
_logger.setLevel(_logging.WARNING)
|
|
40
48
|
setattr(_logger, _INTERNAL_LEVEL_ATTR, _logging.WARNING)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from hapiclient.log import log
|
|
1
|
+
from hapiclient.log import log
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
def setopts(defaults, given):
|
|
@@ -148,7 +148,7 @@ def warning(*args):
|
|
|
148
148
|
import platform
|
|
149
149
|
prefix = "\x1b[31mHAPIWarning:\x1b[0m "
|
|
150
150
|
if platform.system() == 'Windows' and pythonshell() == 'shell':
|
|
151
|
-
prefix = "HAPIWarning: "
|
|
151
|
+
prefix = "HAPIWarning: "
|
|
152
152
|
|
|
153
153
|
return prefix
|
|
154
154
|
|
|
@@ -197,7 +197,6 @@ def error(msg, debug=False):
|
|
|
197
197
|
from inspect import stack
|
|
198
198
|
from os import path
|
|
199
199
|
|
|
200
|
-
debug = False
|
|
201
200
|
if pythonshell() != 'shell':
|
|
202
201
|
try:
|
|
203
202
|
from IPython.core.interactiveshell import InteractiveShell
|
|
@@ -214,7 +213,7 @@ def error(msg, debug=False):
|
|
|
214
213
|
import platform
|
|
215
214
|
prefix = "\033[0;31mHAPIError:\033[0m "
|
|
216
215
|
if platform.system() == 'Windows' and pythonshell() == 'shell':
|
|
217
|
-
prefix = "HAPIError: "
|
|
216
|
+
prefix = "HAPIError: "
|
|
218
217
|
|
|
219
218
|
return prefix
|
|
220
219
|
|
|
@@ -222,7 +221,7 @@ def error(msg, debug=False):
|
|
|
222
221
|
filename=None, tb_offset=None,
|
|
223
222
|
exception_only=False,
|
|
224
223
|
running_compiled_code=False):
|
|
225
|
-
|
|
224
|
+
|
|
226
225
|
exception = sys.exc_info()
|
|
227
226
|
if not debug and exception[0].__name__ == "HAPIError":
|
|
228
227
|
sys.stderr.write(prefix() + str(exception[1]))
|
|
@@ -347,26 +346,19 @@ def urlopen(url, parse_json=False):
|
|
|
347
346
|
|
|
348
347
|
|
|
349
348
|
def urlretrieve(url, fname):
|
|
350
|
-
"""Download URL to file
|
|
349
|
+
"""Download URL to file atomically.
|
|
351
350
|
|
|
352
351
|
res = urlretrieve(url, fname)
|
|
353
352
|
"""
|
|
354
353
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
354
|
+
log('Writing')
|
|
355
|
+
log(' %s' % url)
|
|
356
|
+
log('to')
|
|
357
|
+
log(' %s' % fname)
|
|
358
|
+
res = urlopen(url)
|
|
359
|
+
write_atomic(fname, res)
|
|
361
360
|
|
|
362
|
-
|
|
363
|
-
res = urlopen(url)
|
|
364
|
-
log('Writing')
|
|
365
|
-
log('%s' % url)
|
|
366
|
-
log('to')
|
|
367
|
-
log('%s' % os.path.basename(fname))
|
|
368
|
-
shutil.copyfileobj(res, out)
|
|
369
|
-
return res
|
|
361
|
+
return res
|
|
370
362
|
|
|
371
363
|
|
|
372
364
|
def subset_meta(meta, params):
|
|
@@ -394,7 +386,7 @@ def subset_meta(meta, params):
|
|
|
394
386
|
pa = [meta['parameters'][0]] # First parameter is always the time parameter
|
|
395
387
|
|
|
396
388
|
params_reordered = [] # Re-ordered params
|
|
397
|
-
# If time parameter
|
|
389
|
+
# If time parameter explicitly requested, put it first in params_reordered.
|
|
398
390
|
if meta['parameters'][0]['name'] in p:
|
|
399
391
|
params_reordered = [meta['parameters'][0]['name']]
|
|
400
392
|
|
|
@@ -454,3 +446,63 @@ def missing_length(meta, opts):
|
|
|
454
446
|
return True
|
|
455
447
|
|
|
456
448
|
return False
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def write_atomic(path, data):
|
|
452
|
+
|
|
453
|
+
import os
|
|
454
|
+
import json
|
|
455
|
+
import pickle
|
|
456
|
+
import pathlib
|
|
457
|
+
import secrets
|
|
458
|
+
import warnings
|
|
459
|
+
|
|
460
|
+
import numpy
|
|
461
|
+
|
|
462
|
+
path = pathlib.Path(path)
|
|
463
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
tmp_ext = f".{secrets.token_hex(3)}.tmp"
|
|
465
|
+
tmp_path = path.with_suffix(path.suffix + tmp_ext)
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
|
|
469
|
+
if path.suffix == '.json':
|
|
470
|
+
with tmp_path.open('w') as f:
|
|
471
|
+
json.dump(data, f, indent=2)
|
|
472
|
+
|
|
473
|
+
if path.suffix == '.pkl':
|
|
474
|
+
with tmp_path.open('wb') as f:
|
|
475
|
+
pickle.dump(data, f, protocol=2)
|
|
476
|
+
|
|
477
|
+
if path.suffix == '.npy':
|
|
478
|
+
with warnings.catch_warnings():
|
|
479
|
+
# Ignore warning that occurs when saving Unicode data.
|
|
480
|
+
kwargs = {
|
|
481
|
+
'message': r"Stored array in format 3\.0.*",
|
|
482
|
+
'category': UserWarning,
|
|
483
|
+
'module': r"numpy\.lib\.format"
|
|
484
|
+
}
|
|
485
|
+
warnings.filterwarnings("ignore", **kwargs)
|
|
486
|
+
with tmp_path.open('wb') as f:
|
|
487
|
+
numpy.save(f, data)
|
|
488
|
+
|
|
489
|
+
if path.suffix in ('.bin', '.csv'):
|
|
490
|
+
with tmp_path.open('wb') as f:
|
|
491
|
+
if isinstance(data, (bytes, bytearray)):
|
|
492
|
+
f.write(data)
|
|
493
|
+
else:
|
|
494
|
+
# Assume a file-like / streaming response object.
|
|
495
|
+
import shutil
|
|
496
|
+
shutil.copyfileobj(data, f)
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
os.replace(tmp_path, path)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
warning(f"Failed to rename cache file from {tmp_path} to {path}: {e}")
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
warning(f"Failed to write cache file {tmp_path}: {e}")
|
|
505
|
+
try:
|
|
506
|
+
tmp_path.unlink()
|
|
507
|
+
except OSError:
|
|
508
|
+
warning(f"Failed to remove temporary cache file {tmp_path}")
|
|
@@ -20,6 +20,7 @@ hapiclient.egg-info/SOURCES.txt
|
|
|
20
20
|
hapiclient.egg-info/dependency_links.txt
|
|
21
21
|
hapiclient.egg-info/requires.txt
|
|
22
22
|
hapiclient.egg-info/top_level.txt
|
|
23
|
+
test/test_cache.py
|
|
23
24
|
test/test_chunking.py
|
|
24
25
|
test/test_datetime2hapitime.py
|
|
25
26
|
test/test_hapi_data_requests.py
|
|
@@ -32,7 +32,7 @@ if sys.argv[1] == 'develop':
|
|
|
32
32
|
# version is modified by misc/version.py (executed from Makefile). Do not edit.
|
|
33
33
|
setup(
|
|
34
34
|
name='hapiclient',
|
|
35
|
-
version='0.
|
|
35
|
+
version='0.3.0',
|
|
36
36
|
author='Bob Weigel',
|
|
37
37
|
author_email='rweigel@gmu.edu',
|
|
38
38
|
packages=find_packages(),
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# See ../README.md for instructions on running tests.
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
from hapiclient.hapi import hapi
|
|
5
|
+
|
|
6
|
+
from util import compare
|
|
7
|
+
|
|
8
|
+
from util.get_logger import get_logger
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
kwargs = {
|
|
12
|
+
'cache': False,
|
|
13
|
+
'usecache': False,
|
|
14
|
+
'cachedir': '/tmp/hapi-data',
|
|
15
|
+
'logging': False
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
server = 'http://hapi-server.org/servers/TestData2.0/hapi'
|
|
19
|
+
dataset = 'dataset1'
|
|
20
|
+
start = '1970-01-01'
|
|
21
|
+
stop = '1970-01-01T00:00:03'
|
|
22
|
+
|
|
23
|
+
def test_cache_short():
|
|
24
|
+
|
|
25
|
+
# Compare read with empty cache with read with hot cache and usecache=True
|
|
26
|
+
|
|
27
|
+
opts = {**kwargs, 'cache': True}
|
|
28
|
+
|
|
29
|
+
opts['usecache'] = False
|
|
30
|
+
shutil.rmtree(opts['cachedir'], ignore_errors=True)
|
|
31
|
+
data, _ = hapi(server, dataset, 'scalarint,vectorint', start, stop, **opts)
|
|
32
|
+
|
|
33
|
+
opts['usecache'] = True
|
|
34
|
+
data2, _ = hapi(server, dataset, 'scalarint,vectorint', start, stop, **opts)
|
|
35
|
+
|
|
36
|
+
assert compare.equal(data, data2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cache_error():
|
|
40
|
+
|
|
41
|
+
from unittest.mock import patch
|
|
42
|
+
|
|
43
|
+
import io
|
|
44
|
+
import contextlib
|
|
45
|
+
import pathlib
|
|
46
|
+
import tempfile
|
|
47
|
+
from hapiclient.util import write_atomic
|
|
48
|
+
|
|
49
|
+
def assert_warns(fn, expected):
|
|
50
|
+
buf = io.StringIO()
|
|
51
|
+
with contextlib.redirect_stderr(buf):
|
|
52
|
+
result = fn()
|
|
53
|
+
print(buf.getvalue())
|
|
54
|
+
msg = f"Expected '{expected}' in stderr: {buf.getvalue()!r}"
|
|
55
|
+
assert expected in buf.getvalue(), msg
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
# Direct calls to write_atomic()
|
|
59
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
60
|
+
path = pathlib.Path(tmpdir) / 'test.json'
|
|
61
|
+
data = {'key': 'value'}
|
|
62
|
+
|
|
63
|
+
def call_write_atomic():
|
|
64
|
+
write_atomic(str(path), data)
|
|
65
|
+
|
|
66
|
+
# Simulate write failure
|
|
67
|
+
with patch('json.dump', side_effect=OSError('No space left on device')):
|
|
68
|
+
assert_warns(call_write_atomic, 'Failed to write cache file')
|
|
69
|
+
assert not path.exists(), 'File should not exist after write failure'
|
|
70
|
+
|
|
71
|
+
# Simulate os.replace failure after successful write
|
|
72
|
+
with patch('os.replace', side_effect=OSError('Permission denied')):
|
|
73
|
+
assert_warns(call_write_atomic, 'Failed to rename cache file from')
|
|
74
|
+
assert not path.exists(), 'File should not exist after rename failure'
|
|
75
|
+
|
|
76
|
+
# Simulate write failure AND unlink failure
|
|
77
|
+
patch1 = patch('json.dump', side_effect=OSError('No space left on device'))
|
|
78
|
+
patch2 = patch('pathlib.Path.unlink', side_effect=OSError('Permission denied'))
|
|
79
|
+
with patch1, patch2:
|
|
80
|
+
assert_warns(call_write_atomic, 'Failed to remove temporary cache file')
|
|
81
|
+
|
|
82
|
+
# Simulate successful write
|
|
83
|
+
write_atomic(str(path), data)
|
|
84
|
+
assert path.exists(), 'File should exist after successful write'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Indirect calls to write_atomic()
|
|
88
|
+
dataset = 'dataset1'
|
|
89
|
+
start = '1970-01-01'
|
|
90
|
+
stop = '1970-01-01T00:00:03'
|
|
91
|
+
|
|
92
|
+
opts = {**kwargs, 'cache': True, 'usecache': False}
|
|
93
|
+
|
|
94
|
+
def call_hapi():
|
|
95
|
+
data, meta = hapi(server, dataset, 'scalarint,vectorint', start, stop, **opts)
|
|
96
|
+
return data, meta
|
|
97
|
+
|
|
98
|
+
def assert_data_valid(data):
|
|
99
|
+
msg = hapi(server, dataset, 'scalarint,vectorint', start, stop, **opts)
|
|
100
|
+
assert data['Time'][0] == b'1970-01-01T00:00:00.000Z', msg
|
|
101
|
+
|
|
102
|
+
with patch('pickle.dump', side_effect=OSError('No space left on device')):
|
|
103
|
+
data, _ = assert_warns(call_hapi, 'Failed to write cache file')
|
|
104
|
+
assert_data_valid(data)
|
|
105
|
+
|
|
106
|
+
with patch('os.replace', side_effect=OSError('Permission denied')):
|
|
107
|
+
data, _ = assert_warns(call_hapi, 'Failed to rename cache file from')
|
|
108
|
+
assert_data_valid(data)
|
|
109
|
+
|
|
110
|
+
p1 = patch('pickle.dump', side_effect=OSError('No space left on device'))
|
|
111
|
+
p2 = patch('pathlib.Path.unlink', side_effect=OSError('Permission denied'))
|
|
112
|
+
with p1, p2:
|
|
113
|
+
data, _ = assert_warns(call_hapi, 'Failed to remove temporary cache file')
|
|
114
|
+
assert_data_valid(data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == '__main__':
|
|
118
|
+
test_cache_short()
|
|
119
|
+
test_cache_error()
|
|
@@ -97,25 +97,6 @@ def test_all_test_servers():
|
|
|
97
97
|
pytest.fail("test_server('%s') raised: %s" % (version, e))
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def test_cache_short():
|
|
101
|
-
|
|
102
|
-
# Compare read with empty cache with read with hot cache and usecache=True
|
|
103
|
-
dataset = 'dataset1'
|
|
104
|
-
start = '1970-01-01'
|
|
105
|
-
stop = '1970-01-01T00:00:03'
|
|
106
|
-
|
|
107
|
-
opts = {**kwargs, 'cache': True}
|
|
108
|
-
|
|
109
|
-
opts['usecache'] = False
|
|
110
|
-
shutil.rmtree(opts['cachedir'], ignore_errors=True)
|
|
111
|
-
data, meta = hapi(server, dataset, 'scalarint,vectorint', start, stop, **opts)
|
|
112
|
-
|
|
113
|
-
opts['usecache'] = True
|
|
114
|
-
data2, meta2 = hapi(server, dataset, 'scalarint,vectorint', start, stop, **opts)
|
|
115
|
-
|
|
116
|
-
assert compare.equal(data, data2)
|
|
117
|
-
|
|
118
|
-
|
|
119
100
|
def test_subset_short():
|
|
120
101
|
|
|
121
102
|
dataset = 'dataset1'
|
|
@@ -213,12 +194,42 @@ def test_unicode():
|
|
|
213
194
|
assert compare.cache(server, dataset, parameter, opts.copy(), logger=logger)
|
|
214
195
|
|
|
215
196
|
|
|
197
|
+
def test_empty_response():
|
|
198
|
+
from hapiclient import hapi
|
|
199
|
+
|
|
200
|
+
server = 'http://hapi-server.org/servers/TestData2.0/hapi'
|
|
201
|
+
dataset = 'dataset1'
|
|
202
|
+
|
|
203
|
+
# Time range with no data.
|
|
204
|
+
start = '1970-01-01T00:00:00.1Z'
|
|
205
|
+
stop = '1970-01-01T00:00:00.2Z'
|
|
206
|
+
|
|
207
|
+
_kwargs = kwargs.copy()
|
|
208
|
+
_kwargs['cache'] = False
|
|
209
|
+
_kwargs['usecache'] = False
|
|
210
|
+
data_csv, meta = hapi(server, dataset, '', start, stop, format='csv', **_kwargs)
|
|
211
|
+
data_bin, meta = hapi(server, dataset, '', start, stop, format='binary', **_kwargs)
|
|
212
|
+
|
|
213
|
+
assert len(data_csv) == 0
|
|
214
|
+
assert compare.equal(data_csv, data_bin)
|
|
215
|
+
|
|
216
|
+
for i in range(2):
|
|
217
|
+
_kwargs = kwargs.copy()
|
|
218
|
+
_kwargs['cache'] = True
|
|
219
|
+
_kwargs['usecache'] = True
|
|
220
|
+
data_csv, meta = hapi(server, dataset, '', start, stop, format='csv', **_kwargs)
|
|
221
|
+
data_bin, meta = hapi(server, dataset, '', start, stop, format='binary', **_kwargs)
|
|
222
|
+
|
|
223
|
+
assert len(data_csv) == 0
|
|
224
|
+
assert compare.equal(data_csv, data_bin)
|
|
225
|
+
|
|
226
|
+
|
|
216
227
|
if __name__ == '__main__':
|
|
228
|
+
test_empty_response()
|
|
217
229
|
test_subset_short()
|
|
218
230
|
test_reader_timing_short()
|
|
219
231
|
test_reader_timing_long()
|
|
220
232
|
test_all_test_servers()
|
|
221
233
|
test_subset_short()
|
|
222
|
-
test_cache_short()
|
|
223
234
|
test_request2path()
|
|
224
235
|
test_unicode()
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import os
|
|
3
3
|
import json
|
|
4
4
|
import pytest
|
|
5
|
-
import logging
|
|
6
5
|
|
|
7
6
|
from hapiclient.hapi import hapi
|
|
8
7
|
|
|
@@ -13,7 +12,7 @@ kwargs = {
|
|
|
13
12
|
'cache': False,
|
|
14
13
|
'usecache': False,
|
|
15
14
|
'cachedir': '/tmp/hapi-data',
|
|
16
|
-
'logging':
|
|
15
|
+
'logging': False
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
serverbad = 'http://hapi-server.org/servers/TestData/xhapi'
|
|
@@ -27,7 +27,7 @@ def test_api():
|
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
for Time in times:
|
|
30
|
-
logger.
|
|
30
|
+
logger.debug(" Testing hapitime2datetime(" + str(Time) + ")")
|
|
31
31
|
t = hapitime2datetime(Time)
|
|
32
32
|
assert t[0].strftime("%Y-%m-%dT%H:%M:%S.%fZ") == expected
|
|
33
33
|
_assert_utc(t[0])
|
|
@@ -35,7 +35,7 @@ def test_api():
|
|
|
35
35
|
|
|
36
36
|
def test_parsing():
|
|
37
37
|
|
|
38
|
-
logger.
|
|
38
|
+
logger.debug("test_parsing()")
|
|
39
39
|
|
|
40
40
|
expected = '1989-01-01T00:00:00.000000Z'
|
|
41
41
|
|
|
@@ -76,7 +76,7 @@ def test_parsing():
|
|
|
76
76
|
]
|
|
77
77
|
|
|
78
78
|
for i in range(len(dts)):
|
|
79
|
-
logger.
|
|
79
|
+
logger.debug(" Testing hapitime2datetime('" + str(dts[i]) + "')")
|
|
80
80
|
t = hapitime2datetime(dts[i])
|
|
81
81
|
assert t[0].strftime("%Y-%m-%dT%H:%M:%S.%fZ") == expected
|
|
82
82
|
_assert_utc(t[0])
|
|
@@ -12,8 +12,11 @@ stop = '1970-01-01T00:01:00'
|
|
|
12
12
|
def _reset_logger():
|
|
13
13
|
"""Remove all handlers and reset level on the hapiclient logger."""
|
|
14
14
|
logger = logging.getLogger("hapiclient")
|
|
15
|
-
logger.handlers
|
|
15
|
+
for handler in list(logger.handlers):
|
|
16
|
+
logger.removeHandler(handler)
|
|
17
|
+
handler.close()
|
|
16
18
|
logger.setLevel(logging.NOTSET)
|
|
19
|
+
logger.propagate = False
|
|
17
20
|
if hasattr(logger, "_hapiclient_internal_level"):
|
|
18
21
|
delattr(logger, "_hapiclient_internal_level")
|
|
19
22
|
|
|
@@ -87,30 +90,6 @@ class TestStandardLoggingConsole:
|
|
|
87
90
|
assert 'Running hapi.py version' in output
|
|
88
91
|
|
|
89
92
|
|
|
90
|
-
class TestStandardLoggingFile:
|
|
91
|
-
"""Method 4: Standard logging with FileHandler."""
|
|
92
|
-
|
|
93
|
-
def setup_method(self):
|
|
94
|
-
_reset_logger()
|
|
95
|
-
|
|
96
|
-
def teardown_method(self):
|
|
97
|
-
_reset_logger()
|
|
98
|
-
|
|
99
|
-
def test_logs_to_file(self, tmp_path):
|
|
100
|
-
logfile = tmp_path / "hapiclient.log"
|
|
101
|
-
logger = logging.getLogger("hapiclient")
|
|
102
|
-
logger.setLevel(logging.INFO)
|
|
103
|
-
handler = logging.FileHandler(str(logfile))
|
|
104
|
-
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
105
|
-
logger.addHandler(handler)
|
|
106
|
-
|
|
107
|
-
data, meta = hapi(server, dataset, parameters, start, stop)
|
|
108
|
-
handler.close()
|
|
109
|
-
|
|
110
|
-
content = logfile.read_text()
|
|
111
|
-
assert 'Running hapi.py version' in content
|
|
112
|
-
|
|
113
|
-
|
|
114
93
|
class TestLoggingKeywordFalse:
|
|
115
94
|
"""Default: logging=False suppresses INFO messages."""
|
|
116
95
|
|
|
@@ -129,9 +108,6 @@ class TestLoggingKeywordFalse:
|
|
|
129
108
|
|
|
130
109
|
if __name__ == "__main__":
|
|
131
110
|
import pytest
|
|
132
|
-
#pytest.main([__file__])
|
|
133
111
|
pytest.main([__file__ + "::TestLoggingKeywordFalse", "-v"])
|
|
134
112
|
pytest.main([__file__ + "::TestLoggingKeywordTrue", "-v"])
|
|
135
|
-
pytest.main([__file__ + "::TestLoggingKeywordFileObject", "-v"])
|
|
136
113
|
pytest.main([__file__ + "::TestStandardLoggingConsole", "-v"])
|
|
137
|
-
pytest.main([__file__ + "::TestStandardLoggingFile", "-v"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|