groundhog-hpc 0.5.6__py3-none-any.whl → 0.7.0__py3-none-any.whl

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.
groundhog_hpc/__init__.py CHANGED
@@ -32,6 +32,7 @@ import os
32
32
 
33
33
  from groundhog_hpc.decorators import function, harness, method
34
34
  from groundhog_hpc.import_hook import install_import_hook
35
+ from groundhog_hpc.logging import setup_logging
35
36
  from groundhog_hpc.utils import mark_import_safe
36
37
 
37
38
  try:
@@ -41,5 +42,8 @@ except importlib.metadata.PackageNotFoundError:
41
42
 
42
43
  __all__ = ["function", "harness", "method", "mark_import_safe", "__version__"]
43
44
 
45
+ # Configure logging on import
46
+ setup_logging()
47
+
44
48
  if not os.environ.get("GROUNDHOG_NO_IMPORT_HOOK"):
45
49
  install_import_hook()
groundhog_hpc/app/add.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Add command for managing PEP 723 script dependencies."""
2
2
 
3
+ import os
3
4
  import subprocess
4
5
  from pathlib import Path
5
6
 
@@ -11,9 +12,23 @@ from groundhog_hpc.app.utils import (
11
12
  normalize_python_version_with_uv,
12
13
  update_requires_python,
13
14
  )
15
+ from groundhog_hpc.configuration.endpoints import (
16
+ KNOWN_ENDPOINTS,
17
+ get_endpoint_schema_comments,
18
+ parse_endpoint_spec,
19
+ )
20
+ from groundhog_hpc.configuration.pep723 import add_endpoint_to_script
21
+ from groundhog_hpc.logging import setup_logging
14
22
 
15
23
  console = Console()
16
24
 
25
+ KNOWN_ENDPOINT_ALIASES = []
26
+ for name in KNOWN_ENDPOINTS.keys():
27
+ KNOWN_ENDPOINT_ALIASES += [name]
28
+ KNOWN_ENDPOINT_ALIASES += [
29
+ f"{name}.{variant}" for variant in KNOWN_ENDPOINTS[name]["variants"].keys()
30
+ ]
31
+
17
32
 
18
33
  def add(
19
34
  script: Path = typer.Argument(..., help="Path to the script to modify"),
@@ -24,8 +39,27 @@ def add(
24
39
  python: str | None = typer.Option(
25
40
  None, "--python", "-p", help="Python version specifier"
26
41
  ),
42
+ endpoints: list[str] = typer.Option(
43
+ [],
44
+ "--endpoint",
45
+ "-e",
46
+ help=(
47
+ "Add endpoint configuration (e.g., anvil, anvil.gpu, name:uuid). "
48
+ f"Known endpoints: {', '.join(KNOWN_ENDPOINT_ALIASES)}. Can specify multiple."
49
+ ),
50
+ ),
51
+ log_level: str = typer.Option(
52
+ None,
53
+ "--log-level",
54
+ help="Set logging level (DEBUG, INFO, WARNING, ERROR)\n\n[env: GROUNDHOG_LOG_LEVEL=]",
55
+ ),
27
56
  ) -> None:
28
57
  """Add dependencies or update Python version in a script's PEP 723 metadata."""
58
+ if log_level:
59
+ os.environ["GROUNDHOG_LOG_LEVEL"] = log_level.upper()
60
+ # Reconfigure logging with the new level
61
+ setup_logging()
62
+
29
63
  if not script.exists():
30
64
  console.print(f"[red]Error: Script '{script}' not found[/red]")
31
65
  raise typer.Exit(1)
