diracx-cli 0.0.1a23__py3-none-any.whl → 0.0.1a24__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.
diracx/cli/__init__.py CHANGED
@@ -1,144 +1,13 @@
1
- import asyncio
2
- import json
3
- import os
4
- from datetime import datetime, timedelta, timezone
5
- from typing import Annotated, Optional
1
+ from __future__ import annotations
6
2
 
7
- import typer
8
-
9
- from diracx.client.aio import DiracClient
10
- from diracx.client.models import DeviceFlowErrorResponse
11
3
  from diracx.core.extensions import select_from_extension
12
- from diracx.core.preferences import get_diracx_preferences
13
- from diracx.core.utils import read_credentials, write_credentials
14
-
15
- from .utils import AsyncTyper
16
-
17
- app = AsyncTyper()
18
-
19
-
20
- async def installation_metadata():
21
- async with DiracClient() as api:
22
- return await api.well_known.installation_metadata()
23
-
24
-
25
- def vo_callback(vo: str | None) -> str:
26
- metadata = asyncio.run(installation_metadata())
27
- vos = list(metadata.virtual_organizations)
28
- if not vo:
29
- raise typer.BadParameter(
30
- f"VO must be specified, available options are: {' '.join(vos)}"
31
- )
32
- if vo not in vos:
33
- raise typer.BadParameter(
34
- f"Unknown VO {vo}, available options are: {' '.join(vos)}"
35
- )
36
- return vo
37
-
38
-
39
- @app.async_command()
40
- async def login(
41
- vo: Annotated[
42
- Optional[str],
43
- typer.Argument(callback=vo_callback, help="Virtual Organization name"),
44
- ] = None,
45
- group: Optional[str] = typer.Option(
46
- None,
47
- help="Group name within the VO. If not provided, the default group for the VO will be used.",
48
- ),
49
- property: Optional[list[str]] = typer.Option(
50
- None,
51
- help=(
52
- "List of properties to add to the default properties of the group. "
53
- "If not provided, default properties of the group will be used."
54
- ),
55
- ),
56
- ):
57
- """Login to the DIRAC system using the device flow.
58
-
59
- - If only VO is provided: Uses the default group and its properties for the VO.
60
-
61
- - If VO and group are provided: Uses the specified group and its properties for the VO.
62
4
 
63
- - If VO and properties are provided: Uses the default group and combines its properties with the
64
- provided properties.
5
+ from .auth import app
65
6
 
66
- - If VO, group, and properties are provided: Uses the specified group and combines its properties with the
67
- provided properties.
68
- """
69
- scopes = [f"vo:{vo}"]
70
- if group:
71
- scopes.append(f"group:{group}")
72
- if property:
73
- scopes += [f"property:{p}" for p in property]
74
-
75
- print(f"Logging in with scopes: {scopes}")
76
- async with DiracClient() as api:
77
- data = await api.auth.initiate_device_flow(
78
- client_id=api.client_id,
79
- scope=" ".join(scopes),
80
- )
81
- print("Now go to:", data.verification_uri_complete)
82
- expires = datetime.now(tz=timezone.utc) + timedelta(
83
- seconds=data.expires_in - 30
84
- )
85
- while expires > datetime.now(tz=timezone.utc):
86
- print(".", end="", flush=True)
87
- response = await api.auth.token(device_code=data.device_code, client_id=api.client_id) # type: ignore
88
- if isinstance(response, DeviceFlowErrorResponse):
89
- if response.error == "authorization_pending":
90
- # TODO: Setting more than 5 seconds results in an error
91
- # Related to keep-alive disconnects from uvicon (--timeout-keep-alive)
92
- await asyncio.sleep(2)
93
- continue
94
- raise RuntimeError(f"Device flow failed with {response}")
95
- break
96
- else:
97
- raise RuntimeError("Device authorization flow expired")
98
-
99
- # Save credentials
100
- write_credentials(response)
101
- credentials_path = get_diracx_preferences().credentials_path
102
- print(f"Saved credentials to {credentials_path}")
103
- print("\nLogin successful!")
104
-
105
-
106
- @app.async_command()
107
- async def whoami():
108
- async with DiracClient() as api:
109
- user_info = await api.auth.userinfo()
110
- # TODO: Add a RICH output format
111
- print(json.dumps(user_info.as_dict(), indent=2))
112
-
113
-
114
- @app.async_command()
115
- async def logout():
116
- async with DiracClient() as api:
117
- credentials_path = get_diracx_preferences().credentials_path
118
- if credentials_path.exists():
119
- credentials = read_credentials(credentials_path)
120
-
121
- # Revoke refresh token
122
- try:
123
- await api.auth.revoke_refresh_token(credentials.refresh_token)
124
- except Exception as e:
125
- print(f"Error revoking the refresh token {e!r}")
126
- pass
127
-
128
- # Remove credentials
129
- credentials_path.unlink(missing_ok=True)
130
- print(f"Removed credentials from {credentials_path}")
131
- print("\nLogout successful!")
132
-
133
-
134
- @app.callback()
135
- def callback(output_format: Optional[str] = None):
136
- if output_format is not None:
137
- os.environ["DIRACX_OUTPUT_FORMAT"] = output_format
7
+ __all__ = ("app",)
138
8
 
