diracx-cli 0.0.1a15__tar.gz → 0.0.1a17__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.
Files changed (28) hide show
  1. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/PKG-INFO +1 -1
  2. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/internal/__init__.py +20 -14
  3. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/internal/legacy.py +6 -4
  4. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/jobs.py +48 -10
  5. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/utils.py +9 -1
  6. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx_cli.egg-info/PKG-INFO +1 -1
  7. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/legacy/cs_sync/integration_test.yaml +29 -29
  8. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/legacy/cs_sync/test_cssync.py +1 -1
  9. diracx_cli-0.0.1a17/tests/test_jobs.py +122 -0
  10. diracx_cli-0.0.1a15/tests/test_jobs.py +0 -13
  11. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/README.md +0 -0
  12. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/pyproject.toml +0 -0
  13. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/setup.cfg +0 -0
  14. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/__init__.py +0 -0
  15. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/__main__.py +0 -0
  16. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/config.py +0 -0
  17. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx/cli/py.typed +0 -0
  18. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx_cli.egg-info/SOURCES.txt +0 -0
  19. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx_cli.egg-info/dependency_links.txt +0 -0
  20. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx_cli.egg-info/entry_points.txt +0 -0
  21. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx_cli.egg-info/requires.txt +0 -0
  22. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/src/diracx_cli.egg-info/top_level.txt +0 -0
  23. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/legacy/cs_sync/integration_test.cfg +0 -0
  24. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/legacy/cs_sync/integration_test_buggy.cfg +0 -0
  25. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/legacy/cs_sync/integration_test_secret.cfg +0 -0
  26. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/legacy/test_legacy.py +0 -0
  27. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/test_internal.py +0 -0
  28. {diracx_cli-0.0.1a15 → diracx_cli-0.0.1a17}/tests/test_login.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diracx-cli
3
- Version: 0.0.1a15
3
+ Version: 0.0.1a17
4
4
  Summary: TODO
5
5
  License: GPL-3.0-only
6
6
  Classifier: Intended Audience :: Science/Research
@@ -4,7 +4,7 @@ from typing import Annotated, Optional
4
4
  import git
5
5
  import typer
6
6
  import yaml
7
- from pydantic import parse_obj_as
7
+ from pydantic import TypeAdapter
8
8
 
9
9
  from diracx.core.config import ConfigSource, ConfigSourceUrl
