gdexws 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.
- gdexws-0.1.0/LICENSE +21 -0
- gdexws-0.1.0/PKG-INFO +61 -0
- gdexws-0.1.0/README.md +45 -0
- gdexws-0.1.0/__init__.py +1 -0
- gdexws-0.1.0/composers/__init__.py +0 -0
- gdexws-0.1.0/composers/transform.py +128 -0
- gdexws-0.1.0/gdexws.egg-info/PKG-INFO +61 -0
- gdexws-0.1.0/gdexws.egg-info/SOURCES.txt +31 -0
- gdexws-0.1.0/gdexws.egg-info/dependency_links.txt +1 -0
- gdexws-0.1.0/gdexws.egg-info/entry_points.txt +4 -0
- gdexws-0.1.0/gdexws.egg-info/requires.txt +2 -0
- gdexws-0.1.0/gdexws.egg-info/scm_file_list.json +18 -0
- gdexws-0.1.0/gdexws.egg-info/top_level.txt +1 -0
- gdexws-0.1.0/pbs/transform.pbs +49 -0
- gdexws-0.1.0/pyproject.toml +41 -0
- gdexws-0.1.0/setup.cfg +4 -0
- gdexws-0.1.0/tools/__init__.py +0 -0
- gdexws-0.1.0/tools/add_global_meta.py +45 -0
- gdexws-0.1.0/tools/create_exchange_dir.py +267 -0
- gdexws-0.1.0/utils/__init__.py +4 -0
- gdexws-0.1.0/utils/file_validation.py +47 -0
- gdexws-0.1.0/utils/logging.py +82 -0
- gdexws-0.1.0/utils/parse_payload.py +175 -0
gdexws-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 University Corporation for Atmospheric Research (UCAR)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gdexws-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gdexws
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GDEX web service tools for data processing and management
|
|
5
|
+
Author: GDEX Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/NCAR/gdex-web-services
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: netCDF4>=1.5.0
|
|
14
|
+
Requires-Dist: httpx>=0.24.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# gdexws
|
|
18
|
+
|
|
19
|
+
Service tools for data processing and management.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Install the package in development mode:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install normally:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## CLI Commands
|
|
36
|
+
|
|
37
|
+
### add-global-meta
|
|
38
|
+
|
|
39
|
+
Add a global attribute to a netCDF file.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
add-global-meta -file /path/to/file.nc -global_attr_name "attribute_name" -global_attr_value "attribute_value"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### create-exchange-dir
|
|
46
|
+
|
|
47
|
+
Create a directory directly under `/glade/campaign/collections/gdex/data/exchange` and grant an HPC user read/write access (`rwx`, plus a default ACL so new contents inherit it) via POSIX ACLs. Requires `setfacl`/`getfacl`.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
create-exchange-dir <username> <dirname>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The directory name must be a single path component (`[A-Za-z0-9._-]`, starting with a letter or digit), the user must exist on the machine (and not be uid 0), and the command fails if the directory already exists. If the ACL cannot be applied and verified, the new directory is removed.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
To install with development dependencies:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install -e ".[dev]"
|
|
61
|
+
```
|
gdexws-0.1.0/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# gdexws
|
|
2
|
+
|
|
3
|
+
Service tools for data processing and management.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the package in development mode:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install normally:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI Commands
|
|
20
|
+
|
|
21
|
+
### add-global-meta
|
|
22
|
+
|
|
23
|
+
Add a global attribute to a netCDF file.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
add-global-meta -file /path/to/file.nc -global_attr_name "attribute_name" -global_attr_value "attribute_value"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### create-exchange-dir
|
|
30
|
+
|
|
31
|
+
Create a directory directly under `/glade/campaign/collections/gdex/data/exchange` and grant an HPC user read/write access (`rwx`, plus a default ACL so new contents inherit it) via POSIX ACLs. Requires `setfacl`/`getfacl`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
create-exchange-dir <username> <dirname>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The directory name must be a single path component (`[A-Za-z0-9._-]`, starting with a letter or digit), the user must exist on the machine (and not be uid 0), and the command fails if the directory already exists. If the ACL cannot be applied and verified, the new directory is removed.
|
|
38
|
+
|
|
39
|
+
## Development
|
|
40
|
+
|
|
41
|
+
To install with development dependencies:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e ".[dev]"
|
|
45
|
+
```
|
gdexws-0.1.0/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Transform script that executes commands on files based on a payload JSON."""
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
from gdexws.utils import load_payload, build_command, execute_command, service_log
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def transform(payload_path: str) -> None:
|
|
8
|
+
"""
|
|
9
|
+
Main transform function that processes files
|
|
10
|
+
based on the commands listed in the payload configuration.
|
|
11
|
+
|
|
12
|
+
payload format:
|
|
13
|
+
{
|
|
14
|
+
"Files": ["Web-services/test.nc"],
|
|
15
|
+
"Commands": [
|
|
16
|
+
{
|
|
17
|
+
"command": "add_global_meta",
|
|
18
|
+
"global-attr-name": "gdex_dsid",
|
|
19
|
+
"global-attr-value": "dPayLoadTest",
|
|
20
|
+
"debug": true
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
payload_path : str
|
|
29
|
+
Path to the payload JSON file
|
|
30
|
+
"""
|
|
31
|
+
# Load the payload JSON
|
|
32
|
+
try:
|
|
33
|
+
payload = load_payload(payload_path)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
service_log(
|
|
36
|
+
command_name="transform",
|
|
37
|
+
level="ERROR",
|
|
38
|
+
process_message="Payload file not found",
|
|
39
|
+
payload_path=payload_path
|
|
40
|
+
)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
service_log(
|
|
44
|
+
command_name="transform",
|
|
45
|
+
level="ERROR",
|
|
46
|
+
process_message="Failed to load payload",
|
|
47
|
+
error=str(e)
|
|
48
|
+
)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
# Parse files and commands from the payload
|
|
52
|
+
files = payload.get("Files", [])
|
|
53
|
+
commands = payload.get("Commands", [])
|
|
54
|
+
|
|
55
|
+
if not files:
|
|
56
|
+
service_log(
|
|
57
|
+
command_name="transform",
|
|
58
|
+
level="ERROR",
|
|
59
|
+
process_message="No files specified in payload"
|
|
60
|
+
)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
if not commands:
|
|
64
|
+
service_log(
|
|
65
|
+
command_name="transform",
|
|
66
|
+
level="ERROR",
|
|
67
|
+
process_message="No commands specified in payload"
|
|
68
|
+
)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
# Start processing each file and loop through the commands
|
|
72
|
+
for file_path in files:
|
|
73
|
+
service_log(
|
|
74
|
+
command_name="transform",
|
|
75
|
+
level="INFO",
|
|
76
|
+
process_message="Processing file",
|
|
77
|
+
file_path=file_path
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
for command_spec in commands:
|
|
81
|
+
# Extract command name and parameters
|
|
82
|
+
if "command" not in command_spec:
|
|
83
|
+
service_log(
|
|
84
|
+
command_name="transform",
|
|
85
|
+
level="ERROR",
|
|
86
|
+
process_message=f"Command spec missing 'command' key: {command_spec}"
|
|
87
|
+
)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
command_name = command_spec["command"]
|
|
91
|
+
|
|
92
|
+
# Convert underscore to hyphen for CLI command name
|
|
93
|
+
cli_command = command_name.replace("_", "-")
|
|
94
|
+
|
|
95
|
+
# Build the full command
|
|
96
|
+
cmd = build_command(cli_command, command_spec, file_path)
|
|
97
|
+
|
|
98
|
+
# Execute the command
|
|
99
|
+
return_code = execute_command(cmd)
|
|
100
|
+
|
|
101
|
+
if return_code != 0:
|
|
102
|
+
service_log(
|
|
103
|
+
command_name="transform",
|
|
104
|
+
level="ERROR",
|
|
105
|
+
process_message=f"Command failed with return code {return_code}",
|
|
106
|
+
command=" ".join(cmd)
|
|
107
|
+
)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
"""CLI entry point."""
|
|
112
|
+
parser = argparse.ArgumentParser(
|
|
113
|
+
description="Execute commands on files based on a payload JSON"
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument("-p", "--payload", help="Path to the payload JSON file", required=True)
|
|
116
|
+
args = parser.parse_args()
|
|
117
|
+
|
|
118
|
+
service_log(
|
|
119
|
+
command_name="transform",
|
|
120
|
+
level="INFO",
|
|
121
|
+
process_message="composer.transform started",
|
|
122
|
+
payload_path=args.payload
|
|
123
|
+
)
|
|
124
|
+
transform(args.payload)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gdexws
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GDEX web service tools for data processing and management
|
|
5
|
+
Author: GDEX Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/NCAR/gdex-web-services
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: netCDF4>=1.5.0
|
|
14
|
+
Requires-Dist: httpx>=0.24.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# gdexws
|
|
18
|
+
|
|
19
|
+
Service tools for data processing and management.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Install the package in development mode:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install normally:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## CLI Commands
|
|
36
|
+
|
|
37
|
+
### add-global-meta
|
|
38
|
+
|
|
39
|
+
Add a global attribute to a netCDF file.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
add-global-meta -file /path/to/file.nc -global_attr_name "attribute_name" -global_attr_value "attribute_value"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### create-exchange-dir
|
|
46
|
+
|
|
47
|
+
Create a directory directly under `/glade/campaign/collections/gdex/data/exchange` and grant an HPC user read/write access (`rwx`, plus a default ACL so new contents inherit it) via POSIX ACLs. Requires `setfacl`/`getfacl`.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
create-exchange-dir <username> <dirname>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The directory name must be a single path component (`[A-Za-z0-9._-]`, starting with a letter or digit), the user must exist on the machine (and not be uid 0), and the command fails if the directory already exists. If the ACL cannot be applied and verified, the new directory is removed.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
To install with development dependencies:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install -e ".[dev]"
|
|
61
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
__init__.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
./__init__.py
|
|
6
|
+
./composers/__init__.py
|
|
7
|
+
./composers/transform.py
|
|
8
|
+
./tools/__init__.py
|
|
9
|
+
./tools/add_global_meta.py
|
|
10
|
+
./tools/create_exchange_dir.py
|
|
11
|
+
./utils/__init__.py
|
|
12
|
+
./utils/file_validation.py
|
|
13
|
+
./utils/logging.py
|
|
14
|
+
./utils/parse_payload.py
|
|
15
|
+
composers/__init__.py
|
|
16
|
+
composers/transform.py
|
|
17
|
+
gdexws.egg-info/PKG-INFO
|
|
18
|
+
gdexws.egg-info/SOURCES.txt
|
|
19
|
+
gdexws.egg-info/dependency_links.txt
|
|
20
|
+
gdexws.egg-info/entry_points.txt
|
|
21
|
+
gdexws.egg-info/requires.txt
|
|
22
|
+
gdexws.egg-info/scm_file_list.json
|
|
23
|
+
gdexws.egg-info/top_level.txt
|
|
24
|
+
pbs/transform.pbs
|
|
25
|
+
tools/__init__.py
|
|
26
|
+
tools/add_global_meta.py
|
|
27
|
+
tools/create_exchange_dir.py
|
|
28
|
+
utils/__init__.py
|
|
29
|
+
utils/file_validation.py
|
|
30
|
+
utils/logging.py
|
|
31
|
+
utils/parse_payload.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
"LICENSE",
|
|
4
|
+
"README.md",
|
|
5
|
+
"__init__.py",
|
|
6
|
+
"composers/__init__.py",
|
|
7
|
+
"composers/transform.py",
|
|
8
|
+
"pbs/transform.pbs",
|
|
9
|
+
"pyproject.toml",
|
|
10
|
+
"tools/__init__.py",
|
|
11
|
+
"tools/add_global_meta.py",
|
|
12
|
+
"tools/create_exchange_dir.py",
|
|
13
|
+
"utils/__init__.py",
|
|
14
|
+
"utils/file_validation.py",
|
|
15
|
+
"utils/logging.py",
|
|
16
|
+
"utils/parse_payload.py"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gdexws
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash -l
|
|
2
|
+
|
|
3
|
+
### Job Name
|
|
4
|
+
#PBS -N gdex-web-service-transform
|
|
5
|
+
### Charging account
|
|
6
|
+
#PBS -A P43713000
|
|
7
|
+
### Request one chunk of resources with 1 CPU and 10 GB of memory
|
|
8
|
+
#PBS -l select=1:ncpus=1:mem=1GB
|
|
9
|
+
### Allow job to run up to 1 hour 30 minutes
|
|
10
|
+
#PBS -l walltime=00:05:00
|
|
11
|
+
### Route the job to the casper queue
|
|
12
|
+
#PBS -q gdex
|
|
13
|
+
### Join output and error streams into single file
|
|
14
|
+
#PBS -j oe
|
|
15
|
+
|
|
16
|
+
# Environment Management
|
|
17
|
+
module --force purge
|
|
18
|
+
# module load ncarenv/24.12
|
|
19
|
+
# module load conda
|
|
20
|
+
# conda activate /glade/work/gdexdata/conda-envs/pg-gdex
|
|
21
|
+
source /glade/u/home/chiaweih/gdex-web-services/test-gdexws-env/bin/activate
|
|
22
|
+
|
|
23
|
+
### Parse positional arguments
|
|
24
|
+
PAYLOAD="$1"
|
|
25
|
+
# PAYLOAD="/glade/u/home/chiaweih/gdex-web-services/tests/test.payload.json"
|
|
26
|
+
# CINDEX="1"
|
|
27
|
+
|
|
28
|
+
### SETUP .jsonl
|
|
29
|
+
jsonl_dir="/glade/campaign/collections/gdex/data/exchange/Web-services/"
|
|
30
|
+
output_jsonl="$jsonl_dir/$CINDEX.gdexws.jsonl"
|
|
31
|
+
|
|
32
|
+
# Clear or create file
|
|
33
|
+
> "$output_jsonl"
|
|
34
|
+
|
|
35
|
+
# Shell start message
|
|
36
|
+
time_iso=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
|
37
|
+
echo '{"command": "pbs", "time_of_process": "'"$time_iso"'", "level": "INFO", "process_message": "PBS job started"}' >> "$output_jsonl"
|
|
38
|
+
|
|
39
|
+
# Execute transform (the payload can be Boreas link)
|
|
40
|
+
transform -p "$PAYLOAD" >> "$output_jsonl"
|
|
41
|
+
EXIT_CODE=$?
|
|
42
|
+
|
|
43
|
+
# Shell end message
|
|
44
|
+
time_iso=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
|
45
|
+
if [ $EXIT_CODE -eq 0 ]; then
|
|
46
|
+
echo '{"command": "pbs", "time_of_process": "'"$time_iso"'", "level": "INFO", "process_message": "PBS job completed"}' >> "$output_jsonl"
|
|
47
|
+
else
|
|
48
|
+
echo '{"command": "pbs", "time_of_process": "'"$time_iso"'", "level": "ERROR", "process_message": "PBS job failed", "exit_code": '"$EXIT_CODE"'}' >> "$output_jsonl"
|
|
49
|
+
fi
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gdexws"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "GDEX web service tools for data processing and management"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "GDEX Team"},
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
"netCDF4>=1.5.0",
|
|
22
|
+
"httpx>=0.24.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
add-global-meta = "gdexws.tools.add_global_meta:main"
|
|
27
|
+
create-exchange-dir = "gdexws.tools.create_exchange_dir:main"
|
|
28
|
+
transform = "gdexws.composers.transform:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/NCAR/gdex-web-services"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
packages = ["gdexws", "gdexws.tools", "gdexws.utils", "gdexws.composers"]
|
|
35
|
+
package-dir = {"gdexws" = "."}
|
|
36
|
+
|
|
37
|
+
[tool.setuptools_scm]
|
|
38
|
+
root = ".."
|
|
39
|
+
local_scheme = "no-local-version"
|
|
40
|
+
fallback_version = "0.1.0"
|
|
41
|
+
|
gdexws-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Add global attribute to a netCDF file."""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import argparse
|
|
5
|
+
import netCDF4
|
|
6
|
+
from gdexws.utils import service_log
|
|
7
|
+
from gdexws.utils.file_validation import relpath_validate
|
|
8
|
+
|
|
9
|
+
def add_global_meta(file_path, key, value, debug=False):
|
|
10
|
+
"""Add a global attribute to a netCDF file."""
|
|
11
|
+
full_path = relpath_validate([file_path])[0] # Validate the file path
|
|
12
|
+
if debug:
|
|
13
|
+
service_log("add-global-meta", "DEBUG", f"Adding global attribute {key}={value} to {file_path}")
|
|
14
|
+
|
|
15
|
+
with netCDF4.Dataset(full_path, mode="r+") as ds:
|
|
16
|
+
ds.setncattr(key, value)
|
|
17
|
+
|
|
18
|
+
for i in range(6):
|
|
19
|
+
time.sleep(10)
|
|
20
|
+
if debug:
|
|
21
|
+
service_log("add-global-meta", "DEBUG" , "Loop iteration", iteration=i)
|
|
22
|
+
|
|
23
|
+
if debug:
|
|
24
|
+
service_log("add-global-meta", "DEBUG", "Process completed")
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
"""CLI entry point."""
|
|
28
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
script_name = os.path.basename(__file__)
|
|
30
|
+
|
|
31
|
+
parser = argparse.ArgumentParser(description="Add a global attribute to a netCDF file")
|
|
32
|
+
parser.add_argument("-f", "--file", help="Path to the netCDF file", required=True)
|
|
33
|
+
parser.add_argument("-n", "--global-attr-name", help="Name of the global attribute to add", required=True)
|
|
34
|
+
parser.add_argument("-v", "--global-attr-value", help="Value of the global attribute to add", required=True)
|
|
35
|
+
parser.add_argument("-d", "--debug", action="store_true", help="Enable debug mode")
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
if args.debug:
|
|
39
|
+
service_log("add-global-meta", "DEBUG", "Beginning process")
|
|
40
|
+
service_log("add-global-meta", "DEBUG", "Script info", script_dir=script_dir, script_name=script_name)
|
|
41
|
+
|
|
42
|
+
add_global_meta(args.file, args.global_attr_name, args.global_attr_value, args.debug)
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Create a directory under the GDEX exchange area and grant a user read/write access via POSIX ACLs."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import pwd
|
|
5
|
+
import shutil
|
|
6
|
+
import argparse
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from gdexws.utils import service_log
|
|
11
|
+
|
|
12
|
+
COMMAND_NAME = "create-exchange-dir"
|
|
13
|
+
|
|
14
|
+
# The only location under which this tool will ever create a directory.
|
|
15
|
+
EXCHANGE_DIR = "/glade/campaign/collections/gdex/data/exchange"
|
|
16
|
+
|
|
17
|
+
# A single, plain path component: must start with an alphanumeric (no hidden
|
|
18
|
+
# dirs, no leading '-'), then only alphanumerics, '.', '_' or '-'.
|
|
19
|
+
_DIRNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
20
|
+
_DIRNAME_MAX_LEN = 255
|
|
21
|
+
|
|
22
|
+
# POSIX portable username: lowercase letters, digits, '_' and '-', max 32 chars.
|
|
23
|
+
_USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
|
24
|
+
|
|
25
|
+
# rwx: 'x' is required on a directory for the user to traverse into it.
|
|
26
|
+
_ACL_PERMS = "rwx"
|
|
27
|
+
_DIR_MODE = 0o770
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExchangeDirError(Exception):
|
|
31
|
+
"""Raised when the exchange directory cannot be safely created or shared."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_dirname(dirname):
|
|
35
|
+
"""Validate that ``dirname`` is a single, safe path component.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
dirname : str
|
|
40
|
+
Requested directory name.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
str
|
|
45
|
+
The validated directory name, unchanged.
|
|
46
|
+
|
|
47
|
+
Raises
|
|
48
|
+
------
|
|
49
|
+
ExchangeDirError
|
|
50
|
+
If the name is empty, too long, contains a path separator, NUL, or
|
|
51
|
+
characters outside ``[A-Za-z0-9._-]``, starts with a non-alphanumeric
|
|
52
|
+
character, or is ``.``/``..``.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(dirname, str) or not dirname:
|
|
55
|
+
raise ExchangeDirError("Directory name must be a non-empty string")
|
|
56
|
+
if dirname in (".", ".."):
|
|
57
|
+
raise ExchangeDirError(f"Invalid directory name: '{dirname}'")
|
|
58
|
+
if len(dirname) > _DIRNAME_MAX_LEN:
|
|
59
|
+
raise ExchangeDirError(f"Directory name exceeds {_DIRNAME_MAX_LEN} characters")
|
|
60
|
+
if "/" in dirname or "\x00" in dirname or os.sep in dirname:
|
|
61
|
+
raise ExchangeDirError("Directory name must be a single path component (no '/' allowed)")
|
|
62
|
+
if not _DIRNAME_RE.fullmatch(dirname):
|
|
63
|
+
raise ExchangeDirError(
|
|
64
|
+
"Directory name must start with a letter or digit and contain only "
|
|
65
|
+
"letters, digits, '.', '_' and '-'"
|
|
66
|
+
)
|
|
67
|
+
return dirname
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_username(username):
|
|
71
|
+
"""Validate the username format and verify the account exists on this machine.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
username : str
|
|
76
|
+
HPC username.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
str
|
|
81
|
+
The validated username, unchanged.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
ExchangeDirError
|
|
86
|
+
If the username is malformed, does not exist in the system user
|
|
87
|
+
database, or resolves to root (uid 0).
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(username, str) or not _USERNAME_RE.fullmatch(username):
|
|
90
|
+
raise ExchangeDirError(f"Invalid username format: '{username}'")
|
|
91
|
+
try:
|
|
92
|
+
entry = pwd.getpwnam(username)
|
|
93
|
+
except KeyError:
|
|
94
|
+
raise ExchangeDirError(f"User '{username}' does not exist on this machine")
|
|
95
|
+
if entry.pw_uid == 0:
|
|
96
|
+
raise ExchangeDirError("Refusing to grant an ACL to uid 0")
|
|
97
|
+
return username
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_exchange_dir(exchange_dir):
|
|
101
|
+
"""Return the real path of the exchange directory, verifying it is a directory.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
exchange_dir : str
|
|
106
|
+
Configured exchange directory.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
str
|
|
111
|
+
Fully resolved (symlink-free) absolute path.
|
|
112
|
+
|
|
113
|
+
Raises
|
|
114
|
+
------
|
|
115
|
+
ExchangeDirError
|
|
116
|
+
If the path does not exist or is not a directory.
|
|
117
|
+
"""
|
|
118
|
+
base = os.path.realpath(exchange_dir)
|
|
119
|
+
if not os.path.isdir(base):
|
|
120
|
+
raise ExchangeDirError(f"Exchange directory does not exist: {exchange_dir}")
|
|
121
|
+
return base
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run_acl_tool(args):
|
|
125
|
+
"""Run an ACL tool with a fixed argument list (no shell).
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
args : list of str
|
|
130
|
+
Command and arguments; ``args[0]`` is looked up on ``PATH``.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
str
|
|
135
|
+
The command's stdout.
|
|
136
|
+
|
|
137
|
+
Raises
|
|
138
|
+
------
|
|
139
|
+
ExchangeDirError
|
|
140
|
+
If the tool is missing or exits non-zero.
|
|
141
|
+
"""
|
|
142
|
+
exe = shutil.which(args[0])
|
|
143
|
+
if exe is None:
|
|
144
|
+
raise ExchangeDirError(f"Required tool '{args[0]}' was not found on PATH")
|
|
145
|
+
result = subprocess.run([exe] + args[1:], capture_output=True, text=True, check=False)
|
|
146
|
+
if result.returncode != 0:
|
|
147
|
+
raise ExchangeDirError(f"{args[0]} failed: {result.stderr.strip()}")
|
|
148
|
+
return result.stdout
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _grant_acl(path, username):
|
|
152
|
+
"""Grant ``username`` rwx on ``path`` (access and default ACL) and verify it took effect.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
path : str
|
|
157
|
+
Directory to modify.
|
|
158
|
+
username : str
|
|
159
|
+
User to add to the ACL.
|
|
160
|
+
|
|
161
|
+
Raises
|
|
162
|
+
------
|
|
163
|
+
ExchangeDirError
|
|
164
|
+
If setfacl fails or the entries cannot be confirmed with getfacl.
|
|
165
|
+
"""
|
|
166
|
+
# -P: never follow symlinks; '--' ends option parsing so the path cannot be read as a flag.
|
|
167
|
+
_run_acl_tool([
|
|
168
|
+
"setfacl", "-P",
|
|
169
|
+
"-m", f"u:{username}:{_ACL_PERMS},d:u:{username}:{_ACL_PERMS}",
|
|
170
|
+
"--", path,
|
|
171
|
+
])
|
|
172
|
+
listing = _run_acl_tool(["getfacl", "--absolute-names", "--", path])
|
|
173
|
+
# Drop trailing "#effective:..." comments before comparing entries.
|
|
174
|
+
entries = {line.split("#")[0].strip() for line in listing.splitlines()}
|
|
175
|
+
expected = {f"user:{username}:{_ACL_PERMS}", f"default:user:{username}:{_ACL_PERMS}"}
|
|
176
|
+
if not expected <= entries:
|
|
177
|
+
raise ExchangeDirError(f"ACL verification failed for '{username}' on {path}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_exchange_dir(username, dirname, exchange_dir=EXCHANGE_DIR, debug=False):
|
|
181
|
+
"""Create ``dirname`` under the exchange directory and give ``username`` rwx via ACL.
|
|
182
|
+
|
|
183
|
+
The directory must not already exist. If the ACL step fails, the newly
|
|
184
|
+
created (still empty) directory is removed.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
username : str
|
|
189
|
+
HPC username that must exist on this machine.
|
|
190
|
+
dirname : str
|
|
191
|
+
Name of the single directory to create directly under ``exchange_dir``.
|
|
192
|
+
exchange_dir : str
|
|
193
|
+
Root under which directories may be created. Default: ``EXCHANGE_DIR``.
|
|
194
|
+
debug : bool
|
|
195
|
+
Emit DEBUG log lines.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
str
|
|
200
|
+
Absolute path of the created directory.
|
|
201
|
+
|
|
202
|
+
Raises
|
|
203
|
+
------
|
|
204
|
+
ExchangeDirError
|
|
205
|
+
On any validation, creation, or ACL failure.
|
|
206
|
+
"""
|
|
207
|
+
validate_dirname(dirname)
|
|
208
|
+
validate_username(username)
|
|
209
|
+
base = _resolve_exchange_dir(exchange_dir)
|
|
210
|
+
target = os.path.join(base, dirname)
|
|
211
|
+
|
|
212
|
+
# Defense in depth: the lexical join must still sit directly under base.
|
|
213
|
+
if os.path.dirname(os.path.normpath(target)) != base:
|
|
214
|
+
raise ExchangeDirError("Target path is not directly inside the exchange directory")
|
|
215
|
+
if os.path.lexists(target):
|
|
216
|
+
raise ExchangeDirError(f"'{dirname}' already exists in the exchange directory")
|
|
217
|
+
|
|
218
|
+
if debug:
|
|
219
|
+
service_log(COMMAND_NAME, "DEBUG", "Creating directory", path=target, username=username)
|
|
220
|
+
|
|
221
|
+
# mkdir relative to an open fd on the base dir: atomic, fails with EEXIST if
|
|
222
|
+
# anything (including a symlink) already occupies the name, and is not
|
|
223
|
+
# affected by the base path being swapped after the checks above.
|
|
224
|
+
base_fd = os.open(base, os.O_RDONLY | os.O_DIRECTORY)
|
|
225
|
+
try:
|
|
226
|
+
try:
|
|
227
|
+
os.mkdir(dirname, mode=_DIR_MODE, dir_fd=base_fd)
|
|
228
|
+
except FileExistsError:
|
|
229
|
+
raise ExchangeDirError(f"'{dirname}' already exists in the exchange directory")
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
raise ExchangeDirError(f"Could not create directory: {exc}")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
_grant_acl(target, username)
|
|
235
|
+
except ExchangeDirError:
|
|
236
|
+
try:
|
|
237
|
+
os.rmdir(dirname, dir_fd=base_fd)
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
service_log(COMMAND_NAME, "WARNING", "Could not remove directory after ACL failure",
|
|
240
|
+
path=target, error=str(exc))
|
|
241
|
+
raise
|
|
242
|
+
finally:
|
|
243
|
+
os.close(base_fd)
|
|
244
|
+
|
|
245
|
+
service_log(COMMAND_NAME, "INFO", "Created directory and granted access",
|
|
246
|
+
path=target, username=username, permissions=_ACL_PERMS)
|
|
247
|
+
return target
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main():
|
|
251
|
+
"""CLI entry point."""
|
|
252
|
+
parser = argparse.ArgumentParser(
|
|
253
|
+
description=f"Create a directory under {EXCHANGE_DIR} and grant a user read/write access via ACL"
|
|
254
|
+
)
|
|
255
|
+
parser.add_argument("username", help="HPC username to grant read/write access")
|
|
256
|
+
parser.add_argument("dirname", help="Name of the directory to create (a single path component)")
|
|
257
|
+
parser.add_argument("-d", "--debug", action="store_true", help="Enable debug mode")
|
|
258
|
+
args = parser.parse_args()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
create_exchange_dir(args.username, args.dirname, debug=args.debug)
|
|
262
|
+
except ExchangeDirError as exc:
|
|
263
|
+
service_log(COMMAND_NAME, "ERROR", str(exc), username=args.username, dirname=args.dirname)
|
|
264
|
+
sys.exit(1)
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Utility functions for file path validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Union, Dict, Any
|
|
5
|
+
|
|
6
|
+
from gdexws.utils import service_log
|
|
7
|
+
|
|
8
|
+
def relpath_validate(
|
|
9
|
+
file_paths: List[str],
|
|
10
|
+
base_dir: str = '/glade/campaign/collections/gdex/data/exchange/'
|
|
11
|
+
) -> Union[bool, Dict[str, Any]]:
|
|
12
|
+
"""Validate that all relative file paths are within the base directory.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
file_paths : List[str]
|
|
17
|
+
List of relative file paths to validate.
|
|
18
|
+
base_dir : str
|
|
19
|
+
Base directory that all paths must be within.
|
|
20
|
+
Default: '/glade/campaign/collections/gdex/data/exchange/'
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
bool or dict
|
|
25
|
+
Returns True if all paths are valid.
|
|
26
|
+
Returns standardized dscheck_json error response if any path is invalid.
|
|
27
|
+
|
|
28
|
+
Examples
|
|
29
|
+
--------
|
|
30
|
+
>>> result = relpath_validate(["file1.nc", "file2.nc"])
|
|
31
|
+
>>> if result is True:
|
|
32
|
+
... # Process files
|
|
33
|
+
... else:
|
|
34
|
+
... # result is an error dict, return to user
|
|
35
|
+
... return result
|
|
36
|
+
"""
|
|
37
|
+
base_path = Path(base_dir).resolve()
|
|
38
|
+
full_paths = []
|
|
39
|
+
for file_path in file_paths:
|
|
40
|
+
try:
|
|
41
|
+
full_path = (base_path / file_path).resolve()
|
|
42
|
+
# This will raise ValueError if path escapes base directory
|
|
43
|
+
full_path.relative_to(base_path)
|
|
44
|
+
full_paths.append(str(full_path))
|
|
45
|
+
except ValueError:
|
|
46
|
+
return service_log("relpath-validate", "ERROR", f"Access denied: '{file_path}' escapes base directory")
|
|
47
|
+
return full_paths
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def log_format(
|
|
8
|
+
command_name: str,
|
|
9
|
+
level: str,
|
|
10
|
+
process_message: str,
|
|
11
|
+
**kwargs
|
|
12
|
+
) -> Dict[str, Any]:
|
|
13
|
+
"""
|
|
14
|
+
Create a structured log dictionary in the required format.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
command_name : str
|
|
19
|
+
Name of the command being executed
|
|
20
|
+
level : str
|
|
21
|
+
Log level (ERROR, WARNING, INFO, DEBUG)
|
|
22
|
+
process_message : str
|
|
23
|
+
Free format message for status tracking
|
|
24
|
+
**kwargs
|
|
25
|
+
Additional key=value pairs to include in the dict
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
dict
|
|
30
|
+
Dictionary with the exact format:
|
|
31
|
+
{
|
|
32
|
+
"command": "...",
|
|
33
|
+
"time_of_process": "...",
|
|
34
|
+
"level": "...",
|
|
35
|
+
"process_message": "...",
|
|
36
|
+
... (additional kwargs)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If level is not ERROR, WARNING, INFO, or DEBUG
|
|
43
|
+
"""
|
|
44
|
+
valid_levels = {"ERROR", "WARNING", "INFO", "DEBUG"}
|
|
45
|
+
if level not in valid_levels:
|
|
46
|
+
raise ValueError(f"level must be one of {valid_levels}, got '{level}'")
|
|
47
|
+
|
|
48
|
+
log_dict = {
|
|
49
|
+
"command": command_name,
|
|
50
|
+
"time_of_process": datetime.now().isoformat(),
|
|
51
|
+
"level": level,
|
|
52
|
+
"process_message": process_message,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
log_dict.update(kwargs)
|
|
56
|
+
|
|
57
|
+
return log_dict
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def service_log(command_name: str, level: str, process_message: str, **kwargs) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Log a message at the specified level and print it.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
command_name : str
|
|
68
|
+
Name of the command being executed
|
|
69
|
+
level : str
|
|
70
|
+
Log level (ERROR, WARNING, INFO, DEBUG)
|
|
71
|
+
process_message : str
|
|
72
|
+
Process message to log
|
|
73
|
+
**kwargs
|
|
74
|
+
Additional key=value pairs to include in the log
|
|
75
|
+
"""
|
|
76
|
+
log_dict = log_format(
|
|
77
|
+
command_name=command_name,
|
|
78
|
+
level=level,
|
|
79
|
+
process_message=process_message,
|
|
80
|
+
**kwargs
|
|
81
|
+
)
|
|
82
|
+
print(json.dumps(log_dict), file=sys.stdout, flush=True)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Utilities for parsing and executing payload configurations."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import httpx
|
|
10
|
+
except ImportError:
|
|
11
|
+
httpx = None
|
|
12
|
+
|
|
13
|
+
from .logging import service_log
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_payload(payload_path: str, timeout: float = 10.0) -> Dict[str, Any]:
|
|
17
|
+
"""
|
|
18
|
+
Load payload JSON from a file path or URL.
|
|
19
|
+
|
|
20
|
+
Accepts both local file paths (POSIX) and URLs (http/https).
|
|
21
|
+
Automatically detects the input type and loads accordingly.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
payload_path : str
|
|
26
|
+
Either a local file path (POSIX) or a URL (http/https)
|
|
27
|
+
timeout : float
|
|
28
|
+
Timeout in seconds for HTTP requests. Default: 10.0
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
dict
|
|
33
|
+
Dictionary containing payload configuration
|
|
34
|
+
|
|
35
|
+
Raises
|
|
36
|
+
------
|
|
37
|
+
FileNotFoundError
|
|
38
|
+
If the local file does not exist
|
|
39
|
+
json.JSONDecodeError
|
|
40
|
+
If the content is not valid JSON
|
|
41
|
+
httpx.RequestError
|
|
42
|
+
If the HTTP request fails
|
|
43
|
+
ImportError
|
|
44
|
+
If loading from URL but httpx is not installed
|
|
45
|
+
"""
|
|
46
|
+
# Check if it's a URL
|
|
47
|
+
if payload_path.startswith('http://') or payload_path.startswith('https://'):
|
|
48
|
+
return _load_payload_from_url(payload_path, timeout)
|
|
49
|
+
else:
|
|
50
|
+
return _load_payload_from_file(payload_path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_payload_from_file(file_path: str) -> Dict[str, Any]:
|
|
54
|
+
"""Load payload JSON from a local file.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
file_path : str
|
|
59
|
+
Path to the payload JSON file
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
dict
|
|
64
|
+
Dictionary containing payload configuration
|
|
65
|
+
|
|
66
|
+
Raises
|
|
67
|
+
------
|
|
68
|
+
FileNotFoundError
|
|
69
|
+
If the payload file does not exist
|
|
70
|
+
json.JSONDecodeError
|
|
71
|
+
If the file is not valid JSON
|
|
72
|
+
"""
|
|
73
|
+
with open(file_path, "r") as f:
|
|
74
|
+
return json.load(f)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_payload_from_url(url: str, timeout: float = 10.0) -> Dict[str, Any]:
|
|
78
|
+
"""Load payload JSON from a URL.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
url : str
|
|
83
|
+
HTTP or HTTPS URL to fetch payload JSON from
|
|
84
|
+
timeout : float
|
|
85
|
+
Timeout in seconds for the request
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
dict
|
|
90
|
+
Dictionary containing payload configuration
|
|
91
|
+
|
|
92
|
+
Raises
|
|
93
|
+
------
|
|
94
|
+
ImportError
|
|
95
|
+
If httpx is not installed
|
|
96
|
+
httpx.RequestError
|
|
97
|
+
If the HTTP request fails
|
|
98
|
+
json.JSONDecodeError
|
|
99
|
+
If the response is not valid JSON
|
|
100
|
+
"""
|
|
101
|
+
if httpx is None:
|
|
102
|
+
raise ImportError(
|
|
103
|
+
"httpx is required for loading payload from URLs. "
|
|
104
|
+
"Install with: pip install httpx"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
response = httpx.get(url, timeout=timeout, follow_redirects=True)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
return response.json()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_command(command_name: str, params: Dict[str, Any], file_path: str) -> List[str]:
|
|
113
|
+
"""
|
|
114
|
+
Build a CLI command with arguments.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
command_name : str
|
|
119
|
+
Name of the CLI command
|
|
120
|
+
params : dict
|
|
121
|
+
Dictionary of parameters (excluding "command" key)
|
|
122
|
+
file_path : str
|
|
123
|
+
Path to the file to process
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
list
|
|
128
|
+
List of command and arguments ready for subprocess
|
|
129
|
+
"""
|
|
130
|
+
cmd = [command_name]
|
|
131
|
+
|
|
132
|
+
# Add file argument
|
|
133
|
+
cmd.extend(["-f", file_path])
|
|
134
|
+
|
|
135
|
+
# Add other parameters
|
|
136
|
+
for key, value in params.items():
|
|
137
|
+
# Skip the command key itself
|
|
138
|
+
if key == "command":
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Convert underscore to hyphen for CLI arguments
|
|
142
|
+
cli_key = f"--{key.replace('_', '-')}"
|
|
143
|
+
|
|
144
|
+
# Handle boolean flags
|
|
145
|
+
if isinstance(value, bool):
|
|
146
|
+
if value:
|
|
147
|
+
cmd.append(cli_key)
|
|
148
|
+
else:
|
|
149
|
+
cmd.extend([cli_key, str(value)])
|
|
150
|
+
|
|
151
|
+
return cmd
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def execute_command(cmd: List[str]) -> int:
|
|
155
|
+
"""
|
|
156
|
+
Execute a CLI command.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
cmd : list
|
|
161
|
+
List of command and arguments
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
int
|
|
166
|
+
Return code from subprocess
|
|
167
|
+
"""
|
|
168
|
+
service_log(
|
|
169
|
+
command_name="execute-command",
|
|
170
|
+
level="INFO",
|
|
171
|
+
process_message="Executing command",
|
|
172
|
+
command=" ".join(cmd)
|
|
173
|
+
)
|
|
174
|
+
result = subprocess.run(cmd, capture_output=False, check=True)
|
|
175
|
+
return result.returncode
|