139
9
 
140
10
  # Load all the sub commands
141
-
142
11
  cli_names = set(
143
12
  [entry_point.name for entry_point in select_from_extension(group="diracx.cli")]
144
13
  )
diracx/cli/__main__.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from . import app
2
4
 
3
5
  if __name__ == "__main__":
diracx/cli/auth.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ("app",)
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import Annotated, Optional
10
+
11
+ import typer
12
+
13
+ from diracx.client.aio import DiracClient
14
+ from diracx.client.models import DeviceFlowErrorResponse
15
+ from diracx.core.preferences import get_diracx_preferences
16
+ from diracx.core.utils import read_credentials, write_credentials
17
+
18
+ from .utils import AsyncTyper
19
+
20
+ app = AsyncTyper()
21
+
22
+
23
+ async def installation_metadata():
24
+ async with DiracClient() as api:
25
+ return await api.well_known.installation_metadata()
26
+
27
+
28
+ def vo_callback(vo: str | None) -> str:
29
+ metadata = asyncio.run(installation_metadata())
30
+ vos = list(metadata.virtual_organizations)
31
+ if not vo:
32
+ raise typer.BadParameter(
33
+ f"VO must be specified, available options are: {' '.join(vos)}"
34
+ )
35
+ if vo not in vos:
36
+ raise typer.BadParameter(
37
+ f"Unknown VO {vo}, available options are: {' '.join(vos)}"
38
+ )
39
+ return vo
40
+
41
+
42
+ @app.async_command()
43
+ async def login(
44
+ vo: Annotated[
45
+ Optional[str],
46
+ typer.Argument(callback=vo_callback, help="Virtual Organization name"),
47
+ ] = None,
48
+ group: Optional[str] = typer.Option(
49
+ None,
50
+ help="Group name within the VO. If not provided, the default group for the VO will be used.",
51
+ ),
52
+ property: Optional[list[str]] = typer.Option(
53
+ None,
54
+ help=(
55
+ "List of properties to add to the default properties of the group. "
56
+ "If not provided, default properties of the group will be used."
57
+ ),
58
+ ),
59
+ ):
60
+ """Login to the DIRAC system using the device flow.
61
+
62
+ - If only VO is provided: Uses the default group and its properties for the VO.
63
+
64
+ - If VO and group are provided: Uses the specified group and its properties for the VO.
65
+
66
+ - If VO and properties are provided: Uses the default group and combines its properties with the
67
+ provided properties.
68
+
69
+ - If VO, group, and properties are provided: Uses the specified group and combines its properties with the
70
+ provided properties.
71
+ """
72
+ scopes = [f"vo:{vo}"]
73
+ if group:
74
+ scopes.append(f"group:{group}")
75
+ if property:
76
+ scopes += [f"property:{p}" for p in property]
77
+
78
+ print(f"Logging in with scopes: {scopes}")
79
+ async with DiracClient() as api:
80
+ data = await api.auth.initiate_device_flow(
81
+ client_id=api.client_id,
82
+ scope=" ".join(scopes),
83
+ )
84
+ print("Now go to:", data.verification_uri_complete)
85
+ expires = datetime.now(tz=timezone.utc) + timedelta(
86
+ seconds=data.expires_in - 30
87
+ )
88
+ while expires > datetime.now(tz=timezone.utc):
89
+ print(".", end="", flush=True)
90
+ response = await api.auth.token(device_code=data.device_code, client_id=api.client_id) # type: ignore
91
+ if isinstance(response, DeviceFlowErrorResponse):
92
+ if response.error == "authorization_pending":
93
+ # TODO: Setting more than 5 seconds results in an error
94
+ # Related to keep-alive disconnects from uvicon (--timeout-keep-alive)
95
+ await asyncio.sleep(2)
96
+ continue
97
+ raise RuntimeError(f"Device flow failed with {response}")
98
+ break
99
+ else:
100
+ raise RuntimeError("Device authorization flow expired")
101
+
102
+ # Save credentials
103
+ write_credentials(response)
104
+ credentials_path = get_diracx_preferences().credentials_path
105
+ print(f"Saved credentials to {credentials_path}")
106
+ print("\nLogin successful!")
107
+
108
+
109
+ @app.async_command()
110
+ async def whoami():
111
+ async with DiracClient() as api:
112
+ user_info = await api.auth.userinfo()
113
+ # TODO: Add a RICH output format
114
+ print(json.dumps(user_info.as_dict(), indent=2))
115
+
116
+
117
+ @app.async_command()
118
+ async def logout():
119
+ async with DiracClient() as api:
120
+ credentials_path = get_diracx_preferences().credentials_path
121
+ if credentials_path.exists():
122
+ credentials = read_credentials(credentials_path)
123
+
124
+ # Revoke refresh token
125
+ try:
126
+ await api.auth.revoke_refresh_token(credentials.refresh_token)
127
+ except Exception as e:
128
+ print(f"Error revoking the refresh token {e!r}")
129
+ pass
130
+
131
+ # Remove credentials
132
+ credentials_path.unlink(missing_ok=True)
133
+ print(f"Removed credentials from {credentials_path}")
134
+ print("\nLogout successful!")
135
+
136
+
137
+ @app.callback()
138
+ def callback(output_format: Optional[str] = None):
139
+ if output_format is not None:
140
+ os.environ["DIRACX_OUTPUT_FORMAT"] = output_format
diracx/cli/config.py CHANGED
@@ -1,5 +1,6 @@
1
1
  # Can't using PEP-604 with typer: https://github.com/tiangolo/typer/issues/348
