Skip to content

Commit d894600

Browse files
authored
Merge pull request #35 from flux-framework/add/flux-option-flags
adding support for flux option flags
2 parents 60f9749 + 301cd74 commit d894600

File tree

12 files changed

+176
-9
lines changed

12 files changed

+176
-9
lines changed

app/core/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import logging
12
import os
3+
import re
4+
import shlex
25

36
from pydantic import BaseSettings
47

8+
logger = logging.getLogger(__name__)
9+
510

611
def get_int_envar(key, default=None):
712
"""
@@ -24,6 +29,35 @@ def get_bool_envar(key, default=False):
2429
return default if not os.environ.get(key) else not default
2530

2631

32+
def get_option_flags(key, prefix="-o"):
33+
"""
34+
Wrapper around parse_option_flags to get from environment.
35+
36+
The function can then be shared to parse flags from the UI
37+
in the same way.
38+
"""
39+
flags = os.environ.get(key) or {}
40+
if not flags:
41+
return flags
42+
return parse_option_flags(flags, prefix)
43+
44+
45+
def parse_option_flags(flags, prefix="-o"):
46+
"""
47+
Parse key value pairs (optionally with a prefix) from the environment.
48+
"""
49+
values = {}
50+
for flag in shlex.split(flags):
51+
if "=" not in flag:
52+
logger.warning(f"Missing '=' in flag {flag}, cannot parse.")
53+
continue
54+
option, value = flag.split("=", 1)
55+
if option.startswith(prefix):
56+
option = re.sub(f"^{prefix}", "", option)
57+
values[option] = value
58+
return values
59+
60+
2761
class Settings(BaseSettings):
2862
"""
2963
Basic settings and defaults for the Flux RESTFul API
@@ -41,6 +75,9 @@ class Settings(BaseSettings):
4175
flux_token: str = os.environ.get("FLUX_TOKEN")
4276
require_auth: bool = get_bool_envar("FLUX_REQUIRE_AUTH")
4377

78+
# Default server option flags
79+
option_flags: dict = get_option_flags("FLUX_OPTION_FLAGS")
80+
4481
# If the user requests a launcher, be strict.
4582
# We only allow nextflow and snakemake, sorry
4683
known_launchers: list = ["nextflow", "snakemake"]

app/forms.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from fastapi import Request
44

5+
import app.core.config as config
56
import app.library.flux as flux_cli
67

78

@@ -17,6 +18,7 @@ def __init__(self, request: Request):
1718
self.cores_per_task: Optional[int] = None
1819
self.gpus_per_task: Optional[int] = None
1920
self.exclusive: Optional[bool] = False
21+
self.option_flags: Optional[str] = None
2022
self.is_launcher: Optional[bool] = False
2123
self.exclusive: Optional[bool] = False
2224

@@ -28,6 +30,7 @@ async def load_data(self):
2830
self.num_nodes = form.get("num_nodes")
2931
self.runtime = form.get("runtime") or 0
3032
self.cores_per_task = form.get("cores_per_task")
33+
self.option_flags = form.get("option_flags")
3134
self.gpus_per_task = form.get("gpus_per_task")
3235
self.exclusive = True if form.get("exclusive") == "on" else False
3336
self.is_launcher = True if form.get("is_launcher") == "on" else False
@@ -40,7 +43,8 @@ def kwargs(self):
4043
kwargs = {}
4144
as_int = ["num_tasks", "num_nodes", "cores_per_task", "gpus_per_task"]
4245
as_bool = ["exclusive", "is_launcher"]
43-
for key in as_int + as_bool + ["command"]:
46+
as_str = ["command"]
47+
for key in as_int + as_bool + as_str:
4448
if getattr(self, key, None) is not None:
4549
value = getattr(self, key)
4650

@@ -55,8 +59,23 @@ def kwargs(self):
5559
kwargs[key] = value
5660
else:
5761
kwargs[key] = value
62+
63+
# Custom parsing (or just addition) of settings
64+
kwargs["option_flags"] = self.get_option_flags()
5865
return kwargs
5966