10
10
  from diracx.core.config.schema import (
@@ -25,13 +25,11 @@ app.add_typer(legacy.app, name="legacy")
25
25
 
26
26
 
27
27
  @app.command()
28
- def generate_cs(
29
- config_repo: str,
30
- ):
28
+ def generate_cs(config_repo: str):
31
29
  """Generate a minimal DiracX configuration repository"""
32
- # TODO: The use of parse_obj_as should be moved in to typer itself
33
- config_repo = parse_obj_as(ConfigSourceUrl, config_repo)
34
- if config_repo.scheme != "git+file":
30
+ # TODO: The use of TypeAdapter should be moved in to typer itself
31
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
32
+ if config_repo.scheme != "git+file" or config_repo.path is None:
35
33
  raise NotImplementedError("Only git+file:// URLs are supported")
36
34
  repo_path = Path(config_repo.path)
37
35
  if repo_path.exists() and list(repo_path.iterdir()):
@@ -62,8 +60,10 @@ def add_vo(
62
60
  ):
63
61
  """Add a registry entry (vo) to an existing configuration repository"""
64
62
 
65
- # TODO: The use of parse_obj_as should be moved in to typer itself
66
- config_repo = parse_obj_as(ConfigSourceUrl, config_repo)
63
+ # TODO: The use of TypeAdapter should be moved in to typer itself
64
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
65
+ if config_repo.scheme != "git+file" or config_repo.path is None:
66
+ raise NotImplementedError("Only git+file:// URLs are supported")
67
67
  repo_path = Path(config_repo.path)
68
68
 
69
69
  # A VO should at least contain a default group
@@ -104,8 +104,10 @@ def add_group(
104
104
  ):
105
105
  """Add a group to an existing vo in the configuration repository"""
106
106
 
107
- # TODO: The use of parse_obj_as should be moved in to typer itself
108
- config_repo = parse_obj_as(ConfigSourceUrl, config_repo)
107
+ # TODO: The use of TypeAdapter should be moved in to typer itself
108
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
109
+ if config_repo.scheme != "git+file" or config_repo.path is None:
110
+ raise NotImplementedError("Only git+file:// URLs are supported")
109
111
  repo_path = Path(config_repo.path)
110
112
 
111
113
  new_group = GroupConfig(Properties=set(properties), Quota=None, Users=set())
@@ -139,8 +141,10 @@ def add_user(
139
141
  ):
140
142
  """Add a user to an existing vo and group"""
141
143
 
142
- # TODO: The use of parse_obj_as should be moved in to typer itself
143
- config_repo = parse_obj_as(ConfigSourceUrl, config_repo)
144
+ # TODO: The use of TypeAdapter should be moved in to typer itself
145
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
146
+ if config_repo.scheme != "git+file" or config_repo.path is None:
147
+ raise NotImplementedError("Only git+file:// URLs are supported")
144
148
 
145
149
  repo_path = Path(config_repo.path)
146
150
 
@@ -184,6 +188,8 @@ def update_config_and_commit(repo_path: Path, config: Config, message: str):
184
188
  repo = git.Repo(repo_path)
185
189
  yaml_path = repo_path / "default.yml"
186
190
  typer.echo(f"Writing back configuration to {yaml_path}", err=True)
187
- yaml_path.write_text(yaml.safe_dump(config.dict(exclude_unset=True)))
191
+ yaml_path.write_text(
192
+ yaml.safe_dump(config.model_dump(exclude_unset=True, mode="json"))
193
+ )
188
194
  repo.index.add([yaml_path.relative_to(repo_path)])
189
195
  repo.index.commit(message)
@@ -79,14 +79,16 @@ def cs_sync(old_file: Path, new_file: Path):
79
79
 
80
80
  _apply_fixes(raw)
81
81
 
82
- config = Config.parse_obj(raw)
83
- new_file.write_text(yaml.safe_dump(config.dict(exclude_unset=True)))
82
+ config = Config.model_validate(raw)
83
+ new_file.write_text(
84
+ yaml.safe_dump(config.model_dump(exclude_unset=True, mode="json"))
85
+ )
84
86
 
85
87
 
86
88
  def _apply_fixes(raw):
87
89
  """Modify raw in place to make any layout changes between the old and new structure"""
88
90
 
89
- conv_config = ConversionConfig.parse_obj(raw["DiracX"]["CsSync"])
91
+ conv_config = ConversionConfig.model_validate(raw["DiracX"]["CsSync"])
90
92
 
91
93
  raw.pop("DiracX", None)
92
94
  # Remove dips specific parts from the CS
@@ -119,7 +121,7 @@ def _apply_fixes(raw):
119
121
 
120
122
  for vo, vo_meta in conv_config.VOs.items():
121
123
  raw["Registry"][vo] = {
122
- "IdP": vo_meta.IdP,
124
+ "IdP": vo_meta.IdP.model_dump(),
123
125
  "DefaultGroup": vo_meta.DefaultGroup,
124
126
  "Users": {},
125
127
  "Groups": {},
@@ -4,7 +4,8 @@
4
4
  __all__ = ("app",)
5
5
 
6
6
  import json
7
- from typing import Annotated
7
+ import re
8
+ from typing import Annotated, cast
8
9
 
9
10
  from rich.console import Console
10
11
  from rich.table import Table
@@ -52,29 +53,66 @@ async def search(
52
53
  ],
53
54
  condition: Annotated[list[SearchSpec], Option(parser=parse_condition)] = [],
54
55
  all: bool = False,
56
+ page: int = 1,
57
+ per_page: int = 10,
55
58
  ):
56
59
  async with DiracClient() as api:
57
- jobs = await api.jobs.search(
60
+ jobs, content_range = await api.jobs.search(
58
61
  parameters=None if all else parameter,
59
62
  search=condition if condition else None,
63
+ page=page,
64
+ per_page=per_page,
65
+ cls=lambda _, jobs, headers: (
66
+ jobs,
67
+ ContentRange(headers.get("Content-Range", "jobs")),
68
+ ),
60
69
  )
61
- display(jobs, "jobs")
62
70
 
63
-
64
- def display(data, unit: str):
71
+ display(jobs, cast(ContentRange, content_range))
72
+
73
+
74
+ class ContentRange:
75
+ unit: str | None = None
76
+ start: int | None = None
77
+ end: int | None = None
78
+ total: int | None = None
79
+
80
+ def __init__(self, header: str):
81
+ if match := re.fullmatch(r"(\w+) (\d+-\d+|\*)/(\d+|\*)", header):
82
+ self.unit, range, total = match.groups()
83
+ self.total = int(total)
84
+ if range != "*":
85
+ self.start, self.end = map(int, range.split("-"))
86
+ elif match := re.fullmatch(r"\w+", header):
87
+ self.unit = match.group()
88
+
89
+ @property
90
+ def caption(self):
91
+ if self.start is None and self.end is None:
92
+ range_str = "all"
93
+ else:
94
+ range_str = (
95
+ f"{self.start if self.start is not None else 'unknown'}-"
96
+ f"{self.end if self.end is not None else 'unknown'} "
97
+ f"of {self.total or 'unknown'}"
98
+ )
99
+ return f"Showing {range_str} {self.unit}"
100
+
101
+
102
+ def display(data, content_range: ContentRange):
65
103
  output_format = get_diracx_preferences().output_format
66
104
  match output_format:
67
105
  case OutputFormats.JSON:
68
106
  print(json.dumps(data, indent=2))
69
107
  case OutputFormats.RICH:
70
- display_rich(data, unit)
108
+ display_rich(data, content_range)
71
109
  case _:
72
110
  raise NotImplementedError(output_format)
73
111
 
74
112
 
75
- def display_rich(data, unit: str) -> None:
113
+ def display_rich(data, content_range: ContentRange) -> None:
76
114
  if not data:
77
- print(f"No {unit} found")
115
+ print(f"No {content_range.unit} found")
78
116
  return
79
117
 
80
118
  console = Console()
@@ -83,7 +121,7 @@ def display_rich(data, unit: str) -> None:
83
121
  table = Table(
84
122
  "Parameter",
85
123
  "Value",
86
- caption=f"Showing {len(data)} of {len(data)} {unit}",
124
+ caption=content_range.caption,
87
125
  caption_justify="right",
88
126
  )
89
127
  for job in data:
@@ -93,7 +131,7 @@ def display_rich(data, unit: str) -> None:
93
131
  else:
94
132
  table = Table(
95
133
  *columns,
96
- caption=f"Showing {len(data)} of {len(data)} {unit}",
134
+ caption=content_range.caption,
97
135
  caption_justify="right",
98
136
  )
99
137
  for job in data:
@@ -6,6 +6,8 @@ from asyncio import run
6
6
  from functools import wraps
7
7
 
8
8
  import typer
9
+ from azure.core.exceptions import ClientAuthenticationError
10
+ from rich import print
9
11
 
10
12
 
11
13
  class AsyncTyper(typer.Typer):
@@ -13,7 +15,13 @@ class AsyncTyper(typer.Typer):
13
15
  def decorator(async_func):
14
16
  @wraps(async_func)
15
17
  def sync_func(*_args, **_kwargs):
16
- return run(async_func(*_args, **_kwargs))
18
+ try:
19
+ return run(async_func(*_args, **_kwargs))
20
+ except ClientAuthenticationError:
21
+ print(
22
+ ":x: [bold red]You are not authenticated. Log in with:[/bold red] "
23
+ "[bold] dirac login [OPTIONS] [VO] [/bold]"
24
+ )
17
25
 
18
26
  self.command(*args, **kwargs)(sync_func)
19
27
  return async_func
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diracx-cli
3
- Version: 0.0.1a15
3
+ Version: 0.0.1a17
4
4
  Summary: TODO
5
5
  License: GPL-3.0-only
6
6
  Classifier: Intended Audience :: Science/Research
@@ -52,20 +52,20 @@ Registry:
52
52
  DefaultGroup: jenkins_user
53
53
  Groups:
54
54
  jenkins_fcadmin:
55
- Properties: !!set
56
- FileCatalogManagement: null
57
- NormalUser: null
58
- Users: !!set
59
- 26dbe36e-cf5c-4c52-a834-29a1c904ef74: null
60
- a95ab678-3fa4-41b9-b863-fe62ce8064ce: null
61
- e2cb28ec-1a1e-40ee-a56d-d899b79879ce: null
55
+ Properties:
56
+ - FileCatalogManagement
57
+ - NormalUser
58
+ Users:
59
+ - 26dbe36e-cf5c-4c52-a834-29a1c904ef74
60
+ - a95ab678-3fa4-41b9-b863-fe62ce8064ce
61
+ - e2cb28ec-1a1e-40ee-a56d-d899b79879ce
62
62
  jenkins_user:
63
- Properties: !!set
64
- NormalUser: null
65
- Users: !!set
66
- 26dbe36e-cf5c-4c52-a834-29a1c904ef74: null
67
- a95ab678-3fa4-41b9-b863-fe62ce8064ce: null
68
- e2cb28ec-1a1e-40ee-a56d-d899b79879ce: null
63
+ Properties:
64
+ - NormalUser
65
+ Users:
66
+ - 26dbe36e-cf5c-4c52-a834-29a1c904ef74
67
+ - a95ab678-3fa4-41b9-b863-fe62ce8064ce
68
+ - e2cb28ec-1a1e-40ee-a56d-d899b79879ce
69
69
  IdP:
70
70
  ClientID: 995ed3b9-d5bd-49d3-a7f4-7fc7dbd5a0cd
71
71
  URL: https://jenkins.invalid/
@@ -94,23 +94,23 @@ Registry:
94
94
  DefaultGroup: dirac_user
95
95
  Groups:
96
96
  dirac_admin:
97
- Properties: !!set
98
- AlarmsManagement: null
99
- CSAdministrator: null
100
- FullDelegation: null
101
- JobAdministrator: null
102
- Operator: null
103
- ProxyManagement: null
104
- ServiceAdministrator: null
105
- Users: !!set
106
- 26b14fc9-6d40-4ca5-b014-6234eaf0fb6e: null
97
+ Properties:
98
+ - AlarmsManagement
99
+ - CSAdministrator
100
+ - FullDelegation
101
+ - JobAdministrator
102
+ - Operator
103
+ - ProxyManagement
104
+ - ServiceAdministrator
105
+ Users:
106
+ - 26b14fc9-6d40-4ca5-b014-6234eaf0fb6e
107
107
  dirac_user:
108
- Properties: !!set
109
- NormalUser: null
110
- Users: !!set
111
- 26b14fc9-6d40-4ca5-b014-6234eaf0fb6e: null
112
- d3adc733-6588-4d6f-8581-5986b02d0c87: null
113
- ff2152ff-34f4-4739-b106-3def37e291e3: null
108
+ Properties:
109
+ - NormalUser
110
+ Users:
111
+ - 26b14fc9-6d40-4ca5-b014-6234eaf0fb6e
112
+ - d3adc733-6588-4d6f-8581-5986b02d0c87
113
+ - ff2152ff-34f4-4739-b106-3def37e291e3
114
114
  IdP:
115
115
  ClientID: 072afab5-ed92-46e0-a61d-4ecbc96e0770
116
116
  URL: https://vo.invalid/
@@ -31,7 +31,7 @@ def test_cs_sync(tmp_path, monkeypatch):
31
31
  actual_output = yaml.safe_load(output_file.read_text())
32
32
  expected_output = yaml.safe_load((file_path / "integration_test.yaml").read_text())
33
33
  assert actual_output == expected_output
34
- Config.parse_obj(actual_output)
34
+ Config.model_validate(actual_output)
35
35
 
36
36
 
37
37
  def test_disabled_vos_empty(tmp_path, monkeypatch):
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+
7
+ import pytest
8
+ from pytest import raises
9
+
10
+ from diracx import cli
11
+ from diracx.core.models import ScalarSearchSpec
12
+ from diracx.core.preferences import get_diracx_preferences
13
+
14
+ TEST_JDL = """
15
+ Arguments = "jobDescription.xml -o LogLevel=INFO";
16
+ Executable = "dirac-jobexec";
17
+ JobGroup = jobGroup;
18
+ JobName = jobName;
19
+ JobType = User;
20
+ LogLevel = INFO;
21
+ OutputSandbox =
22
+ {
23
+ Script1_CodeOutput.log,
24
+ std.err,
25
+ std.out
26
+ };
27
+ Priority = 1;
28
+ Site = ANY;
29
+ StdError = std.err;
30
+ StdOutput = std.out;
31
+ """
32
+
33
+
34
+ @pytest.fixture
35
+ async def jdl_file():
36
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as temp_file:
37
+ temp_file.write(TEST_JDL)
38
+ temp_file.flush()
39
+ yield temp_file.name
40
+
41
+
42
+ async def test_submit(with_cli_login, jdl_file, capfd):
43
+ """Test submitting a job using a JDL file."""
44
+
45
+ with open(jdl_file, "r") as temp_file:
46
+ await cli.jobs.submit([temp_file])
47
+
48
+ cap = capfd.readouterr()
49
+ assert cap.err == ""
50
+ assert "Inserted 1 jobs with ids" in cap.out
51
+
52
+
53
+ async def test_search(with_cli_login, jdl_file, capfd):
54
+ """Test searching for jobs."""
55
+
56
+ # Submit 20 jobs
57
+ with open(jdl_file, "r") as temp_file:
58
+ await cli.jobs.submit([temp_file] * 20)
59
+
60
+ cap = capfd.readouterr()
61
+
62
+ # By default the output should be in JSON format as capfd is not a TTY
63
+ await cli.jobs.search()
64
+ cap = capfd.readouterr()
65
+ assert cap.err == ""
66
+ jobs = json.loads(cap.out)
67
+
68
+ # There should be 10 jobs by default
69
+ assert len(jobs) == 10
70
+ assert "JobID" in jobs[0]
71
+ assert "JobGroup" in jobs[0]
72
+
73
+ # Change per-page to a very large number to get all the jobs at once: the caption should change
74
+ await cli.jobs.search(per_page=9999)
75
+ cap = capfd.readouterr()
76
+ assert cap.err == ""
77
+ jobs = json.loads(cap.out)
78
+
79
+ # There should be 20 jobs at least now
80
+ assert len(jobs) >= 20
81
+ assert "JobID" in cap.out
82
+ assert "JobGroup" in cap.out
83
+
84
+ # Search for a job that doesn't exist
85
+ condition = ScalarSearchSpec(parameter="Status", operator="eq", value="nonexistent")
86
+ await cli.jobs.search(condition=[condition])
87
+ cap = capfd.readouterr()
88
+ assert cap.err == ""
89
+ assert "[]" == cap.out.strip()
90
+
91
+ # Switch to RICH output
92
+ get_diracx_preferences.cache_clear()
93
+ os.environ["DIRACX_OUTPUT_FORMAT"] = "RICH"
94
+
95
+ await cli.jobs.search()
96
+ cap = capfd.readouterr()
97
+ assert cap.err == ""
98
+
99
+ with raises(json.JSONDecodeError):
100
+ json.loads(cap.out)
101
+
102
+ assert "JobID" in cap.out
103
+ assert "JobGroup" in cap.out
104
+ assert "Showing 0-9 of " in cap.out
105
+
106
+ # Change per-page to a very large number to get all the jobs at once: the caption should change
107
+ await cli.jobs.search(per_page=9999)
108
+ cap = capfd.readouterr()
109
+ assert cap.err == ""
110
+
111
+ with raises(json.JSONDecodeError):
112
+ json.loads(cap.out)
113
+
114
+ assert "JobID" in cap.out
115
+ assert "JobGroup" in cap.out
116
+ assert "Showing all jobs" in cap.out
117
+
118
+ # Search for a job that doesn't exist
119
+ await cli.jobs.search(condition=[condition])
120
+ cap = capfd.readouterr()
121
+ assert cap.err == ""
122
+ assert "No jobs found" in cap.out
@@ -1,13 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
-
5
- from diracx import cli
6
-
7
-
8
- async def test_search(with_cli_login, capfd):
9
- await cli.jobs.search()
10
- cap = capfd.readouterr()
11
- assert cap.err == ""
12
- # By default the output should be in JSON format as capfd is not a TTY
13
- json.loads(cap.out)
File without changes
File without changes