2
2
  # from __future__ import annotations
3
+ from __future__ import annotations
3
4
 
4
5
  __all__ = ("dump",)
5
6
 
@@ -1,192 +1,8 @@
1
- from pathlib import Path
2
- from typing import Annotated, Optional
1
+ from __future__ import annotations
3
2
 
4
- import git
5
- import typer
6
- import yaml
7
- from pydantic import TypeAdapter
8
-
9
- from diracx.core.config import ConfigSource, ConfigSourceUrl
10
- from diracx.core.config.schema import (
11
- Config,
12
- DIRACConfig,
13
- GroupConfig,
14
- IdpConfig,
15
- OperationsConfig,
16
- RegistryConfig,
17
- UserConfig,
18
- )
19
-
20
- from ..utils import AsyncTyper
21
3
  from . import legacy
4
+ from .config import app
22
5
 
23
- app = AsyncTyper()
24
- app.add_typer(legacy.app, name="legacy")
25
-
26
-
27
- @app.command()
28
- def generate_cs(config_repo: str):
29
- """Generate a minimal DiracX configuration repository."""
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:
33
- raise NotImplementedError("Only git+file:// URLs are supported")
34
- repo_path = Path(config_repo.path)
35
- if repo_path.exists() and list(repo_path.iterdir()):
36
- typer.echo(f"ERROR: Directory {repo_path} already exists", err=True)
37
- raise typer.Exit(1)
38
-
39
- config = Config(
40
- Registry={},
41
- DIRAC=DIRACConfig(),
42
- Operations={"Defaults": OperationsConfig()},
43
- )
44
-
45
- git.Repo.init(repo_path, initial_branch="master")
46
- update_config_and_commit(
47
- repo_path=repo_path, config=config, message="Initial commit"
48
- )
49
- typer.echo(f"Successfully created repo in {config_repo}", err=True)
50
-
51
-
52
- @app.command()
53
- def add_vo(
54
- config_repo: str,
55
- *,
56
- vo: Annotated[str, typer.Option()],
57
- default_group: Optional[str] = "user",
58
- idp_url: Annotated[str, typer.Option()],
59
- idp_client_id: Annotated[str, typer.Option()],
60
- ):
61
- """Add a registry entry (vo) to an existing configuration repository."""
62
- # TODO: The use of TypeAdapter should be moved in to typer itself
63
- config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
64
- if config_repo.scheme != "git+file" or config_repo.path is None:
65
- raise NotImplementedError("Only git+file:// URLs are supported")
66
- repo_path = Path(config_repo.path)
67
-
68
- # A VO should at least contain a default group
69
- new_registry = RegistryConfig(
70
- IdP=IdpConfig(URL=idp_url, ClientID=idp_client_id),
71
- DefaultGroup=default_group,
72
- Users={},
73
- Groups={
74
- default_group: GroupConfig(
75
- Properties={"NormalUser"}, Quota=None, Users=set()
76
- )
77
- },
78
- )
79
-
80
- config = ConfigSource.create_from_url(backend_url=repo_path).read_config()
81
-
82
- if vo in config.Registry:
83
- typer.echo(f"ERROR: VO {vo} already exists", err=True)
84
- raise typer.Exit(1)
85
-
86
- config.Registry[vo] = new_registry
87
-
88
- update_config_and_commit(
89
- repo_path=repo_path,
90
- config=config,
91
- message=f"Added vo {vo} registry (default group {default_group} and idp {idp_url})",
92
- )
93
- typer.echo(f"Successfully added vo to {config_repo}", err=True)
94
-
95
-
96
- @app.command()
97
- def add_group(
98
- config_repo: str,
99
- *,
100
- vo: Annotated[str, typer.Option()],
101
- group: Annotated[str, typer.Option()],
102
- properties: list[str] = ["NormalUser"],
103
- ):
104
- """Add a group to an existing vo in the configuration repository."""
105
- # TODO: The use of TypeAdapter should be moved in to typer itself
106
- config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
107
- if config_repo.scheme != "git+file" or config_repo.path is None:
108
- raise NotImplementedError("Only git+file:// URLs are supported")
109
- repo_path = Path(config_repo.path)
110
-
111
- new_group = GroupConfig(Properties=set(properties), Quota=None, Users=set())
112
-
113
- config = ConfigSource.create_from_url(backend_url=repo_path).read_config()
6
+ __all__ = ("app",)
114
7
 
