Alaea 2.6.2__tar.gz → 2.6.3__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.
- alaea-2.6.3/.VERSION +1 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_atmos.py +17 -3
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/__init__.py +1 -0
- alaea-2.6.3/Alaea/netcdf_input.py +669 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea.egg-info/PKG-INFO +10 -2
- {alaea-2.6.2 → alaea-2.6.3}/Alaea.egg-info/SOURCES.txt +1 -0
- {alaea-2.6.2 → alaea-2.6.3}/ChangeLog.md +5 -0
- {alaea-2.6.2 → alaea-2.6.3}/PKG-INFO +10 -2
- {alaea-2.6.2 → alaea-2.6.3}/README.md +9 -1
- {alaea-2.6.2 → alaea-2.6.3}/setup.py +1 -1
- {alaea-2.6.2 → alaea-2.6.3}/tests/test_core.py +92 -2
- alaea-2.6.2/.VERSION +0 -1
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Blogging.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Pyshell/RS_File.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Pyshell/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/S_DateTime.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/SameAsMATLAB/CoorTrans.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/SameAsMATLAB/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/SameAsMATLAB/calculatefuns.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/vars_name.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_attrs.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_ice.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_netcdf.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_vars.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_wave.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/Standard/xr_wind.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/baysalt/Forage/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/baysalt/Forage/erosion_data/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/baysalt/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/baysalt/erosion_coastline.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/commonCode.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/cprintfs.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/exutils/Interp/Interp_g2s.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/exutils/Interp/Interp_s2g.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/exutils/Interp/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/exutils/Interp/interpolate.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/exutils/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/pFVCOM/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/pFVCOM/fvcom_class.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/pFVCOM/fvcom_image.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/pFVCOM/fvcom_load.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/pFVCOM/fvcom_tools.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/pFVCOM/writeFile.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/processBar.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/read_conf.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/server_info.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/storage.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/tools/P_params.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/tools/Read.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/tools/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/tools/download_check.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/tools/geo_distance.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/tools/kml2shp.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea/warningTools/__init__.py +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea.egg-info/dependency_links.txt +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea.egg-info/requires.txt +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/Alaea.egg-info/top_level.txt +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/LICENSE +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/MANIFEST.in +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/pyproject.toml +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/setup.cfg +0 -0
- {alaea-2.6.2 → alaea-2.6.3}/tests/test_logging_multiprocess.py +0 -0
alaea-2.6.3/.VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
v2.6.3
|
|
@@ -10,11 +10,20 @@ import pandas as pd
|
|
|
10
10
|
import xarray as xr
|
|
11
11
|
|
|
12
12
|
from .. import Ttime
|
|
13
|
-
from .xr_attrs import add_attrs_lat, add_attrs_lon, add_attrs_precipitation, add_attrs_slp, add_attrs_temperature, add_attrs_time, add_attrs_TIME, add_attrs_wind, compress_scale_offset, fixed_encoding
|
|
13
|
+
from .xr_attrs import add_attrs_lat, add_attrs_lon, add_attrs_precipitation, add_attrs_slp, add_attrs_temperature, add_attrs_time, add_attrs_TIME, add_attrs_wind, calc_dtype, calc_fillvalue, compress_scale_offset, fixed_encoding
|
|
14
14
|
|
|
15
15
|
__all__ = ['xr_atmos_dataset', 'xr_wind_dataset', 'xr_air_temperature_dataset', 'xr_precipitation_dataset', 'xr_slp_dataset']
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
_ATMOS_VARIABLE_ATTRS = {
|
|
18
|
+
'visibility': {'units': 'm', 'long_name': 'Surface horizontal visibility', 'standard_name': 'visibility_in_air'},
|
|
19
|
+
'total_cloud_cover': {'units': '%', 'long_name': 'Total cloud cover for the entire atmosphere', 'standard_name': 'cloud_area_fraction', 'comment': 'Cloud fraction for the entire atmospheric column; values are percent, not the sum of level cloud fractions'},
|
|
20
|
+
'sunshine_duration': {'units': 's', 'long_name': 'Hourly sunshine duration', 'standard_name': 'duration_of_sunshine', 'cell_methods': 'time: sum', 'comment': 'Each value is the sunshine duration during its forecast-hour interval'},
|
|
21
|
+
'relative_humidity': {'units': '%', 'long_name': '2 metre relative humidity', 'standard_name': 'relative_humidity'},
|
|
22
|
+
'wind_gust': {'units': 'm s-1', 'long_name': 'Surface wind gust', 'standard_name': 'wind_speed_of_gust'},
|
|
23
|
+
'dew_point_temperature': {'units': 'K', 'long_name': '2 metre dew point temperature', 'standard_name': 'dew_point_temperature'},
|
|
24
|
+
'apparent_temperature': {'units': 'K', 'long_name': '2 metre apparent temperature', 'comment': 'Perceived temperature supplied by the source model; this is not 2 metre air temperature'},
|
|
25
|
+
}
|
|
26
|
+
_ATMOS_VARIABLES = frozenset({'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp', *_ATMOS_VARIABLE_ATTRS})
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
def xr_atmos_dataset(_lon: np.ndarray, _lat: np.ndarray, _time: Union[Sequence[Union[str, datetime, np.datetime64, pd.Timestamp]], np.ndarray, pd.DatetimeIndex], _TIME_type: str = 'char', _COMPRESS: bool = True, _ATTRS_GLOBAL: Optional[dict] = None, _ENCODING_GLOBAL: Optional[dict] = None, **kwargs) -> xr.Dataset:
|
|
@@ -27,7 +36,7 @@ def xr_atmos_dataset(_lon: np.ndarray, _lat: np.ndarray, _time: Union[Sequence[U
|
|
|
27
36
|
:param _COMPRESS: 是否为数值业务变量配置整数 scale/offset packing。
|
|
28
37
|
:param _ATTRS_GLOBAL: 可选的全局属性。
|
|
29
38
|
:param _ENCODING_GLOBAL: 可选的 Dataset 编码设置。
|
|
30
|
-
:param kwargs:
|
|
39
|
+
:param kwargs: 可选的风、气温、降水、海平面气压、能见度、整层总云量、逐小时日照时长、2 米相对湿度、阵风、2 米露点和 2 米体感温度三维数组。
|
|
31
40
|
:return: 时间、坐标、属性和编码已标准化的 xarray.Dataset。
|
|
32
41
|
:raises ValueError: TIME 类型非法、没有业务变量或传入未知变量。
|
|
33
42
|
"""
|
|
@@ -57,6 +66,11 @@ def xr_atmos_dataset(_lon: np.ndarray, _lat: np.ndarray, _time: Union[Sequence[U
|
|
|
57
66
|
dataset = add_attrs_precipitation(dataset, prec_name='precipitation')
|
|
58
67
|
if 'slp' in variables:
|
|
59
68
|
dataset = add_attrs_slp(dataset, slp_name='slp')
|
|
69
|
+
for name, attrs in _ATMOS_VARIABLE_ATTRS.items():
|
|
70
|
+
if name in variables:
|
|
71
|
+
dataset[name].attrs.update(attrs)
|
|
72
|
+
dtype = calc_dtype(dataset[name].data)
|
|
73
|
+
dataset[name].encoding = {'_FillValue': calc_fillvalue(dtype), 'dtype': dtype, 'zlib': True}
|
|
60
74
|
if _ATTRS_GLOBAL is not None:
|
|
61
75
|
dataset.attrs.update(_ATTRS_GLOBAL)
|
|
62
76
|
dataset = fixed_encoding(dataset, **(_ENCODING_GLOBAL or {}))
|
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""为 NetCDF 输入选择直接、本地 SSD 或内存读取方式。"""
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from contextlib import ExitStack, contextmanager
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
import xarray as xr
|
|
16
|
+
from netCDF4 import Dataset
|
|
17
|
+
from xarray.backends import NetCDF4DataStore
|
|
18
|
+
|
|
19
|
+
from .storage import run_storage_fallback
|
|
20
|
+
|
|
21
|
+
INPUT_COPY_BUFFER_BYTES = 32 * 1024 * 1024 # NFS 到本地 SSD 使用 32 MiB 顺序缓冲,减少大文件复制系统调用。
|
|
22
|
+
STAGED_INPUT_FILE_PREFIX = 'alaea-netcdf-input-'
|
|
23
|
+
SUPPORTED_INPUT_METHODS = {'FD_PATH', 'MEMORY', 'NAMED_FILE', 'O_TMPFILE'}
|
|
24
|
+
SUPPORTED_BATCH_INPUT_METHODS = {'LOCAL', 'NAMED_FILE', 'O_TMPFILE'}
|
|
25
|
+
DEFAULT_INPUT_AUTO_METHODS = ('O_TMPFILE', 'NAMED_FILE', 'MEMORY', 'FD_PATH')
|
|
26
|
+
DEFAULT_BATCH_INPUT_AUTO_METHODS = ('O_TMPFILE', 'NAMED_FILE', 'LOCAL')
|
|
27
|
+
DEFAULT_MEMORY_MAX_BYTES = 4 * 1024 * 1024 * 1024
|
|
28
|
+
AT_FDCWD = -100
|
|
29
|
+
AT_EMPTY_PATH = 0x1000
|
|
30
|
+
AT_SYMLINK_FOLLOW = 0x400
|
|
31
|
+
|
|
32
|
+
__all__ = ['DEFAULT_BATCH_INPUT_AUTO_METHODS', 'DEFAULT_INPUT_AUTO_METHODS', 'DEFAULT_MEMORY_MAX_BYTES', 'INPUT_METHOD_OPENERS', 'OpenedNetCDFInput', 'STAGED_INPUT_FILE_PREFIX', 'cleanup_orphaned_input_files', 'open_netcdf_input', 'stage_netcdf_inputs']
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenedNetCDFInput:
|
|
36
|
+
"""保存一个已打开的 xarray/NetCDF4 共享读取器及其内存缓冲。
|
|
37
|
+
|
|
38
|
+
:param dataset: 供制瓦业务读取变量的 xarray 数据集。
|
|
39
|
+
:type dataset: xarray.Dataset
|
|
40
|
+
:param netcdf: 供现有时间坐标逻辑读取的 NetCDF4 数据集;与 ``dataset`` 共用同一底层句柄。
|
|
41
|
+
:type netcdf: netCDF4.Dataset
|
|
42
|
+
:param method: 实际成功使用的输入读取方式。
|
|
43
|
+
:type method: str
|
|
44
|
+
:param memory_buffer: MEMORY 模式必须保持存活的完整 NC 字节缓冲;其他模式为 ``None``。
|
|
45
|
+
:type memory_buffer: bytearray | None
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, dataset, netcdf, method, memory_buffer=None):
|
|
49
|
+
"""建立一个统一生命周期的制瓦输入读取器。
|
|
50
|
+
|
|
51
|
+
:param dataset: 供制瓦业务读取变量的 xarray 数据集。
|
|
52
|
+
:type dataset: xarray.Dataset
|
|
53
|
+
:param netcdf: 与 xarray 共用底层句柄的 NetCDF4 数据集。
|
|
54
|
+
:type netcdf: netCDF4.Dataset
|
|
55
|
+
:param method: 实际成功使用的输入读取方式。
|
|
56
|
+
:type method: str
|
|
57
|
+
:param memory_buffer: MEMORY 模式需保持存活的完整 NC 缓冲。
|
|
58
|
+
:type memory_buffer: bytearray | None
|
|
59
|
+
:return: 无返回值;初始化当前对象。
|
|
60
|
+
:rtype: None
|
|
61
|
+
"""
|
|
62
|
+
self.dataset = dataset
|
|
63
|
+
self.netcdf = netcdf
|
|
64
|
+
self.method = method
|
|
65
|
+
self.memory_buffer = memory_buffer
|
|
66
|
+
|
|
67
|
+
def close(self):
|
|
68
|
+
"""关闭共享 HDF5 读取器并释放可能存在的内存缓冲。
|
|
69
|
+
|
|
70
|
+
:return: 无返回值;重复调用安全。
|
|
71
|
+
:rtype: None
|
|
72
|
+
"""
|
|
73
|
+
dataset = self.dataset
|
|
74
|
+
self.dataset = None
|
|
75
|
+
try:
|
|
76
|
+
if dataset is not None:
|
|
77
|
+
dataset.close()
|
|
78
|
+
finally:
|
|
79
|
+
self.netcdf = None
|
|
80
|
+
self.memory_buffer = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cleanup_orphaned_input_files(local_directory, logger=None, prefixes=None):
|
|
84
|
+
"""删除 Linux 上已退出进程遗留的受控 NetCDF 具名输入副本。
|
|
85
|
+
|
|
86
|
+
:param local_directory: 制瓦输入副本所在的本地 SSD 目录。
|
|
87
|
+
:type local_directory: str | os.PathLike
|
|
88
|
+
:param logger: 可选标准 Logger,用于记录清理失败。
|
|
89
|
+
:type logger: logging.Logger | None
|
|
90
|
+
:param prefixes: 允许清理的文件名前缀;为空时仅使用 Alaea 当前前缀。
|
|
91
|
+
:type prefixes: collections.abc.Iterable[str] | None
|
|
92
|
+
:return: 删除的孤儿文件数,类型为 int。
|
|
93
|
+
:rtype: int
|
|
94
|
+
:raises ValueError: 前缀为空、重复或包含路径分隔符。
|
|
95
|
+
"""
|
|
96
|
+
active_prefixes = tuple(prefixes or (STAGED_INPUT_FILE_PREFIX,))
|
|
97
|
+
if not active_prefixes or len(set(active_prefixes)) != len(active_prefixes) or any(not prefix or os.path.basename(prefix) != prefix for prefix in active_prefixes):
|
|
98
|
+
raise ValueError('prefixes 必须是不重复且不含路径的非空前缀')
|
|
99
|
+
if not os.path.isdir('/proc') or not os.path.isdir(local_directory):
|
|
100
|
+
return 0
|
|
101
|
+
removed = 0
|
|
102
|
+
for entry in os.scandir(local_directory):
|
|
103
|
+
matched_prefix = next((prefix for prefix in active_prefixes if entry.name.startswith(prefix)), None)
|
|
104
|
+
if matched_prefix is None or not entry.name.endswith('.nc') or not entry.is_file(follow_symlinks=False):
|
|
105
|
+
continue
|
|
106
|
+
pid_text = entry.name[len(matched_prefix):].split('-', 1)[0]
|
|
107
|
+
if not pid_text.isdigit() or os.path.exists(f'/proc/{pid_text}'):
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
os.unlink(entry.path)
|
|
111
|
+
removed += 1
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
pass
|
|
114
|
+
except OSError as error:
|
|
115
|
+
if logger is not None:
|
|
116
|
+
logger.warning('清理已退出进程的本地 SSD 输入副本失败:path=%s, error=%s', entry.path, error)
|
|
117
|
+
return removed
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _ensure_local_directory(local_directory, logger=None):
|
|
121
|
+
"""创建本地 SSD 暂存目录并清理可确认的孤儿文件。
|
|
122
|
+
|
|
123
|
+
:param local_directory: 本地 SSD 暂存目录。
|
|
124
|
+
:type local_directory: str | os.PathLike
|
|
125
|
+
:param logger: 可选标准 Logger,用于记录孤儿文件清理失败。
|
|
126
|
+
:type logger: logging.Logger | None
|
|
127
|
+
:return: 可传给文件 API 的目录字符串。
|
|
128
|
+
:rtype: str
|
|
129
|
+
:raises OSError: 目录无法创建或访问。
|
|
130
|
+
"""
|
|
131
|
+
active_local_directory = os.fspath(local_directory)
|
|
132
|
+
os.makedirs(active_local_directory, mode=0o700, exist_ok=True)
|
|
133
|
+
cleanup_orphaned_input_files(active_local_directory, logger=logger)
|
|
134
|
+
return active_local_directory
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _source_signature(file_stat):
|
|
138
|
+
"""提取足以发现复制期间替换或修改的源文件签名。
|
|
139
|
+
|
|
140
|
+
:param file_stat: ``os.stat`` 或 ``os.fstat`` 返回值。
|
|
141
|
+
:type file_stat: os.stat_result
|
|
142
|
+
:return: 设备、inode、字节数和纳秒修改时间组成的元组。
|
|
143
|
+
:rtype: tuple[int, int, int, int]
|
|
144
|
+
"""
|
|
145
|
+
return file_stat.st_dev, file_stat.st_ino, file_stat.st_size, file_stat.st_mtime_ns
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _copy_stable_source(source_path, copy_stream):
|
|
149
|
+
"""打开源文件、执行一次顺序读取并确认复制期间源内容未变化。
|
|
150
|
+
|
|
151
|
+
:param source_path: 正式输入文件路径。
|
|
152
|
+
:type source_path: str
|
|
153
|
+
:param copy_stream: 接收 ``(源文件流, 源字节数)`` 并返回复制结果的函数。
|
|
154
|
+
:type copy_stream: collections.abc.Callable
|
|
155
|
+
:return: ``copy_stream`` 的返回值和源文件字节数。
|
|
156
|
+
:rtype: tuple[typing.Any, int]
|
|
157
|
+
:raises OSError: 源文件读取失败或复制字节数不完整。
|
|
158
|
+
:raises RuntimeError: 复制开始前或期间源文件被替换、截断或修改。
|
|
159
|
+
"""
|
|
160
|
+
source_stat = os.stat(source_path)
|
|
161
|
+
source_signature = _source_signature(source_stat)
|
|
162
|
+
with open(source_path, 'rb', buffering=0) as source_stream:
|
|
163
|
+
if _source_signature(os.fstat(source_stream.fileno())) != source_signature:
|
|
164
|
+
raise RuntimeError(f'NFS 输入在复制开始前已变化:{source_path}')
|
|
165
|
+
result = copy_stream(source_stream, source_stat.st_size)
|
|
166
|
+
completed_signature = _source_signature(os.fstat(source_stream.fileno()))
|
|
167
|
+
if completed_signature != source_signature or _source_signature(os.stat(source_path)) != source_signature:
|
|
168
|
+
raise RuntimeError(f'NFS 输入在复制期间已变化:{source_path}')
|
|
169
|
+
return result, source_stat.st_size
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _copy_source_to_file(source_path, staged_file):
|
|
173
|
+
"""把稳定源文件顺序复制到已打开的本地文件。
|
|
174
|
+
|
|
175
|
+
:param source_path: 正式输入文件路径。
|
|
176
|
+
:type source_path: str
|
|
177
|
+
:param staged_file: 已打开且可写入的本地二进制文件。
|
|
178
|
+
:type staged_file: typing.BinaryIO
|
|
179
|
+
:return: 源文件字节数。
|
|
180
|
+
:rtype: int
|
|
181
|
+
:raises OSError: 复制失败或字节数不一致。
|
|
182
|
+
:raises RuntimeError: 复制期间源文件变化。
|
|
183
|
+
"""
|
|
184
|
+
def copy_stream(source_stream, source_size):
|
|
185
|
+
"""把当前源文件流复制到外层已打开的本地文件。
|
|
186
|
+
|
|
187
|
+
:param source_stream: 已校验身份的正式输入文件流。
|
|
188
|
+
:type source_stream: typing.BinaryIO
|
|
189
|
+
:param source_size: 正式输入的预期字节数。
|
|
190
|
+
:type source_size: int
|
|
191
|
+
:return: 实际写入本地文件的字节数。
|
|
192
|
+
:rtype: int
|
|
193
|
+
"""
|
|
194
|
+
shutil.copyfileobj(source_stream, staged_file, length=INPUT_COPY_BUFFER_BYTES)
|
|
195
|
+
return staged_file.tell()
|
|
196
|
+
|
|
197
|
+
copied_bytes, source_size = _copy_stable_source(source_path, copy_stream)
|
|
198
|
+
if copied_bytes != source_size:
|
|
199
|
+
raise OSError(f'NFS 输入复制字节数不一致:{source_path}')
|
|
200
|
+
staged_file.flush()
|
|
201
|
+
staged_file.seek(0)
|
|
202
|
+
return source_size
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _read_source_into_memory(source_path, maximum_bytes):
|
|
206
|
+
"""把稳定源文件顺序读取到一个预分配内存缓冲。
|
|
207
|
+
|
|
208
|
+
:param source_path: 正式输入文件路径。
|
|
209
|
+
:type source_path: str
|
|
210
|
+
:param maximum_bytes: 允许进入内存模式的最大 NC 字节数。
|
|
211
|
+
:type maximum_bytes: int
|
|
212
|
+
:return: 完整 NC 字节缓冲。
|
|
213
|
+
:rtype: bytearray
|
|
214
|
+
:raises MemoryError: 文件超过配置上限或内存分配失败。
|
|
215
|
+
:raises OSError: 源文件读取不完整。
|
|
216
|
+
:raises RuntimeError: 读取期间源文件变化。
|
|
217
|
+
"""
|
|
218
|
+
def copy_stream(source_stream, source_size):
|
|
219
|
+
"""把当前源文件流写入预分配 RAM 缓冲。
|
|
220
|
+
|
|
221
|
+
:param source_stream: 已校验身份的正式输入文件流。
|
|
222
|
+
:type source_stream: typing.BinaryIO
|
|
223
|
+
:param source_size: 正式输入的预期字节数。
|
|
224
|
+
:type source_size: int
|
|
225
|
+
:return: 已完整填充的 NC 字节缓冲。
|
|
226
|
+
:rtype: bytearray
|
|
227
|
+
"""
|
|
228
|
+
if source_size > maximum_bytes:
|
|
229
|
+
raise MemoryError(f'NC 文件 {source_size} 字节超过内存读取上限 {maximum_bytes} 字节')
|
|
230
|
+
memory_buffer = bytearray(source_size)
|
|
231
|
+
memory_view = memoryview(memory_buffer)
|
|
232
|
+
offset = 0
|
|
233
|
+
try:
|
|
234
|
+
while offset < source_size:
|
|
235
|
+
read_size = source_stream.readinto(memory_view[offset:min(offset + INPUT_COPY_BUFFER_BYTES, source_size)])
|
|
236
|
+
if not read_size:
|
|
237
|
+
break
|
|
238
|
+
offset += read_size
|
|
239
|
+
finally:
|
|
240
|
+
memory_view.release()
|
|
241
|
+
if offset != source_size:
|
|
242
|
+
raise OSError(f'NFS 输入读取字节数不一致:{source_path}')
|
|
243
|
+
return memory_buffer
|
|
244
|
+
|
|
245
|
+
memory_buffer, _source_size = _copy_stable_source(source_path, copy_stream)
|
|
246
|
+
return memory_buffer
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _wrap_netcdf_dataset(netcdf, method, memory_buffer=None):
|
|
250
|
+
"""让 xarray 与既有时间坐标逻辑共享同一个 NetCDF4 句柄。
|
|
251
|
+
|
|
252
|
+
:param netcdf: 已成功打开的 NetCDF4 数据集。
|
|
253
|
+
:type netcdf: netCDF4.Dataset
|
|
254
|
+
:param method: 实际输入读取方式。
|
|
255
|
+
:type method: str
|
|
256
|
+
:param memory_buffer: MEMORY 模式的完整 NC 缓冲。
|
|
257
|
+
:type memory_buffer: bytearray | None
|
|
258
|
+
:return: 可统一关闭的 NetCDF 输入读取器。
|
|
259
|
+
:rtype: OpenedNetCDFInput
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
dataset = xr.open_dataset(NetCDF4DataStore(netcdf))
|
|
263
|
+
except BaseException:
|
|
264
|
+
netcdf.close()
|
|
265
|
+
raise
|
|
266
|
+
return OpenedNetCDFInput(dataset, netcdf, method, memory_buffer)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _open_using_local_path(source_path, _local_directory, _memory_max_bytes):
|
|
270
|
+
"""直接打开本地正式 NC,不复制数据。
|
|
271
|
+
|
|
272
|
+
:param source_path: 本地正式输入路径。
|
|
273
|
+
:type source_path: str
|
|
274
|
+
:param _local_directory: 未使用的统一方法参数。
|
|
275
|
+
:type _local_directory: str | os.PathLike
|
|
276
|
+
:param _memory_max_bytes: 未使用的统一方法参数。
|
|
277
|
+
:type _memory_max_bytes: int
|
|
278
|
+
:return: 已打开的 NetCDF 输入。
|
|
279
|
+
:rtype: OpenedNetCDFInput
|
|
280
|
+
"""
|
|
281
|
+
return _wrap_netcdf_dataset(Dataset(source_path), 'LOCAL')
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _open_using_fd_path(source_path, local_directory, _memory_max_bytes):
|
|
285
|
+
"""复制到匿名临时文件,并通过当前平台的文件描述符路径打开 HDF5。
|
|
286
|
+
|
|
287
|
+
:param source_path: 正式输入文件路径。
|
|
288
|
+
:type source_path: str
|
|
289
|
+
:param local_directory: 本地 SSD 暂存目录。
|
|
290
|
+
:type local_directory: str | os.PathLike
|
|
291
|
+
:param _memory_max_bytes: 未使用的统一方法参数。
|
|
292
|
+
:type _memory_max_bytes: int
|
|
293
|
+
:return: 已打开的 NetCDF 输入。
|
|
294
|
+
:rtype: OpenedNetCDFInput
|
|
295
|
+
:raises RuntimeError: 当前平台没有可用的文件描述符路径。
|
|
296
|
+
"""
|
|
297
|
+
active_local_directory = _ensure_local_directory(local_directory)
|
|
298
|
+
with tempfile.TemporaryFile(mode='w+b', suffix='.nc', dir=active_local_directory) as staged_file:
|
|
299
|
+
_copy_source_to_file(source_path, staged_file)
|
|
300
|
+
descriptor_paths = (f'/dev/fd/{staged_file.fileno()}', f'/proc/self/fd/{staged_file.fileno()}')
|
|
301
|
+
staged_path = next((path for path in descriptor_paths if os.path.exists(path)), None)
|
|
302
|
+
if staged_path is None:
|
|
303
|
+
raise RuntimeError('当前系统没有可供 NetCDF/HDF5 打开的文件描述符路径')
|
|
304
|
+
return _wrap_netcdf_dataset(Dataset(staged_path), 'FD_PATH')
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _open_using_memory(source_path, _local_directory, memory_max_bytes):
|
|
308
|
+
"""把完整 NC 顺序读入 RAM,再通过 NetCDF-C 内存接口打开。
|
|
309
|
+
|
|
310
|
+
:param source_path: 正式输入文件路径。
|
|
311
|
+
:type source_path: str
|
|
312
|
+
:param _local_directory: 未使用的统一方法参数。
|
|
313
|
+
:type _local_directory: str | os.PathLike
|
|
314
|
+
:param memory_max_bytes: 允许进入内存模式的最大 NC 字节数。
|
|
315
|
+
:type memory_max_bytes: int
|
|
316
|
+
:return: 已打开的 NetCDF 输入。
|
|
317
|
+
:rtype: OpenedNetCDFInput
|
|
318
|
+
"""
|
|
319
|
+
memory_buffer = _read_source_into_memory(source_path, memory_max_bytes)
|
|
320
|
+
try:
|
|
321
|
+
netcdf = Dataset(os.path.basename(source_path), memory=memory_buffer)
|
|
322
|
+
except BaseException:
|
|
323
|
+
memory_buffer = None
|
|
324
|
+
raise
|
|
325
|
+
return _wrap_netcdf_dataset(netcdf, 'MEMORY', memory_buffer)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _temporary_link_path(local_directory):
|
|
329
|
+
"""生成只供 O_TMPFILE 短暂建立目录项的进程独占路径。
|
|
330
|
+
|
|
331
|
+
:param local_directory: 本地 SSD 暂存目录。
|
|
332
|
+
:type local_directory: str
|
|
333
|
+
:return: 尚不存在的临时链接路径。
|
|
334
|
+
:rtype: str
|
|
335
|
+
"""
|
|
336
|
+
return os.path.join(local_directory, f'{STAGED_INPUT_FILE_PREFIX}{os.getpid()}-{secrets.token_hex(8)}.nc')
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _link_anonymous_file(file_descriptor, target_path):
|
|
340
|
+
"""使用 Linux ``linkat(AT_EMPTY_PATH)`` 给 O_TMPFILE 临时建立目录项。
|
|
341
|
+
|
|
342
|
+
:param file_descriptor: 由 ``O_TMPFILE`` 创建的匿名文件描述符。
|
|
343
|
+
:type file_descriptor: int
|
|
344
|
+
:param target_path: 待建立的同文件系统具名路径。
|
|
345
|
+
:type target_path: str
|
|
346
|
+
:return: 无返回值。
|
|
347
|
+
:rtype: None
|
|
348
|
+
:raises OSError: 当前 libc、内核、权限或文件系统不支持该操作。
|
|
349
|
+
"""
|
|
350
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
351
|
+
linkat = getattr(libc, 'linkat', None)
|
|
352
|
+
if linkat is None:
|
|
353
|
+
raise OSError(errno.ENOSYS, '当前 libc 不提供 linkat')
|
|
354
|
+
linkat.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_int)
|
|
355
|
+
linkat.restype = ctypes.c_int
|
|
356
|
+
encoded_target_path = os.fsencode(target_path)
|
|
357
|
+
if linkat(file_descriptor, b'', AT_FDCWD, encoded_target_path, AT_EMPTY_PATH) == 0:
|
|
358
|
+
return
|
|
359
|
+
# 普通服务用户通常没有 AT_EMPTY_PATH 所需的 CAP_DAC_READ_SEARCH;Linux 官方兼容路径
|
|
360
|
+
# 通过 /proc/self/fd 和 AT_SYMLINK_FOLLOW 链接同一 O_TMPFILE inode。
|
|
361
|
+
descriptor_path = os.fsencode(f'/proc/self/fd/{file_descriptor}')
|
|
362
|
+
if linkat(AT_FDCWD, descriptor_path, AT_FDCWD, encoded_target_path, AT_SYMLINK_FOLLOW) == 0:
|
|
363
|
+
return
|
|
364
|
+
error_number = ctypes.get_errno()
|
|
365
|
+
raise OSError(error_number, os.strerror(error_number), target_path)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@lru_cache(maxsize=None)
|
|
369
|
+
def _require_otmpfile_link_support(local_directory):
|
|
370
|
+
"""用空文件探测并缓存指定文件系统的 O_TMPFILE/linkat 能力。
|
|
371
|
+
|
|
372
|
+
先探测可避免权限或文件系统不支持时完整复制大型 NC 后才失败。
|
|
373
|
+
|
|
374
|
+
:param local_directory: 已创建的本地 SSD 暂存目录。
|
|
375
|
+
:type local_directory: str
|
|
376
|
+
:return: 无返回值;成功结果按目录缓存。
|
|
377
|
+
:rtype: None
|
|
378
|
+
:raises OSError: 当前 Python、libc、内核、权限或文件系统不支持该组合。
|
|
379
|
+
"""
|
|
380
|
+
if not hasattr(os, 'O_TMPFILE'):
|
|
381
|
+
raise OSError(errno.ENOTSUP, '当前 Python/操作系统不提供 O_TMPFILE')
|
|
382
|
+
probe_path = _temporary_link_path(local_directory)
|
|
383
|
+
probe_descriptor = os.open(local_directory, os.O_RDWR | os.O_TMPFILE | getattr(os, 'O_CLOEXEC', 0), 0o600)
|
|
384
|
+
try:
|
|
385
|
+
_link_anonymous_file(probe_descriptor, probe_path)
|
|
386
|
+
finally:
|
|
387
|
+
try:
|
|
388
|
+
os.unlink(probe_path)
|
|
389
|
+
except FileNotFoundError:
|
|
390
|
+
pass
|
|
391
|
+
os.close(probe_descriptor)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _open_using_named_file(source_path, local_directory, _memory_max_bytes):
|
|
395
|
+
"""复制到具名 SSD 文件,HDF5 打开后立即删除文件名。
|
|
396
|
+
|
|
397
|
+
:param source_path: 正式输入文件路径。
|
|
398
|
+
:type source_path: str
|
|
399
|
+
:param local_directory: 本地 SSD 暂存目录。
|
|
400
|
+
:type local_directory: str | os.PathLike
|
|
401
|
+
:param _memory_max_bytes: 未使用的统一方法参数。
|
|
402
|
+
:type _memory_max_bytes: int
|
|
403
|
+
:return: 已打开的 NetCDF 输入。
|
|
404
|
+
:rtype: OpenedNetCDFInput
|
|
405
|
+
"""
|
|
406
|
+
active_local_directory = _ensure_local_directory(local_directory)
|
|
407
|
+
staged_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'{STAGED_INPUT_FILE_PREFIX}{os.getpid()}-', suffix='.nc', dir=active_local_directory, delete=False)
|
|
408
|
+
staged_path = staged_file.name
|
|
409
|
+
try:
|
|
410
|
+
_copy_source_to_file(source_path, staged_file)
|
|
411
|
+
return _wrap_netcdf_dataset(Dataset(staged_path), 'NAMED_FILE')
|
|
412
|
+
finally:
|
|
413
|
+
try:
|
|
414
|
+
os.unlink(staged_path)
|
|
415
|
+
except FileNotFoundError:
|
|
416
|
+
pass
|
|
417
|
+
staged_file.close()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _open_using_otmpfile(source_path, local_directory, _memory_max_bytes):
|
|
421
|
+
"""使用 Linux O_TMPFILE 复制,短暂 linkat 后让 HDF5 打开并立即解除路径。
|
|
422
|
+
|
|
423
|
+
:param source_path: 正式输入文件路径。
|
|
424
|
+
:type source_path: str
|
|
425
|
+
:param local_directory: 本地 SSD 暂存目录。
|
|
426
|
+
:type local_directory: str | os.PathLike
|
|
427
|
+
:param _memory_max_bytes: 未使用的统一方法参数。
|
|
428
|
+
:type _memory_max_bytes: int
|
|
429
|
+
:return: 已打开的 NetCDF 输入。
|
|
430
|
+
:rtype: OpenedNetCDFInput
|
|
431
|
+
:raises OSError: Python、libc、内核、权限或文件系统不支持 O_TMPFILE/linkat。
|
|
432
|
+
"""
|
|
433
|
+
active_local_directory = _ensure_local_directory(local_directory)
|
|
434
|
+
_require_otmpfile_link_support(active_local_directory)
|
|
435
|
+
open_flags = os.O_RDWR | os.O_TMPFILE | getattr(os, 'O_CLOEXEC', 0)
|
|
436
|
+
staged_file = os.fdopen(os.open(active_local_directory, open_flags, 0o600), 'w+b')
|
|
437
|
+
linked_path = _temporary_link_path(active_local_directory)
|
|
438
|
+
try:
|
|
439
|
+
_copy_source_to_file(source_path, staged_file)
|
|
440
|
+
_link_anonymous_file(staged_file.fileno(), linked_path)
|
|
441
|
+
return _wrap_netcdf_dataset(Dataset(linked_path), 'O_TMPFILE')
|
|
442
|
+
finally:
|
|
443
|
+
try:
|
|
444
|
+
os.unlink(linked_path)
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
pass
|
|
447
|
+
staged_file.close()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
INPUT_METHOD_OPENERS = {
|
|
451
|
+
'FD_PATH': _open_using_fd_path,
|
|
452
|
+
'MEMORY': _open_using_memory,
|
|
453
|
+
'NAMED_FILE': _open_using_named_file,
|
|
454
|
+
'O_TMPFILE': _open_using_otmpfile,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def open_netcdf_input(input_file, storage_mode='AUTO', auto_methods=DEFAULT_INPUT_AUTO_METHODS, local_directory=None, memory_max_bytes=DEFAULT_MEMORY_MAX_BYTES, logger=None, context='netcdf-input'):
|
|
459
|
+
"""直接或通过 SSD/内存暂存打开单个 NetCDF 输入。
|
|
460
|
+
|
|
461
|
+
:param input_file: 已存在的 NetCDF 输入路径。
|
|
462
|
+
:type input_file: str | os.PathLike
|
|
463
|
+
:param storage_mode: ``LOCAL``、``AUTO``、``O_TMPFILE``、``NAMED_FILE``、``MEMORY`` 或 ``FD_PATH``。
|
|
464
|
+
:type storage_mode: str
|
|
465
|
+
:param auto_methods: AUTO 模式的有序方法列表。
|
|
466
|
+
:type auto_methods: collections.abc.Iterable[str]
|
|
467
|
+
:param local_directory: SSD 暂存目录;使用文件暂存方式时不能为 None。
|
|
468
|
+
:type local_directory: str | os.PathLike | None
|
|
469
|
+
:param memory_max_bytes: MEMORY 模式允许的最大字节数。
|
|
470
|
+
:type memory_max_bytes: int
|
|
471
|
+
:param logger: 可选标准 Logger。
|
|
472
|
+
:type logger: logging.Logger | None
|
|
473
|
+
:param context: 不含敏感值的日志上下文。
|
|
474
|
+
:type context: str
|
|
475
|
+
:return: 已打开且必须由调用方关闭的 ``OpenedNetCDFInput``。
|
|
476
|
+
:raises ValueError: 模式、AUTO 顺序或内存上限非法。
|
|
477
|
+
:raises OSError: 所选方式无法读取输入。
|
|
478
|
+
:raises RuntimeError: 复制期间源文件变化或 AUTO 全部方式失败。
|
|
479
|
+
"""
|
|
480
|
+
source_path = os.fspath(input_file)
|
|
481
|
+
active_storage_mode = str(storage_mode).strip().upper()
|
|
482
|
+
active_auto_methods = tuple(str(method).strip().upper() for method in auto_methods)
|
|
483
|
+
requested_methods = active_auto_methods if active_storage_mode == 'AUTO' else (active_storage_mode,)
|
|
484
|
+
if 'MEMORY' in requested_methods and (not isinstance(memory_max_bytes, int) or memory_max_bytes <= 0):
|
|
485
|
+
raise ValueError('memory_max_bytes 必须是正整数')
|
|
486
|
+
if any(method in {'FD_PATH', 'NAMED_FILE', 'O_TMPFILE'} for method in requested_methods) and local_directory is None:
|
|
487
|
+
raise ValueError('文件暂存方式必须提供 local_directory')
|
|
488
|
+
source_bytes = os.stat(source_path).st_size
|
|
489
|
+
operations = {'LOCAL': lambda: _open_using_local_path(source_path, local_directory, memory_max_bytes)} | {method: (lambda active_method=method: INPUT_METHOD_OPENERS[active_method](source_path, local_directory, memory_max_bytes)) for method in SUPPORTED_INPUT_METHODS}
|
|
490
|
+
return run_storage_fallback(active_storage_mode, active_auto_methods, operations, logger=logger, context=f'{context} source={source_path}, bytes={source_bytes}')
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _stage_named_source(source_path, local_directory):
|
|
494
|
+
"""把单个稳定 NetCDF 源文件复制为具名 SSD 候选。
|
|
495
|
+
|
|
496
|
+
:param source_path: 源 NetCDF 路径。
|
|
497
|
+
:type source_path: str | os.PathLike
|
|
498
|
+
:param local_directory: 已创建的 SSD 目录。
|
|
499
|
+
:type local_directory: str | os.PathLike
|
|
500
|
+
:return: 完整复制的候选路径,类型为 str。
|
|
501
|
+
:raises OSError: 候选创建、复制或刷盘失败。
|
|
502
|
+
:raises RuntimeError: 复制期间源文件变化。
|
|
503
|
+
"""
|
|
504
|
+
descriptor, staged_path = tempfile.mkstemp(prefix=f'{STAGED_INPUT_FILE_PREFIX}{os.getpid()}-', suffix='.nc', dir=local_directory)
|
|
505
|
+
try:
|
|
506
|
+
with os.fdopen(descriptor, 'w+b') as staged_file:
|
|
507
|
+
_copy_source_to_file(source_path, staged_file)
|
|
508
|
+
return staged_path
|
|
509
|
+
except BaseException:
|
|
510
|
+
try:
|
|
511
|
+
os.unlink(staged_path)
|
|
512
|
+
except FileNotFoundError:
|
|
513
|
+
pass
|
|
514
|
+
raise
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _stage_otmp_source(source_path, local_directory):
|
|
518
|
+
"""把单个稳定 NetCDF 源文件复制到 O_TMPFILE 并建立临时路径。
|
|
519
|
+
|
|
520
|
+
:param source_path: 源 NetCDF 路径。
|
|
521
|
+
:type source_path: str | os.PathLike
|
|
522
|
+
:param local_directory: 已验证 O_TMPFILE/linkat 的 SSD 目录。
|
|
523
|
+
:type local_directory: str | os.PathLike
|
|
524
|
+
:return: 必须由调用方关闭的文件对象和临时路径元组。
|
|
525
|
+
:raises OSError: 匿名文件、复制或链接创建失败。
|
|
526
|
+
:raises RuntimeError: 复制期间源文件变化。
|
|
527
|
+
"""
|
|
528
|
+
staged_file = os.fdopen(os.open(local_directory, os.O_RDWR | os.O_TMPFILE | getattr(os, 'O_CLOEXEC', 0), 0o600), 'w+b')
|
|
529
|
+
staged_path = _temporary_link_path(local_directory)
|
|
530
|
+
try:
|
|
531
|
+
_copy_source_to_file(source_path, staged_file)
|
|
532
|
+
_link_anonymous_file(staged_file.fileno(), staged_path)
|
|
533
|
+
return staged_file, staged_path
|
|
534
|
+
except BaseException:
|
|
535
|
+
staged_file.close()
|
|
536
|
+
try:
|
|
537
|
+
os.unlink(staged_path)
|
|
538
|
+
except FileNotFoundError:
|
|
539
|
+
pass
|
|
540
|
+
raise
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _remove_staged_path(staged_path):
|
|
544
|
+
"""删除本模块创建的具名暂存路径,路径已不存在时保持幂等。
|
|
545
|
+
|
|
546
|
+
:param staged_path: 本模块创建的具名暂存文件路径。
|
|
547
|
+
:type staged_path: str | os.PathLike
|
|
548
|
+
:return: 无返回值。
|
|
549
|
+
:rtype: None
|
|
550
|
+
:raises OSError: 删除存在的暂存文件失败。
|
|
551
|
+
"""
|
|
552
|
+
try:
|
|
553
|
+
os.unlink(staged_path)
|
|
554
|
+
except FileNotFoundError:
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _cleanup_otmp_result(result):
|
|
559
|
+
"""删除 O_TMPFILE 临时链接并始终关闭对应文件句柄。
|
|
560
|
+
|
|
561
|
+
:param result: ``(文件对象, 临时链接路径)`` 元组。
|
|
562
|
+
:type result: tuple[typing.BinaryIO, str]
|
|
563
|
+
:return: 无返回值。
|
|
564
|
+
:rtype: None
|
|
565
|
+
:raises OSError: 临时链接存在但删除失败。
|
|
566
|
+
"""
|
|
567
|
+
staged_file, staged_path = result
|
|
568
|
+
try:
|
|
569
|
+
_remove_staged_path(staged_path)
|
|
570
|
+
finally:
|
|
571
|
+
staged_file.close()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _stage_sources(source_paths, worker, workers, cleanup):
|
|
575
|
+
"""有界并发执行多文件暂存,失败时清理已完成候选。
|
|
576
|
+
|
|
577
|
+
:param source_paths: 保持输入顺序的源路径元组。
|
|
578
|
+
:type source_paths: tuple[str, ...]
|
|
579
|
+
:param worker: 接收单个源路径并返回暂存结果的可调用对象。
|
|
580
|
+
:type worker: collections.abc.Callable
|
|
581
|
+
:param workers: 同时复制的最大文件数。
|
|
582
|
+
:type workers: int
|
|
583
|
+
:param cleanup: 接收单个暂存结果的清理回调。
|
|
584
|
+
:type cleanup: collections.abc.Callable
|
|
585
|
+
:return: 与源路径同顺序的暂存结果列表。
|
|
586
|
+
:raises BaseException: 任一暂存任务失败时清理后保留首个异常。
|
|
587
|
+
"""
|
|
588
|
+
results = [None] * len(source_paths)
|
|
589
|
+
errors = []
|
|
590
|
+
with ThreadPoolExecutor(max_workers=min(workers, len(source_paths)), thread_name_prefix='netcdf-stage') as executor:
|
|
591
|
+
futures = [executor.submit(worker, source_path) for source_path in source_paths]
|
|
592
|
+
for index, future in enumerate(futures):
|
|
593
|
+
try:
|
|
594
|
+
results[index] = future.result()
|
|
595
|
+
except BaseException as error:
|
|
596
|
+
errors.append(error)
|
|
597
|
+
if errors:
|
|
598
|
+
for result in results:
|
|
599
|
+
if result is not None:
|
|
600
|
+
cleanup(result)
|
|
601
|
+
raise errors[0]
|
|
602
|
+
return results
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@contextmanager
|
|
606
|
+
def stage_netcdf_inputs(input_files, storage_mode='AUTO', auto_methods=DEFAULT_BATCH_INPUT_AUTO_METHODS, local_directory=None, workers=1, logger=None, context='netcdf-input-batch'):
|
|
607
|
+
"""在上下文内把多个 NetCDF 输入保留为可供 xarray 懒读取的路径。
|
|
608
|
+
|
|
609
|
+
:param input_files: 按业务顺序排列的 NetCDF 路径序列。
|
|
610
|
+
:type input_files: collections.abc.Iterable[str | os.PathLike]
|
|
611
|
+
:param storage_mode: ``AUTO``、``LOCAL``、``O_TMPFILE`` 或 ``NAMED_FILE``。
|
|
612
|
+
:type storage_mode: str
|
|
613
|
+
:param auto_methods: AUTO 模式的有序方法列表。
|
|
614
|
+
:type auto_methods: collections.abc.Iterable[str]
|
|
615
|
+
:param local_directory: O_TMPFILE/NAMED_FILE 使用的 SSD 目录。
|
|
616
|
+
:type local_directory: str | os.PathLike | None
|
|
617
|
+
:param workers: 同时复制的最大文件数。
|
|
618
|
+
:type workers: int
|
|
619
|
+
:param logger: 可选标准 Logger。
|
|
620
|
+
:type logger: logging.Logger | None
|
|
621
|
+
:param context: 不含敏感值的日志上下文。
|
|
622
|
+
:type context: str
|
|
623
|
+
:return: 上下文期间有效的路径元组;退出时自动清理 SSD 候选。
|
|
624
|
+
:rtype: contextlib.AbstractContextManager[tuple[str, ...]]
|
|
625
|
+
:raises ValueError: 输入、并发数或方法非法。
|
|
626
|
+
:raises OSError: 暂存无法完成。
|
|
627
|
+
:raises RuntimeError: 源文件变化或 AUTO 全部方式失败。
|
|
628
|
+
"""
|
|
629
|
+
source_paths = tuple(os.fspath(path) for path in input_files)
|
|
630
|
+
if not source_paths:
|
|
631
|
+
raise ValueError('input_files 不能为空')
|
|
632
|
+
if not isinstance(workers, int) or isinstance(workers, bool) or workers < 1:
|
|
633
|
+
raise ValueError('workers 必须是正整数')
|
|
634
|
+
active_storage_mode = str(storage_mode).strip().upper()
|
|
635
|
+
active_auto_methods = tuple(str(method).strip().upper() for method in auto_methods)
|
|
636
|
+
requested_methods = active_auto_methods if active_storage_mode == 'AUTO' else (active_storage_mode,)
|
|
637
|
+
if any(method not in SUPPORTED_BATCH_INPUT_METHODS for method in requested_methods):
|
|
638
|
+
raise ValueError(f'批量路径暂存只支持 {sorted(SUPPORTED_BATCH_INPUT_METHODS)}')
|
|
639
|
+
if any(method != 'LOCAL' for method in requested_methods) and local_directory is None:
|
|
640
|
+
raise ValueError('批量 SSD 暂存必须提供 local_directory')
|
|
641
|
+
stack = ExitStack()
|
|
642
|
+
|
|
643
|
+
def local_operation():
|
|
644
|
+
"""校验并返回原路径。"""
|
|
645
|
+
[os.stat(path) for path in source_paths]
|
|
646
|
+
return source_paths
|
|
647
|
+
|
|
648
|
+
def named_operation():
|
|
649
|
+
"""并发生成具名 SSD 候选并登记退出清理。"""
|
|
650
|
+
active_directory = _ensure_local_directory(local_directory, logger=logger)
|
|
651
|
+
staged_paths = _stage_sources(source_paths, lambda path: _stage_named_source(path, active_directory), workers, _remove_staged_path)
|
|
652
|
+
[stack.callback(_remove_staged_path, path) for path in staged_paths]
|
|
653
|
+
return tuple(staged_paths)
|
|
654
|
+
|
|
655
|
+
def otmp_operation():
|
|
656
|
+
"""并发生成 O_TMPFILE 候选并保持句柄到上下文退出。"""
|
|
657
|
+
active_directory = _ensure_local_directory(local_directory, logger=logger)
|
|
658
|
+
_require_otmpfile_link_support(active_directory)
|
|
659
|
+
staged_results = _stage_sources(source_paths, lambda path: _stage_otmp_source(path, active_directory), workers, _cleanup_otmp_result)
|
|
660
|
+
for staged_file, staged_path in staged_results:
|
|
661
|
+
stack.callback(_cleanup_otmp_result, (staged_file, staged_path))
|
|
662
|
+
return tuple(staged_path for _staged_file, staged_path in staged_results)
|
|
663
|
+
|
|
664
|
+
operations = {'LOCAL': local_operation, 'NAMED_FILE': named_operation, 'O_TMPFILE': otmp_operation}
|
|
665
|
+
try:
|
|
666
|
+
staged_paths = run_storage_fallback(active_storage_mode, active_auto_methods, operations, logger=logger, context=f'{context} files={len(source_paths)}')
|
|
667
|
+
yield staged_paths
|
|
668
|
+
finally:
|
|
669
|
+
stack.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Alaea
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.3
|
|
4
4
|
Summary: Some simple tools by Christmas
|
|
5
5
|
Author: Christmas
|
|
6
6
|
Author-email: 273519355@qq.com
|
|
@@ -66,7 +66,10 @@ continues uploading the remaining archives.
|
|
|
66
66
|
## Standard atmosphere datasets
|
|
67
67
|
|
|
68
68
|
`xr_atmos_dataset` builds any supported combination of wind, 2 metre air
|
|
69
|
-
temperature, per-period precipitation,
|
|
69
|
+
temperature, per-period precipitation, mean sea-level pressure, visibility,
|
|
70
|
+
entire-atmosphere total cloud cover, hourly sunshine duration, 2 metre
|
|
71
|
+
relative humidity, surface wind gust, 2 metre dew point temperature, and
|
|
72
|
+
2 metre apparent temperature. The
|
|
70
73
|
single-element `xr_wind_dataset`, `xr_air_temperature_dataset`,
|
|
71
74
|
`xr_precipitation_dataset`, and `xr_slp_dataset` interfaces use the same
|
|
72
75
|
metadata and encoding path. Integer packing is enabled by default and can be
|
|
@@ -82,6 +85,11 @@ plugin is unavailable.
|
|
|
82
85
|
error boundary, so input staging and output publication can share fallback
|
|
83
86
|
policy without sharing incompatible lifecycle code.
|
|
84
87
|
|
|
88
|
+
`open_netcdf_input` adds a shared single-file NetCDF reader for direct, local
|
|
89
|
+
SSD, memory, named-file, and Linux `O_TMPFILE` modes. For `xarray.open_mfdataset`,
|
|
90
|
+
`stage_netcdf_inputs` preserves input order while copying through a bounded
|
|
91
|
+
worker pool and removes only its own staged files when the context exits.
|
|
92
|
+
|
|
85
93
|
## Unified spatial interpolation
|
|
86
94
|
|
|
87
95
|
`s2s`, `g2s`, `s2g`, and `g2g` use the same argument order. `s` means
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## v2.6.3
|
|
4
|
+
|
|
5
|
+
- `xr_atmos_dataset` 新增能见度、整层大气总云量、逐小时日照持续时间、2 米相对湿度、地表阵风、2 米露点温度和 2 米体感温度,并统一写入物理属性与编码;可与原有五个大气字段任意组合,不再要求调用方补字段或伪造占位变量。
|
|
6
|
+
- 新增公共 NetCDF 输入暂存模块:单文件支持直接、具名 SSD、内存、文件描述符及 Linux `O_TMPFILE` 读取;多文件支持按原顺序有限并发暂存到 SSD,供 `xarray.open_mfdataset` 使用,并在成功、失败及上下文退出时清理本模块候选文件。孤儿清理可接收受控旧前缀用于应用迁移,但不会扫描其他 NC;MakeTiles 与下载程序可共用同一实现,不再分别维护复制、降级和孤儿文件清理逻辑。
|
|
7
|
+
|
|
3
8
|
## v2.6.2
|
|
4
9
|
|
|
5
10
|
- `run_storage_fallback` 的成功线路明细由 INFO 调整为 DEBUG,减少 `Storage method succeeded` 高频常规日志;实际失败并降级仍保持 WARNING。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Alaea
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.3
|
|
4
4
|
Summary: Some simple tools by Christmas
|
|
5
5
|
Author: Christmas
|
|
6
6
|
Author-email: 273519355@qq.com
|
|
@@ -66,7 +66,10 @@ continues uploading the remaining archives.
|
|
|
66
66
|
## Standard atmosphere datasets
|
|
67
67
|
|
|
68
68
|
`xr_atmos_dataset` builds any supported combination of wind, 2 metre air
|
|
69
|
-
temperature, per-period precipitation,
|
|
69
|
+
temperature, per-period precipitation, mean sea-level pressure, visibility,
|
|
70
|
+
entire-atmosphere total cloud cover, hourly sunshine duration, 2 metre
|
|
71
|
+
relative humidity, surface wind gust, 2 metre dew point temperature, and
|
|
72
|
+
2 metre apparent temperature. The
|
|
70
73
|
single-element `xr_wind_dataset`, `xr_air_temperature_dataset`,
|
|
71
74
|
`xr_precipitation_dataset`, and `xr_slp_dataset` interfaces use the same
|
|
72
75
|
metadata and encoding path. Integer packing is enabled by default and can be
|
|
@@ -82,6 +85,11 @@ plugin is unavailable.
|
|
|
82
85
|
error boundary, so input staging and output publication can share fallback
|
|
83
86
|
policy without sharing incompatible lifecycle code.
|
|
84
87
|
|
|
88
|
+
`open_netcdf_input` adds a shared single-file NetCDF reader for direct, local
|
|
89
|
+
SSD, memory, named-file, and Linux `O_TMPFILE` modes. For `xarray.open_mfdataset`,
|
|
90
|
+
`stage_netcdf_inputs` preserves input order while copying through a bounded
|
|
91
|
+
worker pool and removes only its own staged files when the context exits.
|
|
92
|
+
|
|
85
93
|
## Unified spatial interpolation
|
|
86
94
|
|
|
87
95
|
`s2s`, `g2s`, `s2g`, and `g2g` use the same argument order. `s` means
|
|
@@ -29,7 +29,10 @@ continues uploading the remaining archives.
|
|
|
29
29
|
## Standard atmosphere datasets
|
|
30
30
|
|
|
31
31
|
`xr_atmos_dataset` builds any supported combination of wind, 2 metre air
|
|
32
|
-
temperature, per-period precipitation,
|
|
32
|
+
temperature, per-period precipitation, mean sea-level pressure, visibility,
|
|
33
|
+
entire-atmosphere total cloud cover, hourly sunshine duration, 2 metre
|
|
34
|
+
relative humidity, surface wind gust, 2 metre dew point temperature, and
|
|
35
|
+
2 metre apparent temperature. The
|
|
33
36
|
single-element `xr_wind_dataset`, `xr_air_temperature_dataset`,
|
|
34
37
|
`xr_precipitation_dataset`, and `xr_slp_dataset` interfaces use the same
|
|
35
38
|
metadata and encoding path. Integer packing is enabled by default and can be
|
|
@@ -45,6 +48,11 @@ plugin is unavailable.
|
|
|
45
48
|
error boundary, so input staging and output publication can share fallback
|
|
46
49
|
policy without sharing incompatible lifecycle code.
|
|
47
50
|
|
|
51
|
+
`open_netcdf_input` adds a shared single-file NetCDF reader for direct, local
|
|
52
|
+
SSD, memory, named-file, and Linux `O_TMPFILE` modes. For `xarray.open_mfdataset`,
|
|
53
|
+
`stage_netcdf_inputs` preserves input order while copying through a bounded
|
|
54
|
+
worker pool and removes only its own staged files when the context exits.
|
|
55
|
+
|
|
48
56
|
## Unified spatial interpolation
|
|
49
57
|
|
|
50
58
|
`s2s`, `g2s`, `s2g`, and `g2g` use the same argument order. `s` means
|
|
@@ -28,6 +28,7 @@ import Alaea.cprintfs as CPRINT
|
|
|
28
28
|
import Alaea.pFVCOM.writeFile as FVCOM_WRITE
|
|
29
29
|
import Alaea.processBar as PROCESS_BAR
|
|
30
30
|
from Alaea.Standard import calc_dtype, calc_scale_offset, compress_scale_offset, write_netcdf, xr_air_temperature_dataset, xr_atmos_dataset, xr_precipitation_dataset, xr_slp_dataset, xr_wave_dateset, xr_wind_dataset, xr_wind_dateset
|
|
31
|
+
from Alaea.netcdf_input import INPUT_METHOD_OPENERS, STAGED_INPUT_FILE_PREFIX, cleanup_orphaned_input_files, open_netcdf_input, stage_netcdf_inputs
|
|
31
32
|
from Alaea.storage import run_storage_fallback
|
|
32
33
|
|
|
33
34
|
|
|
@@ -257,7 +258,8 @@ class UtilityRegressionTest(unittest.TestCase):
|
|
|
257
258
|
times = pd.date_range('2026-01-01', periods=2, freq='h', tz='UTC')
|
|
258
259
|
coordinates = np.asarray([0.0], dtype=np.float32)
|
|
259
260
|
values = xr.DataArray(np.ones((2, 1, 1), dtype=np.float32), dims=('time', 'latitude', 'longitude'))
|
|
260
|
-
|
|
261
|
+
added_variables = {'visibility', 'total_cloud_cover', 'sunshine_duration', 'relative_humidity', 'wind_gust', 'dew_point_temperature', 'apparent_temperature'}
|
|
262
|
+
atmosphere = xr_atmos_dataset(coordinates, coordinates, times, _COMPRESS=False, wind_U10=values, wind_V10=values, air_temperature=values, precipitation=values, slp=values, **{name: values for name in added_variables})
|
|
261
263
|
datasets = (
|
|
262
264
|
xr_wind_dataset(coordinates, coordinates, times, wind_U10=values, _COMPRESS=False),
|
|
263
265
|
xr_air_temperature_dataset(coordinates, coordinates, times, values, _COMPRESS=False),
|
|
@@ -265,7 +267,7 @@ class UtilityRegressionTest(unittest.TestCase):
|
|
|
265
267
|
xr_slp_dataset(coordinates, coordinates, times, values, _COMPRESS=False),
|
|
266
268
|
)
|
|
267
269
|
|
|
268
|
-
self.assertEqual(set(atmosphere.data_vars), {'TIME', 'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp'})
|
|
270
|
+
self.assertEqual(set(atmosphere.data_vars), {'TIME', 'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp', *added_variables})
|
|
269
271
|
self.assertEqual([set(dataset.data_vars) - {'TIME'} for dataset in datasets], [{'wind_U10'}, {'air_temperature'}, {'precipitation'}, {'slp'}])
|
|
270
272
|
self.assertEqual(atmosphere.time.attrs['standard_name'], 'time')
|
|
271
273
|
self.assertEqual(atmosphere.time.attrs['units'], 'seconds since 1970-01-01 00:00:00')
|
|
@@ -276,6 +278,13 @@ class UtilityRegressionTest(unittest.TestCase):
|
|
|
276
278
|
self.assertEqual(atmosphere.air_temperature.attrs['standard_name'], 'air_temperature')
|
|
277
279
|
self.assertEqual(atmosphere.precipitation.attrs['standard_name'], 'lwe_thickness_of_precipitation_amount')
|
|
278
280
|
self.assertEqual(atmosphere.slp.attrs['standard_name'], 'air_pressure_at_mean_sea_level')
|
|
281
|
+
self.assertEqual(atmosphere.visibility.attrs['standard_name'], 'visibility_in_air')
|
|
282
|
+
self.assertEqual(atmosphere.total_cloud_cover.attrs['standard_name'], 'cloud_area_fraction')
|
|
283
|
+
self.assertEqual(atmosphere.sunshine_duration.attrs['cell_methods'], 'time: sum')
|
|
284
|
+
self.assertEqual(atmosphere.relative_humidity.attrs['long_name'], '2 metre relative humidity')
|
|
285
|
+
self.assertEqual(atmosphere.wind_gust.attrs['units'], 'm s-1')
|
|
286
|
+
self.assertEqual(atmosphere.dew_point_temperature.attrs['standard_name'], 'dew_point_temperature')
|
|
287
|
+
self.assertNotIn('standard_name', atmosphere.apparent_temperature.attrs)
|
|
279
288
|
with tempfile.TemporaryDirectory() as directory:
|
|
280
289
|
output = Path(directory) / 'atmos.nc'
|
|
281
290
|
atmosphere.to_netcdf(output, engine='h5netcdf')
|
|
@@ -353,6 +362,87 @@ class UtilityRegressionTest(unittest.TestCase):
|
|
|
353
362
|
with self.assertRaisesRegex(OSError, 'cache unavailable'):
|
|
354
363
|
run_storage_fallback('CACHE', ('CACHE', 'NFS'), operations)
|
|
355
364
|
|
|
365
|
+
def test_netcdf_input_single_and_batch_staging_preserve_data_and_cleanup(self):
|
|
366
|
+
"""单文件和批量暂存必须保持数据、顺序及自动清理契约。"""
|
|
367
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
368
|
+
root = Path(directory)
|
|
369
|
+
cache = root / 'cache'
|
|
370
|
+
sources = []
|
|
371
|
+
for index in range(2):
|
|
372
|
+
source = root / f'source-{index}.nc'
|
|
373
|
+
xr.Dataset({'value': ('sample', np.asarray([index], dtype=np.float32))}).to_netcdf(source)
|
|
374
|
+
sources.append(source)
|
|
375
|
+
for storage_mode in ('LOCAL', 'NAMED_FILE', 'MEMORY', 'FD_PATH'):
|
|
376
|
+
opened = open_netcdf_input(sources[0], storage_mode=storage_mode, local_directory=cache)
|
|
377
|
+
try:
|
|
378
|
+
self.assertEqual(opened.method, storage_mode)
|
|
379
|
+
np.testing.assert_array_equal(opened.dataset.value.values, [0.0])
|
|
380
|
+
np.testing.assert_array_equal(opened.netcdf.variables['value'][:], [0.0])
|
|
381
|
+
finally:
|
|
382
|
+
opened.close()
|
|
383
|
+
if cache.exists():
|
|
384
|
+
self.assertEqual(list(cache.iterdir()), [])
|
|
385
|
+
for storage_mode in ('LOCAL', 'NAMED_FILE'):
|
|
386
|
+
with stage_netcdf_inputs(sources, storage_mode=storage_mode, local_directory=cache, workers=2) as staged_paths:
|
|
387
|
+
self.assertEqual(len(staged_paths), 2)
|
|
388
|
+
values = []
|
|
389
|
+
for path in staged_paths:
|
|
390
|
+
with xr.open_dataset(path) as dataset:
|
|
391
|
+
values.append(float(dataset.value.values[0]))
|
|
392
|
+
self.assertEqual(values, [0.0, 1.0])
|
|
393
|
+
if storage_mode == 'NAMED_FILE':
|
|
394
|
+
self.assertTrue(all(Path(path).parent == cache for path in staged_paths))
|
|
395
|
+
if cache.exists():
|
|
396
|
+
self.assertEqual(list(cache.iterdir()), [])
|
|
397
|
+
|
|
398
|
+
def test_netcdf_input_auto_order_orphan_cleanup_and_failed_batch_cleanup(self):
|
|
399
|
+
"""AUTO 依序降级,孤儿识别和批量失败均只清理本模块候选。"""
|
|
400
|
+
import Alaea.netcdf_input as netcdf_input
|
|
401
|
+
|
|
402
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
403
|
+
root = Path(directory)
|
|
404
|
+
source = root / 'source.nc'
|
|
405
|
+
cache = root / 'cache'
|
|
406
|
+
cache.mkdir()
|
|
407
|
+
xr.Dataset({'value': ('sample', np.asarray([1.0], dtype=np.float32))}).to_netcdf(source)
|
|
408
|
+
sentinel = mock.Mock()
|
|
409
|
+
first = mock.Mock(side_effect=OSError('unsupported'))
|
|
410
|
+
second = mock.Mock(return_value=sentinel)
|
|
411
|
+
with mock.patch.dict(INPUT_METHOD_OPENERS, {'O_TMPFILE': first, 'NAMED_FILE': second}, clear=False):
|
|
412
|
+
self.assertIs(open_netcdf_input(source, storage_mode='AUTO', auto_methods=('O_TMPFILE', 'NAMED_FILE'), local_directory=cache), sentinel)
|
|
413
|
+
first.assert_called_once()
|
|
414
|
+
second.assert_called_once()
|
|
415
|
+
|
|
416
|
+
orphan = cache / f'{STAGED_INPUT_FILE_PREFIX}99999999-orphan.nc'
|
|
417
|
+
legacy_orphan = cache / 'legacy-netcdf-input-99999999-orphan.nc'
|
|
418
|
+
unrelated = cache / 'unrelated.nc'
|
|
419
|
+
orphan.write_bytes(b'orphan')
|
|
420
|
+
legacy_orphan.write_bytes(b'orphan')
|
|
421
|
+
unrelated.write_bytes(b'keep')
|
|
422
|
+
real_exists = os.path.exists
|
|
423
|
+
with mock.patch('Alaea.netcdf_input.os.path.isdir', return_value=True), mock.patch('Alaea.netcdf_input.os.path.exists', side_effect=lambda path: False if path == '/proc/99999999' else real_exists(path)):
|
|
424
|
+
self.assertEqual(cleanup_orphaned_input_files(cache, prefixes=(STAGED_INPUT_FILE_PREFIX, 'legacy-netcdf-input-')), 2)
|
|
425
|
+
self.assertFalse(orphan.exists())
|
|
426
|
+
self.assertFalse(legacy_orphan.exists())
|
|
427
|
+
self.assertEqual(unrelated.read_bytes(), b'keep')
|
|
428
|
+
|
|
429
|
+
calls = 0
|
|
430
|
+
original_stage = netcdf_input._stage_named_source
|
|
431
|
+
|
|
432
|
+
def fail_second(path, local_directory):
|
|
433
|
+
"""首个候选成功后模拟第二个源在复制期间失败。"""
|
|
434
|
+
nonlocal calls
|
|
435
|
+
calls += 1
|
|
436
|
+
if calls == 2:
|
|
437
|
+
raise RuntimeError('source changed')
|
|
438
|
+
return original_stage(path, local_directory)
|
|
439
|
+
|
|
440
|
+
with mock.patch('Alaea.netcdf_input._stage_named_source', side_effect=fail_second):
|
|
441
|
+
with self.assertRaisesRegex(RuntimeError, 'source changed'):
|
|
442
|
+
with stage_netcdf_inputs((source, source), storage_mode='NAMED_FILE', local_directory=cache, workers=1):
|
|
443
|
+
pass
|
|
444
|
+
self.assertEqual([path.name for path in cache.iterdir()], ['unrelated.nc'])
|
|
445
|
+
|
|
356
446
|
@unittest.skipUnless(DASK_AVAILABLE, 'dask is optional')
|
|
357
447
|
def test_standard_encoding_helpers_keep_dask_dtype_lazy_and_reduce_for_packing(self):
|
|
358
448
|
"""Dask dtype 推断不应读取数据,packing 只需得到正确范围。"""
|
alaea-2.6.2/.VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
v2.6.2
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|