67+
def get_option_flags(self):
68+
"""
69+
Get option flags from the form, add on to server defaults.
70+
"""
71+
flags = config.settings.option_flags
72+
option_flags = getattr(self, "option_flags", None)
73+
74+
# The user setting in the UI takes precedence over the server!
75+
if option_flags not in [None, ""]:
76+
flags.update(config.parse_option_flags(option_flags))
77+
return flags
78+
6079
def is_valid(self):
6180
"""
6281
Determine if the form is valid (devoid of errors)

app/library/flux.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ def validate_submit_kwargs(kwargs, envars=None, runtime=None):
2828
f"The server only has {settings.flux_nodes} nodes, you requested {num_nodes}"
2929
)
3030

31+
# Make sure if option_flags defined, we don't have a -o prefix
32+
option_flags = kwargs.get("option_flags") or {}
33+
if not isinstance(option_flags, dict):
34+
errors.append(
35+
f"Please provide option args as a dictionary, type {type(option_flags)} is invalid."
36+
)
37+
else:
38+
for option, _ in option_flags.items():
39+
if "-o" in option:
40+
errors.append(f"Please provide keys without -o, {option} is invalid.")
41+
3142
# If the user asks for gpus and we don't have any, no go
3243
if "gpus_per_task" in kwargs and not settings.has_gpus:
3344
errors.append("This server does not support gpus: gpus_per_task cannot be set.")
@@ -51,6 +62,7 @@ def prepare_job(kwargs, runtime=0, workdir=None, envars=None):
5162
After validation, prepare the job (shared function).
5263
"""
5364
envars = envars or {}
65+
option_flags = kwargs.get("option_flags") or {}
5466

5567
# Generate the flux job
5668
command = kwargs["command"]
@@ -59,12 +71,16 @@ def prepare_job(kwargs, runtime=0, workdir=None, envars=None):
5971
print(f"⭐️ Command being submit: {command}")
6072

6173
# Delete command from the kwargs (we added because is required and validated that way)
62-
del kwargs["command"]
74+
# From the command line API client is_launcher won't be here, in the UI it will.
75+
for key in ["command", "option_flags", "is_launcher"]:
76+
if key in kwargs:
77+
del kwargs[key]
6378

64-
# From the command line API client this won't be here, in the UI it will.
65-
if "is_launcher" in kwargs:
66-
del kwargs["is_launcher"]
79+
# Assemble the flux job!
6780
fluxjob = flux.job.JobspecV1.from_command(command, **kwargs)
81+
for option, value in option_flags.items():
82+
print(f"⭐️ Setting shell option: {option}={value}")
83+
fluxjob.setattr_shell_option(option, value)
6884

6985
print(f"⭐️ Workdir provided: {workdir}")
7086
if workdir is not None:

app/routers/api.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@
88
from fastapi.responses import JSONResponse, StreamingResponse
99
from fastapi.templating import Jinja2Templates
1010

11+
import app.core.config as config
1112
import app.library.flux as flux_cli
1213
import app.library.helpers as helpers
1314
import app.library.launcher as launcher
14-
from app.core.config import settings
1515
from app.library.auth import alert_auth, check_auth
1616

1717
# Print (hidden message) to give status of auth
1818
alert_auth()
1919
router = APIRouter(
2020
prefix="/v1",
2121
tags=["jobs"],
22-
dependencies=[Depends(check_auth)] if settings.require_auth else [],
22+
dependencies=[Depends(check_auth)] if config.settings.require_auth else [],
2323
responses={404: {"description": "Not found"}},
2424
)
2525
no_auth_router = APIRouter(prefix="/v1", tags=["jobs"])
@@ -155,8 +155,9 @@ async def submit_job(request: Request):
155155
# Optional arguments
156156
as_int = ["num_tasks", "cores_per_task", "gpus_per_task", "num_nodes"]
157157
as_bool = ["exclusive"]
158+
as_is = ["option_flags"]
158159

159-
for optional in as_int + as_bool:
160+
for optional in as_int + as_bool + as_is:
160161
if optional in payload and payload[optional]:
161162
if optional in as_bool:
162163
kwargs[optional] = bool(payload[optional])

clients/python/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are:
1414
The versions coincide with releases on pip. Only major versions will be released as tags on Github.
1515