115
- if vo not in config.Registry:
116
- typer.echo(f"ERROR: Virtual Organization {vo} does not exist", err=True)
117
- raise typer.Exit(1)
118
-
119
- if group in config.Registry[vo].Groups.keys():
120
- typer.echo(f"ERROR: Group {group} already exists in {vo}", err=True)
121
- raise typer.Exit(1)
122
-
123
- config.Registry[vo].Groups[group] = new_group
124
-
125
- update_config_and_commit(
126
- repo_path=repo_path, config=config, message=f"Added group {group} in {vo}"
127
- )
128
- typer.echo(f"Successfully added group to {config_repo}", err=True)
129
-
130
-
131
- @app.command()
132
- def add_user(
133
- config_repo: str,
134
- *,
135
- vo: Annotated[str, typer.Option()],
136
- groups: Annotated[Optional[list[str]], typer.Option("--group")] = None,
137
- sub: Annotated[str, typer.Option()],
138
- preferred_username: Annotated[str, typer.Option()],
139
- ):
140
- """Add a user to an existing vo and group."""
141
- # TODO: The use of TypeAdapter should be moved in to typer itself
142
- config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
143
- if config_repo.scheme != "git+file" or config_repo.path is None:
144
- raise NotImplementedError("Only git+file:// URLs are supported")
145
-
146
- repo_path = Path(config_repo.path)
147
-
148
- new_user = UserConfig(PreferedUsername=preferred_username)
149
-
150
- config = ConfigSource.create_from_url(backend_url=repo_path).read_config()
151
-
152
- if vo not in config.Registry:
153
- typer.echo(f"ERROR: Virtual Organization {vo} does not exist", err=True)
154
- raise typer.Exit(1)
155
-
156
- if sub in config.Registry[vo].Users:
157
- typer.echo(f"ERROR: User {sub} already exists", err=True)
158
- raise typer.Exit(1)
159
-
160
- config.Registry[vo].Users[sub] = new_user
161
-
162
- if not groups:
163
- groups = [config.Registry[vo].DefaultGroup]
164
-
165
- for group in set(groups):
166
- if group not in config.Registry[vo].Groups:
167
- typer.echo(f"ERROR: Group {group} does not exist in {vo}", err=True)
168
- raise typer.Exit(1)
169
- if sub in config.Registry[vo].Groups[group].Users:
170
- typer.echo(f"ERROR: User {sub} already exists in group {group}", err=True)
171
- raise typer.Exit(1)
172
-
173
- config.Registry[vo].Groups[group].Users.add(sub)
174
-
175
- update_config_and_commit(
176
- repo_path=repo_path,
177
- config=config,
178
- message=f"Added user {sub} ({preferred_username}) to vo {vo} and groups {groups}",
179
- )
180
- typer.echo(f"Successfully added user to {config_repo}", err=True)
181
-
182
-
183
- def update_config_and_commit(repo_path: Path, config: Config, message: str):
184
- """Update the yaml file in the repo and commit it."""
185
- repo = git.Repo(repo_path)
186
- yaml_path = repo_path / "default.yml"
187
- typer.echo(f"Writing back configuration to {yaml_path}", err=True)
188
- yaml_path.write_text(
189
- yaml.safe_dump(config.model_dump(exclude_unset=True, mode="json"))
190
- )
191
- repo.index.add([yaml_path.relative_to(repo_path)])
192
- repo.index.commit(message)
8
+ app.add_typer(legacy.app, name="legacy")
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import git
7
+ import typer
8
+ import yaml
9
+ from pydantic import TypeAdapter
10
+
11
+ from diracx.core.config import ConfigSource, ConfigSourceUrl
12
+ from diracx.core.config.schema import (
13
+ Config,
14
+ DIRACConfig,
15
+ GroupConfig,
16
+ IdpConfig,
17
+ OperationsConfig,
18
+ RegistryConfig,
19
+ UserConfig,
20
+ )
21
+
22
+ from ..utils import AsyncTyper
23
+
24
+ app = AsyncTyper()
25
+
26
+
27
+ @app.command()
28
+ def generate_cs(config_repo: str):
29
+ """Generate a minimal DiracX configuration repository."""
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:
33
+ raise NotImplementedError("Only git+file:// URLs are supported")
34
+ repo_path = Path(config_repo.path)
35
+ if repo_path.exists() and list(repo_path.iterdir()):
36
+ typer.echo(f"ERROR: Directory {repo_path} already exists", err=True)
37
+ raise typer.Exit(1)
38
+
39
+ config = Config(
40
+ Registry={},
41
+ DIRAC=DIRACConfig(),
42
+ Operations={"Defaults": OperationsConfig()},
43
+ )
44
+
45
+ git.Repo.init(repo_path, initial_branch="master")
46
+ update_config_and_commit(
47
+ repo_path=repo_path, config=config, message="Initial commit"
48
+ )
49
+ typer.echo(f"Successfully created repo in {config_repo}", err=True)
50
+
51
+
52
+ @app.command()
53
+ def add_vo(
54
+ config_repo: str,
55
+ *,
56
+ vo: Annotated[str, typer.Option()],
57
+ default_group: Optional[str] = "user",
58
+ idp_url: Annotated[str, typer.Option()],
59
+ idp_client_id: Annotated[str, typer.Option()],
60
+ ):
61
+ """Add a registry entry (vo) to an existing configuration repository."""
62
+ # TODO: The use of TypeAdapter should be moved in to typer itself
63
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
64
+ if config_repo.scheme != "git+file" or config_repo.path is None:
65
+ raise NotImplementedError("Only git+file:// URLs are supported")
66
+ repo_path = Path(config_repo.path)
67
+
68
+ # A VO should at least contain a default group
69
+ new_registry = RegistryConfig(
70
+ IdP=IdpConfig(URL=idp_url, ClientID=idp_client_id),
71
+ DefaultGroup=default_group,
72
+ Users={},
73
+ Groups={
74
+ default_group: GroupConfig(
75
+ Properties={"NormalUser"}, Quota=None, Users=set()
76
+ )
77
+ },
78
+ )
79
+
80
+ config = ConfigSource.create_from_url(backend_url=repo_path).read_config()
81
+
82
+ if vo in config.Registry:
83
+ typer.echo(f"ERROR: VO {vo} already exists", err=True)
84
+ raise typer.Exit(1)
85
+
86
+ config.Registry[vo] = new_registry
87
+
88
+ update_config_and_commit(
89
+ repo_path=repo_path,
90
+ config=config,
91
+ message=f"Added vo {vo} registry (default group {default_group} and idp {idp_url})",
92
+ )
93
+ typer.echo(f"Successfully added vo to {config_repo}", err=True)
94
+
95
+
96
+ @app.command()
97
+ def add_group(
98
+ config_repo: str,
99
+ *,
100
+ vo: Annotated[str, typer.Option()],
101
+ group: Annotated[str, typer.Option()],
102
+ properties: list[str] = ["NormalUser"],
103
+ ):
104
+ """Add a group to an existing vo in the configuration repository."""
105
+ # TODO: The use of TypeAdapter should be moved in to typer itself
106
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
107
+ if config_repo.scheme != "git+file" or config_repo.path is None:
108
+ raise NotImplementedError("Only git+file:// URLs are supported")
109
+ repo_path = Path(config_repo.path)
110
+
111
+ new_group = GroupConfig(Properties=set(properties), Quota=None, Users=set())
112
+
113
+ config = ConfigSource.create_from_url(backend_url=repo_path).read_config()
114
+
115
+ if vo not in config.Registry:
116
+ typer.echo(f"ERROR: Virtual Organization {vo} does not exist", err=True)
117
+ raise typer.Exit(1)
118
+
119
+ if group in config.Registry[vo].Groups.keys():
120
+ typer.echo(f"ERROR: Group {group} already exists in {vo}", err=True)
121
+ raise typer.Exit(1)
122
+
123
+ config.Registry[vo].Groups[group] = new_group
124
+
125
+ update_config_and_commit(
126
+ repo_path=repo_path, config=config, message=f"Added group {group} in {vo}"
127
+ )
128
+ typer.echo(f"Successfully added group to {config_repo}", err=True)
129
+
130
+
131
+ @app.command()
132
+ def add_user(
133
+ config_repo: str,
134
+ *,
135
+ vo: Annotated[str, typer.Option()],
136
+ groups: Annotated[Optional[list[str]], typer.Option("--group")] = None,
137
+ sub: Annotated[str, typer.Option()],
138
+ preferred_username: Annotated[str, typer.Option()],
139
+ ):
140
+ """Add a user to an existing vo and group."""
141
+ # TODO: The use of TypeAdapter should be moved in to typer itself
142
+ config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo)
143
+ if config_repo.scheme != "git+file" or config_repo.path is None:
144
+ raise NotImplementedError("Only git+file:// URLs are supported")
145
+
146
+ repo_path = Path(config_repo.path)
147
+
148
+ new_user = UserConfig(PreferedUsername=preferred_username)
149
+
150
+ config = ConfigSource.create_from_url(backend_url=repo_path).read_config()
151
+
152
+ if vo not in config.Registry:
153
+ typer.echo(f"ERROR: Virtual Organization {vo} does not exist", err=True)
154
+ raise typer.Exit(1)
155
+
156
+ if sub in config.Registry[vo].Users:
157
+ typer.echo(f"ERROR: User {sub} already exists", err=True)
158
+ raise typer.Exit(1)
159
+
160
+ config.Registry[vo].Users[sub] = new_user
161
+
162
+ if not groups:
163
+ groups = [config.Registry[vo].DefaultGroup]
164
+
165
+ for group in set(groups):
166
+ if group not in config.Registry[vo].Groups:
167
+ typer.echo(f"ERROR: Group {group} does not exist in {vo}", err=True)
168
+ raise typer.Exit(1)
169
+ if sub in config.Registry[vo].Groups[group].Users:
170
+ typer.echo(f"ERROR: User {sub} already exists in group {group}", err=True)
171
+ raise typer.Exit(1)
172
+
173
+ config.Registry[vo].Groups[group].Users.add(sub)
174
+
175
+ update_config_and_commit(
176
+ repo_path=repo_path,
177
+ config=config,
178
+ message=f"Added user {sub} ({preferred_username}) to vo {vo} and groups {groups}",
179
+ )
180
+ typer.echo(f"Successfully added user to {config_repo}", err=True)
181
+
182
+
183
+ def update_config_and_commit(repo_path: Path, config: Config, message: str):
184
+ """Update the yaml file in the repo and commit it."""
185
+ repo = git.Repo(repo_path)
186
+ yaml_path = repo_path / "default.yml"
187
+ typer.echo(f"Writing back configuration to {yaml_path}", err=True)
188
+ yaml_path.write_text(
189
+ yaml.safe_dump(config.model_dump(exclude_unset=True, mode="json"))
190
+ )
191
+ repo.index.add([yaml_path.relative_to(repo_path)])
192
+ repo.index.commit(message)
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import base64
2
4
  import hashlib
