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 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
+ ```
@@ -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,4 @@
1
+ [console_scripts]
2
+ add-global-meta = gdexws.tools.add_global_meta:main
3
+ create-exchange-dir = gdexws.tools.create_exchange_dir:main
4
+ transform = gdexws.composers.transform:main
@@ -0,0 +1,2 @@
1
+ netCDF4>=1.5.0
2
+ httpx>=0.24.0
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,4 @@
1
+ from .logging import service_log
2
+ from .parse_payload import load_payload, build_command, execute_command
3
+
4
+ __all__ = ["service_log", "load_payload", "build_command", "execute_command"]
@@ -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