1616
## [0.0.x](https://github.com/flux-framework/flux-restful-api/tree/main) (0.0.x)
17+
- support for adding option flags to submit (0.0.15)
1718
- support for `is_launcher` parameter to indicate a launcher should be used instead (0.0.14)
1819
- support for streaming job output (0.0.13)
1920
- ensure logs end with one newline! (0.0.12)

clients/python/flux_restful_client/main/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def submit(self, command, **kwargs):
213213
cores_per_task (int): Number of cores per task (default to 1)
214214
gpus_per_task (int): Number of gpus per task (defaults to None)
215215
num_nodes (int): Number of nodes (defaults to None)
216+
option_flags (dict): Option flags (as dict, defaults to {})
216217
exclusive (bool): is the job exclusive? (defaults to False)
217218
is_launcher (bool): the command should be submit to a launcher.
218219
This is currently supported for snakemake and nextflow.
@@ -225,6 +226,7 @@ def submit(self, command, **kwargs):
225226
"num_tasks",
226227
"cores_per_task",
227228
"gpus_per_task",
229+
"option_flags",
228230
"num_nodes",
229231
"exclusive",
230232
"is_launcher",
@@ -237,6 +239,7 @@ def submit(self, command, **kwargs):
237239
data[optional] = kwargs[optional]
238240

239241
# Validate the data first.
242+
print(data)
240243
jsonschema.validate(data, schema=schemas.job_submit_schema)
241244
result = self.do_request("jobs/submit", "POST", data=data)
242245
if result.status_code == 404:

clients/python/flux_restful_client/main/schemas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
"type": ["number", "null"],
4343
"description": "number of nodes for the job.",
4444
},
45+
"option_flags": {
46+
"type": "object",
47+
"description": "option flags (dict) or key value pairs for flux.",
48+
},
4549
"exclusive": {
4650
"type": ["boolean", "null"],
4751
"description": "ask for exclusive nodes for the job.",

clients/python/flux_restful_client/tests/test_api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import time
22

3+
import jsonschema
4+
import pytest
35
from flux_restful_client.main import get_client
46

57

@@ -77,6 +79,22 @@ def test_job_output():
7779
assert "pancakes 🥞️🥞️🥞️\n" in lines["Output"]
7880

7981

82+
def test_option_flags():
83+
"""
84+
Test adding valid and invalid option flags
85+
"""
86+
client = get_client()
87+
88+
with pytest.raises(jsonschema.ValidationError):
89+
client.submit("sleep 1", option_flags="invalid format")
90+
91+
# The server should reject a key with -o
92+
result = client.submit("sleep 1", option_flags={"-ompi": "noodles"})
93+
assert "Errors" in result
94+
assert result["Errors"]
95+
assert "keys without -o" in result["Errors"][0]
96+
97+
8098
def test_job_query():
8199
"""
82100
Test endpoint to query jobs

clients/python/flux_restful_client/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "0.0.14"
1+
__version__ = "0.0.15"
22
AUTHOR = "Vanessa Sochat"
33
44
NAME = "flux-restful-client"

docs/getting_started/developer-guide.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,30 @@ The following variables are available (with their defaults):
147147
|FLUX_USER| The username to require for Basic Auth (if `FLUX_REQUIRE_AUTH` is set) | unset |
148148
|FLUX_HAS_GPU | GPUs are available for the user to request | unset |
149149
|FLUX_NUMBER_NODES| The number of nodes available in the cluster | 1 |
150+
|FLUX_OPTION_FLAGS | Option flags to give to flux, in the same format you'd give on the command line | unset |
151+
152+
### Flux Option Flags
153+
154+
Option flags can be set server-wide or on the fly by a user in the interface
155+
(or restful API). An option set by a user will over-ride the server setting.
156+
An example setting a server-level option flags is below:
157+
158+
```bash
159+
export FLUX_OPTION_FLAGS="-ompi=openmpi@5"
160+
```
161+
162+
This would be translated to:
163+
164+
```python
165+
fluxjob = flux.job.JobspecV1.from_command(command, **kwargs)
166+
fluxjob.setattr_shell_option("mpi", "openmpi@5")
167+
```
168+
169+
And note that you can easily set more than one:
170+
171+
```bash
172+
export FLUX_OPTION_FLAGS="-ompi=openmpi@5 -okey=value"
173+
```
150174

151175
## Code Linting
152176

0 commit comments

Comments
 (0)