3
5
  import json
@@ -40,17 +42,6 @@ class ConversionConfig(BaseModel):
40
42
  VOs: dict[str, VOConfig]
41
43
 
42
44
 
43
- # def parse_args():
44
- # parser = argparse.ArgumentParser("Convert the legacy DIRAC CS to the new format")
45
- # parser.add_argument("old_file", type=Path)
46
- # parser.add_argument("conversion_config", type=Path)
47
- # parser.add_argument("repo", type=Path)
48
- # args = parser.parse_args()
49
-
50
-
51
- # main(args.old_file, args.conversion_config, args.repo / DEFAULT_CONFIG_FILE)
52
-
53
-
54
45
  @app.command()
55
46
  def cs_sync(old_file: Path, new_file: Path):
56
47
  """Load the old CS and convert it to the new YAML format."""
diracx/cli/jobs.py CHANGED
@@ -1,5 +1,6 @@
1
1
  # Can't using PEP-604 with typer: https://github.com/tiangolo/typer/issues/348
2
2
  # from __future__ import annotations
3
+ from __future__ import annotations
3
4
 
4
5
  __all__ = ("app",)
5
6
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: diracx-cli
3
- Version: 0.0.1a23
3
+ Version: 0.0.1a24
4
4
  Summary: TODO
5
5
  License: GPL-3.0-only