@@ -60,3 +94,43 @@ def add(
60
94
  except subprocess.CalledProcessError as e:
61
95
  console.print(f"[red]{e.stderr.strip()}[/red]")
62
96
  raise typer.Exit(1)
97
+
98
+ # handle --endpoint flags
99
+ if endpoints:
100
+ content = script.read_text()
101
+ added_any = False
102
+
103
+ for endpoint_spec_str in endpoints:
104
+ try:
105
+ spec = parse_endpoint_spec(endpoint_spec_str)
106
+ except Exception as e:
107
+ console.print(f"[red]Error: {e}[/red]")
108
+ raise typer.Exit(1)
109
+
110
+ # Build config dict from the spec
111
+ endpoint_config = {"endpoint": spec.uuid, **spec.base_defaults}
112
+ variant_config = spec.variant_defaults if spec.variant else None
113
+
114
+ # Fetch schema comments if UUID is valid (not a TODO placeholder)
115
+ schema_comments = None
116
+ if not spec.uuid.startswith("TODO"):
117
+ schema_comments = get_endpoint_schema_comments(spec.uuid)
118
+
119
+ content, skip_msg = add_endpoint_to_script(
120
+ content,
121
+ endpoint_name=spec.name,
122
+ endpoint_config=endpoint_config,
123
+ variant_name=spec.variant,
124
+ variant_config=variant_config,
125
+ schema_comments=schema_comments,
126
+ )
127
+
128
+ if skip_msg:
129
+ console.print(f"[yellow]{skip_msg}[/yellow]")
130
+ else:
131
+ added_any = True
132
+
133
+ script.write_text(content)
134
+
135
+ if added_any:
136
+ console.print(f"[green]Added endpoint configuration to {script}[/green]")
groundhog_hpc/app/init.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Init command for creating new Groundhog scripts."""
2
2
 
3
+ import os
3
4
  import subprocess
4
5
  from pathlib import Path
5
6
  from typing import Optional
@@ -11,9 +12,15 @@ from rich.console import Console
11
12
  from groundhog_hpc.app.utils import normalize_python_version_with_uv
12
13
  from groundhog_hpc.configuration.endpoints import (
13
14
  KNOWN_ENDPOINTS,
14
- fetch_and_format_endpoints,
15
+ get_endpoint_schema_comments,
16
+ parse_endpoint_spec,
15
17
  )
16
- from groundhog_hpc.configuration.pep723 import Pep723Metadata
18
+ from groundhog_hpc.configuration.pep723 import (
19
+ Pep723Metadata,
20
+ add_endpoint_to_script,
21
+ remove_endpoint_from_script,
22
+ )
23
+ from groundhog_hpc.logging import setup_logging
17
24
 
18
25
  console = Console()
19
26
 
@@ -46,8 +53,18 @@ def init(
46
53
  "Can specify multiple."
47
54
  ),
48
55
  ),
56
+ log_level: str = typer.Option(
57
+ None,
58
+ "--log-level",
59
+ help="Set logging level (DEBUG, INFO, WARNING, ERROR)\n\n[env: GROUNDHOG_LOG_LEVEL=]",
60
+ ),
49
61
  ) -> None:
50
62
  """Create a new groundhog script with PEP 723 metadata and example code."""
63
+ if log_level:
64
+ os.environ["GROUNDHOG_LOG_LEVEL"] = log_level.upper()
65
+ # Reconfigure logging with the new level
66
+ setup_logging()
67
+
51
68
  if Path(filename).exists():
52
69
  console.print(f"[red]Error: {filename} already exists[/red]")
53
70
  raise typer.Exit(1)
@@ -67,32 +84,59 @@ def init(
67
84
  assert default_meta.tool and default_meta.tool.uv
68
85
  exclude_newer = default_meta.tool.uv.exclude_newer
69
86
 
70
- # Fetch and format endpoint configurations if provided
71
- endpoint_blocks = []
87
+ # Parse endpoint specs if provided
88
+ endpoint_specs = []
72
89
  if endpoints:
73
90
  try:
74
- endpoint_blocks = fetch_and_format_endpoints(endpoints)
75
- for endpoint in endpoint_blocks:
76
- console.print(f"[green]✓[/green] Fetched schema for {endpoint.name}")
91
+ endpoint_specs = [parse_endpoint_spec(spec) for spec in endpoints]
77
92
  except Exception as e:
78
93
  console.print(f"[red]Error: {e}[/red]")
79
94
  raise typer.Exit(1)
80
95
 
96
+ # Determine endpoint name for decorator (first endpoint or placeholder)
97
+ first_endpoint_name = endpoint_specs[0].name if endpoint_specs else "my_endpoint"
98
+
99
+ # Render template (always includes my_endpoint placeholder)
81
100
  env = Environment(loader=PackageLoader("groundhog_hpc", "templates"))
82
101
  template = env.get_template("init_script.py.jinja")
83
102
  content = template.render(
84
103
  filename=filename,
85
104
  python=python,
86
105
  exclude_newer=exclude_newer,
87
- endpoint_blocks=endpoint_blocks,
106
+ endpoint_name=first_endpoint_name,
88
107
  )
108
+
109
+ # If endpoints provided, replace placeholder with real endpoints
110
+ if endpoint_specs:
111
+ # Remove placeholder
112
+ content = remove_endpoint_from_script(content, "my_endpoint")
113
+
114
+ # Add each requested endpoint
115
+ for spec in endpoint_specs:
116
+ endpoint_config = {"endpoint": spec.uuid, **spec.base_defaults}
117
+ variant_config = spec.variant_defaults if spec.variant else None
118
+
119
+ # Fetch schema comments if UUID is valid (not a TODO placeholder)
120
+ schema_comments = None
121
+ if not spec.uuid.startswith("TODO"):
122
+ schema_comments = get_endpoint_schema_comments(spec.uuid)
123
+
124
+ content, _ = add_endpoint_to_script(
125
+ content,
126
+ endpoint_name=spec.name,
127
+ endpoint_config=endpoint_config,
128
+ variant_name=spec.variant,
129
+ variant_config=variant_config,
130
+ schema_comments=schema_comments,
131
+ )
132
+
89
133
  Path(filename).write_text(content)
90
134
 
91
135
  console.print(f"[green]✓[/green] Created {filename}")
92
- if endpoint_blocks:
136
+ if endpoint_specs:
93
137
  console.print("\nNext steps:")
94
138
  console.print(
95
- f" 1. Update fields in the \\[tool.hog.{endpoint_blocks[0].name}] block"
139
+ f" 1. Update fields in the \\[tool.hog.{endpoint_specs[0].name}] block"
96
140
  )
97
141
  console.print(f" 2. Run with: [bold]hog run {filename} main[/bold]")
98
142
  else:
groundhog_hpc/app/main.py CHANGED
@@ -16,7 +16,11 @@ from groundhog_hpc.app.run import run
16
16
 
17
17
  app = typer.Typer(pretty_exceptions_show_locals=False)
18
18
 
19
- app.command(no_args_is_help=True)(run)
19
+ # Enable extra args for run command to capture harness arguments after --
20
+ app.command(
21
+ no_args_is_help=True,
22
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
23
+ )(run)
20
24
  app.command(no_args_is_help=True)(init)
21
25
  app.command(no_args_is_help=True)(add)
22
26
  app.command(no_args_is_help=True)(remove)
@@ -1,5 +1,6 @@
1
1
  """Remove command for managing PEP 723 script dependencies."""
2
2
 
3
+ import os
3
4
  import subprocess
4
5
  from pathlib import Path
5
6
 
@@ -7,31 +8,105 @@ import typer
7
8
  import uv
8
9
  from rich.console import Console
9
10
 
11
+ from groundhog_hpc.configuration.endpoints import KNOWN_ENDPOINTS
12
+ from groundhog_hpc.configuration.pep723 import remove_endpoint_from_script
13
+ from groundhog_hpc.logging import setup_logging
14
+
10
15
  console = Console()
11
16
 
17
+ KNOWN_ENDPOINT_ALIASES = []
18
+ for name in KNOWN_ENDPOINTS.keys():
19
+ KNOWN_ENDPOINT_ALIASES += [name]
20
+ KNOWN_ENDPOINT_ALIASES += [
21
+ f"{name}.{variant}" for variant in KNOWN_ENDPOINTS[name]["variants"].keys()
22
+ ]
23
+
12
24
 
13
25
  def remove(
14
26
  script: Path = typer.Argument(..., help="Path to the script to modify"),
15
- packages: list[str] = typer.Argument(..., help="Packages to remove"),
27
+ packages: list[str] | None = typer.Argument(None, help="Packages to remove"),
28
+ endpoints: list[str] = typer.Option(
29
+ [],
30
+ "--endpoint",
31
+ "-e",
32
+ help=(
33
+ "Remove endpoint or variant configuration (e.g., anvil, anvil.gpu, my_endpoint). "
34
+ f"Known endpoints: {', '.join(KNOWN_ENDPOINT_ALIASES)}. Can specify multiple. "
35
+ "Note: Removing a base endpoint (e.g., anvil) removes all its variants. "
36
+ "Removing a specific variant (e.g., anvil.gpu) leaves the base and other variants intact."
37
+ ),
38
+ ),
39
+ log_level: str = typer.Option(
40
+ None,
41
+ "--log-level",
42
+ help="Set logging level (DEBUG, INFO, WARNING, ERROR)\n\n[env: GROUNDHOG_LOG_LEVEL=]",
43
+ ),
16
44
  ) -> None:
17
45
  """Remove dependencies from a script's PEP 723 metadata."""
46
+ if log_level:
47
+ os.environ["GROUNDHOG_LOG_LEVEL"] = log_level.upper()
48
+ # Reconfigure logging with the new level
49
+ setup_logging()
50
+
18
51
  # Validate script exists
19
52
  if not script.exists():
20
53
  console.print(f"[red]Error: Script '{script}' not found[/red]")
21
54
  raise typer.Exit(1)
22
55
 
23
- # Shell out to uv
24
- cmd = [f"{uv.find_uv_bin()}", "remove", "--script", str(script)]
25
- cmd.extend(packages)
26
-
27
- try:
28
- subprocess.run(
29
- cmd,
30
- check=True,
31
- capture_output=True,
32
- text=True,
33
- )
34
- console.print(f"[green]Removed packages from {script}[/green]")
35
- except subprocess.CalledProcessError as e:
36
- console.print(f"[red]{e.stderr.strip()}[/red]")
37
- raise typer.Exit(1)
56
+ # Handle package removal
57
+ packages = packages or []
58
+ if packages:
59
+ # Shell out to uv
60
+ cmd = [f"{uv.find_uv_bin()}", "remove", "--script", str(script)]
61
+ cmd.extend(packages)
62
+
63
+ try:
64
+ subprocess.run(
65
+ cmd,
66
+ check=True,
67
+ capture_output=True,
68
+ text=True,
69
+ )
70
+ console.print(f"[green]Removed packages from {script}[/green]")
71
+ except subprocess.CalledProcessError as e:
72
+ console.print(f"[red]{e.stderr.strip()}[/red]")
73
+ raise typer.Exit(1)
74
+
75
+ # Handle endpoint removal
76
+ if endpoints:
77
+ content = script.read_text()
78
+ removed_any = False
79
+
80
+ for endpoint_spec in endpoints:
81
+ # Parse endpoint spec to extract base name and optional variant
82
+ # Format can be: "name", "name.variant", or "name:uuid"
83
+ # Split by ':' first to handle "name:uuid" or "name.variant:uuid"
84
+ name_part = endpoint_spec.split(":")[0]
85
+
86
+ # Check if user specified a variant
87
+ if "." in name_part:
88
+ base_name, variant_name = name_part.split(".", 1)
89
+ else:
90
+ base_name = name_part
91
+ variant_name = None
92
+
93
+ original_content = content
94
+ content = remove_endpoint_from_script(content, base_name, variant_name)
95
+
96
+ if content != original_content:
97
+ removed_any = True
98
+ else:
99
+ if variant_name:
100
+ console.print(
101
+ f"[yellow]Variant '{base_name}.{variant_name}' not found in {script}[/yellow]"
102
+ )
103
+ else:
104
+ console.print(
105
+ f"[yellow]Endpoint '{base_name}' not found in {script}[/yellow]"
106
+ )
107
+
108
+ if removed_any:
109
+ script.write_text(content)
110
+ console.print(
111
+ f"[green]Removed endpoint configuration(s) from {script}[/green]"
112
+ )
groundhog_hpc/app/run.py CHANGED
@@ -1,8 +1,10 @@
1
1
  """Run command for executing Groundhog scripts on Globus Compute endpoints."""
2
2
 
3
+ import inspect
3
4
  import os
4
5
  import sys
5
6
  from pathlib import Path
7
+ from typing import Any
6
8
 
7
9
  import typer
8
10
 
@@ -13,6 +15,7 @@ from groundhog_hpc.app.utils import (
13
15
  from groundhog_hpc.configuration.pep723 import read_pep723
14
16
  from groundhog_hpc.errors import RemoteExecutionError
15
17
  from groundhog_hpc.harness import Harness
18
+ from groundhog_hpc.logging import setup_logging
16
19
  from groundhog_hpc.utils import (
17
20
  get_groundhog_version_spec,
18
21
  import_user_script,
@@ -20,7 +23,38 @@ from groundhog_hpc.utils import (
20
23
  )
21
24
 
22
25
 
26
+ def invoke_harness_with_args(harness: Harness, args: list[str]) -> Any:
27
+ """Parse CLI args and invoke harness function.
28
+
29
+ Reproduces typer.run() logic but with explicit args and standalone_mode=False
30
+ to capture return values and exceptions instead of sys.exit().
31
+
32
+ Args:
33
+ harness: The harness to invoke
34
+ args: CLI arguments to parse (e.g., ["arg1", "--count=5"])
35
+
36
+ Returns:
37
+ The return value from the harness function
38
+
39
+ Raises:
40
+ SystemExit: If argument parsing fails (from Click/Typer)
41
+ Any exception raised by the harness function
42
+ """
43
+ original_argv = sys.argv
44
+ # Use harness name for better help/error messages
45
+ sys.argv = [harness.func.__name__] + args
46
+
47
+ try:
48
+ app = typer.Typer(add_completion=False)
49
+ app.command()(harness.func)
50
+ result = app(standalone_mode=False)
51
+ return result
52
+ finally:
53
+ sys.argv = original_argv
54
+
55
+
23
56
  def run(
57
+ ctx: typer.Context,
24
58
  script: Path = typer.Argument(
25
59
  ..., help="Path to script with PEP 723 dependencies to deploy to the endpoint"
26
60
  ),
@@ -32,11 +66,34 @@ def run(
32
66
  "--no-fun-allowed",
33
67
  help="Suppress emoji output\n\n[env: GROUNDHOG_NO_FUN_ALLOWED=]",
34
68
  ),
69
+ log_level: str = typer.Option(
70
+ None,
71
+ "--log-level",
72
+ help="Set logging level (DEBUG, INFO, WARNING, ERROR)\n\n[env: GROUNDHOG_LOG_LEVEL=]",
73
+ ),
35
74
  ) -> None:
36
- """Run a Python script on a Globus Compute endpoint."""
75
+ """Run a Python script on a Globus Compute endpoint.
76
+
77
+ Use -- to pass arguments to parameterized harnesses:
78
+ hog run script.py harness -- arg1 --option=value
79
+ """
80
+ # Handle the -- separator for harness arguments
81
+ # ctx.args may contain ['--', 'arg1', 'arg2'] - strip the '--' if present
82
+ harness_args = ctx.args # List of extra args, or empty list
83
+ if harness_args and harness_args[0] == "--":
84
+ harness_args = harness_args[1:] # Strip leading '--'
85
+ if harness == "--":
86
+ # User typed: hog run script.py -- args
87
+ # Use default harness "main" and shift args
88
+ harness = "main"
37
89
  if no_fun_allowed:
38
90
  os.environ["GROUNDHOG_NO_FUN_ALLOWED"] = str(no_fun_allowed)
39
91
 
92
+ if log_level:
93
+ os.environ["GROUNDHOG_LOG_LEVEL"] = log_level.upper()
94
+ # Reconfigure logging with the new level
95
+ setup_logging()
96
+
40
97
  script_path = script.resolve()
41
98
  if not script_path.exists():
42
99
  typer.echo(f"Error: Script '{script_path}' not found", err=True)
@@ -87,7 +144,18 @@ def run(
87
144
  )
88
145
  raise typer.Exit(1)
89
146
 
90
- result = harness_func()
147
+ # Dispatch based on whether harness arguments were provided
148
+ if not harness_args:
149
+ # No extra args: zero-arg call (backward compatible)
150
+ result = harness_func()
151
+ else:
152
+ # Has extra args: parse and invoke parameterized harness
153
+ sig = inspect.signature(harness_func.func)
154
+ if len(sig.parameters) == 0:
155
+ typer.echo(f"Error: Harness '{harness}' takes no arguments", err=True)
156
+ raise typer.Exit(1)
157
+ result = invoke_harness_with_args(harness_func, harness_args)
158
+
91
159
  typer.echo(result)
92
160
  except RemoteExecutionError as e:
93
161
  if e.returncode == 124:
groundhog_hpc/compute.py CHANGED
@@ -5,6 +5,7 @@ ShellFunctions, registering them, and submitting them for execution on remote
5
5
  endpoints.
6
6
  """
7
7
 
8
+ import logging
8
9
  import os
9
10
  import warnings
10
11
  from functools import lru_cache
@@ -14,6 +15,8 @@ from uuid import UUID
14
15
  from groundhog_hpc.future import GroundhogFuture
15
16
  from groundhog_hpc.templating import template_shell_command
16
17
 
18
+ logger = logging.getLogger(__name__)
19
+
17
20
  warnings.filterwarnings(
18
21
  "ignore",
19
22
  category=UserWarning,
@@ -92,10 +95,21 @@ def submit_to_executor(
92
95
  expected_keys = set(schema.get("properties", {}).keys())
93
96
  unexpected_keys = set(config.keys()) - expected_keys
94
97
  if unexpected_keys:
98
+ logger.debug(
99
+ f"Filtering unexpected config keys for endpoint {endpoint}: {unexpected_keys}"
100
+ )
95
101
  config = {k: v for k, v in config.items() if k not in unexpected_keys}
96
102
 
103
+ logger.debug(f"Creating Globus Compute executor for endpoint {endpoint}")
97
104
  with gc.Executor(endpoint, user_endpoint_config=config) as executor:
105
+ func_name = getattr(
106
+ shell_function, "__name__", getattr(shell_function, "name", "unknown")
107
+ )
108
+ logger.info(f"Submitting function '{func_name}' to endpoint '{endpoint}'")
98
109
  future = executor.submit(shell_function)
110
+ task_id = getattr(future, "task_id", None)
111
+ if task_id:
112
+ logger.info(f"Task submitted with ID: {task_id}")
99
113
  deserializing_future = GroundhogFuture(future)
100
114
  return deserializing_future
101
115
 
@@ -114,7 +128,8 @@ def get_task_status(task_id: str | UUID | None) -> dict[str, Any]:
114
128
  return {"status": "status pending", "exception": None}
115
129
 
116
130
  client = _get_compute_client()
117
- return client.get_task(task_id)
131
+ task_status = client.get_task(task_id)
132
+ return task_status
118
133
 
119
134
 
120
135
  @lru_cache
@@ -1,6 +1,7 @@
1
1
  # Default Globus Compute Executor configuration
2
2
  DEFAULT_USER_CONFIG = {
3
3
  "worker_init": "",
4
+ "endpoint_setup": "",
4
5
  }
5
6
 
6
7
  # default maximum execution time for remote functions (in seconds)