6
6
  Classifier: Intended Audience :: Science/Research
@@ -17,7 +17,7 @@ Requires-Dist: diracx-core
17
17
  Requires-Dist: gitpython
18
18
  Requires-Dist: pydantic>=2.10
19
19
  Requires-Dist: rich
20
- Requires-Dist: typer
20
+ Requires-Dist: typer>=0.12.4
21
21
  Requires-Dist: pyyaml
22
22
  Provides-Extra: testing
23
23
  Requires-Dist: diracx-testing; extra == "testing"
@@ -0,0 +1,15 @@
1
+ diracx/cli/__init__.py,sha256=HB9Umd1DXskg8lrRY0ZnSaOBVYPD5Pl4PYRWts-ZR0E,808
2
+ diracx/cli/__main__.py,sha256=yGjYWjRcrrp5mJ0xD0v3rc7VIA9bzDib5D7LPAdH4OI,92
3
+ diracx/cli/auth.py,sha256=N9rBvRgyUeznTqG0Ck2pylClvXwR3IR6OyP4mTA50BA,4733
4
+ diracx/cli/config.py,sha256=-QPTTuH83epgFviO_af_DJBVjRakIrc6MbxkHg1KLlI,853
5
+ diracx/cli/jobs.py,sha256=qxV7y_WVtYzO6h55lgbWgk_ZC9eN1i1hIFHTpwxC9mE,4760
6
+ diracx/cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ diracx/cli/utils.py,sha256=NwhMMHwveKOdW2aoSqpnLnfOKhPnjmPPLpX69naPAzc,855
8
+ diracx/cli/internal/__init__.py,sha256=KZrzVcKu3YhNev2XF2KA2nttAa9ONU3CVUgatVMonJ4,143
9
+ diracx/cli/internal/config.py,sha256=xPT7lnJ3QPqJgaNJuMoUpV6CIIxZY_d7HKFb4uINb_8,6552
10
+ diracx/cli/internal/legacy.py,sha256=AQJnLZZDNmE1N6Vd6WdjG8kZPFWufBSez4Hhi0aQZYc,10538
11
+ diracx_cli-0.0.1a24.dist-info/METADATA,sha256=7O65DRbTkxA7kC0K2Z2JDS1N7oHYWefW_IwAslI9eMo,803
12
+ diracx_cli-0.0.1a24.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
13
+ diracx_cli-0.0.1a24.dist-info/entry_points.txt,sha256=b1909GHVOkFUiHVglNlpwia4Ug-7Ncrg-8D5xtYVAlw,169
14
+ diracx_cli-0.0.1a24.dist-info/top_level.txt,sha256=vJx10tdRlBX3rF2Psgk5jlwVGZNcL3m_7iQWwgPXt-U,7
15
+ diracx_cli-0.0.1a24.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,13 +0,0 @@
1
- diracx/cli/__init__.py,sha256=eenV2ezX845cIvDV2mTKqd23TLwXQBwD6Ho252h8WwA,5407
2
- diracx/cli/__main__.py,sha256=SM9tEc-fiW7cDHTKQRwgKobe5FfijHLYiAWfWaIM_zg,56
3
- diracx/cli/config.py,sha256=r5Lq_SN-1t3IzGAeS57ZzS-ukLhP6PMnmTXNld2pZXU,818
4
- diracx/cli/jobs.py,sha256=wV7-YMI_jUIflj87cBM4yuI2EF9K5mu2WsCG_eakWrg,4725
5
- diracx/cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- diracx/cli/utils.py,sha256=NwhMMHwveKOdW2aoSqpnLnfOKhPnjmPPLpX69naPAzc,855
7
- diracx/cli/internal/__init__.py,sha256=DCKzknHUEvo7PYiatZis15-gkxhga5WU4cyVZ6LCkmA,6578
8
- diracx/cli/internal/legacy.py,sha256=bhq8vfHoL0fZGhtye0EqMsucwepOYpQkj3UGe-rHNhY,10882
9
- diracx_cli-0.0.1a23.dist-info/METADATA,sha256=OUALphzbb6P9u36Wk9uPcwbrm6Yj8rixezOJdbhSsSs,795
10
- diracx_cli-0.0.1a23.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
11
- diracx_cli-0.0.1a23.dist-info/entry_points.txt,sha256=b1909GHVOkFUiHVglNlpwia4Ug-7Ncrg-8D5xtYVAlw,169
12
- diracx_cli-0.0.1a23.dist-info/top_level.txt,sha256=vJx10tdRlBX3rF2Psgk5jlwVGZNcL3m_7iQWwgPXt-U,7
13
- diracx_cli-0.0.1a23.dist-info/RECORD,,