flyte 2.0.0b25__py3-none-any.whl → 2.0.0b28__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.

Potentially problematic release.


This version of flyte might be problematic. Click here for more details.

Files changed (54) hide show
  1. flyte/__init__.py +2 -0
  2. flyte/_bin/runtime.py +8 -0
  3. flyte/_code_bundle/_utils.py +4 -4
  4. flyte/_code_bundle/bundle.py +1 -1
  5. flyte/_constants.py +1 -0
  6. flyte/_deploy.py +0 -1
  7. flyte/_excepthook.py +1 -1
  8. flyte/_initialize.py +10 -0
  9. flyte/_interface.py +2 -0
  10. flyte/_internal/imagebuild/docker_builder.py +3 -1
  11. flyte/_internal/imagebuild/remote_builder.py +3 -1
  12. flyte/_internal/resolvers/_task_module.py +4 -37
  13. flyte/_internal/runtime/convert.py +3 -2
  14. flyte/_internal/runtime/entrypoints.py +24 -1
  15. flyte/_internal/runtime/rusty.py +3 -3
  16. flyte/_internal/runtime/task_serde.py +19 -4
  17. flyte/_internal/runtime/trigger_serde.py +2 -2
  18. flyte/_map.py +2 -35
  19. flyte/_module.py +68 -0
  20. flyte/_resources.py +38 -0
  21. flyte/_run.py +23 -6
  22. flyte/_task.py +1 -2
  23. flyte/_task_plugins.py +4 -2
  24. flyte/_trigger.py +623 -5
  25. flyte/_utils/__init__.py +2 -1
  26. flyte/_utils/asyn.py +3 -1
  27. flyte/_utils/docker_credentials.py +173 -0
  28. flyte/_utils/module_loader.py +15 -0
  29. flyte/_version.py +3 -3
  30. flyte/cli/_common.py +15 -3
  31. flyte/cli/_create.py +100 -3
  32. flyte/cli/_deploy.py +38 -4
  33. flyte/cli/_plugins.py +208 -0
  34. flyte/cli/_run.py +69 -6
  35. flyte/cli/_serve.py +154 -0
  36. flyte/cli/main.py +6 -0
  37. flyte/connectors/__init__.py +3 -0
  38. flyte/connectors/_connector.py +270 -0
  39. flyte/connectors/_server.py +183 -0
  40. flyte/connectors/utils.py +26 -0
  41. flyte/models.py +13 -4
  42. flyte/remote/_client/auth/_channel.py +9 -5
  43. flyte/remote/_console.py +3 -2
  44. flyte/remote/_secret.py +6 -4
  45. flyte/remote/_trigger.py +2 -2
  46. flyte/types/_type_engine.py +1 -2
  47. {flyte-2.0.0b25.data → flyte-2.0.0b28.data}/scripts/runtime.py +8 -0
  48. {flyte-2.0.0b25.dist-info → flyte-2.0.0b28.dist-info}/METADATA +6 -2
  49. {flyte-2.0.0b25.dist-info → flyte-2.0.0b28.dist-info}/RECORD +54 -46
  50. {flyte-2.0.0b25.data → flyte-2.0.0b28.data}/scripts/debug.py +0 -0
  51. {flyte-2.0.0b25.dist-info → flyte-2.0.0b28.dist-info}/WHEEL +0 -0
  52. {flyte-2.0.0b25.dist-info → flyte-2.0.0b28.dist-info}/entry_points.txt +0 -0
  53. {flyte-2.0.0b25.dist-info → flyte-2.0.0b28.dist-info}/licenses/LICENSE +0 -0
  54. {flyte-2.0.0b25.dist-info → flyte-2.0.0b28.dist-info}/top_level.txt +0 -0
flyte/_utils/__init__.py CHANGED
@@ -9,12 +9,13 @@ from .coro_management import run_coros
9
9
  from .file_handling import filehash_update, update_hasher_for_source
10
10
  from .helpers import get_cwd_editable_install
11
11
  from .lazy_module import lazy_module
12
- from .module_loader import load_python_modules
12
+ from .module_loader import adjust_sys_path, load_python_modules
13
13
  from .org_discovery import hostname_from_url, org_from_endpoint, sanitize_endpoint
14
14
  from .uv_script_parser import parse_uv_script_file
15
15
 
16
16
  __all__ = [
17
17
  "AsyncLRUCache",
18
+ "adjust_sys_path",
18
19
  "filehash_update",
19
20
  "get_cwd_editable_install",
20
21
  "hostname_from_url",
flyte/_utils/asyn.py CHANGED
@@ -9,6 +9,8 @@ async def async_add(a: int, b: int) -> int:
9
9
  result = run_sync(async_add, a=10, b=12)
10
10
  """
11
11
 
12
+ from __future__ import annotations
13
+
12
14
  import asyncio
13
15
  import atexit
14
16
  import functools
@@ -88,7 +90,7 @@ class _TaskRunner:
88
90
 
89
91
 
90
92
  class _AsyncLoopManager:
91
- def __init__(self):
93
+ def __init__(self: _AsyncLoopManager):
92
94
  self._runner_map: dict[str, _TaskRunner] = {}
93
95
 
94
96
  def run_sync(self, coro_func: Callable[..., Awaitable[T]], *args, **kwargs) -> T:
@@ -0,0 +1,173 @@
1
+ """Helper functions for creating Docker registry credentials for image pull secrets."""
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _CONFIG_JSON = "config.json"
14
+ _DEFAULT_CONFIG_PATH = f"~/.docker/{_CONFIG_JSON}"
15
+ _CRED_HELPERS = "credHelpers"
16
+ _CREDS_STORE = "credsStore"
17
+
18
+
19
+ def _load_docker_config(config_path: str | Path | None = None) -> dict[str, Any]:
20
+ """
21
+ Load Docker config from specified path.
22
+
23
+ Args:
24
+ config_path: Path to Docker config file. If None, uses DOCKER_CONFIG env var
25
+ or defaults to ~/.docker/config.json
26
+
27
+ Returns:
28
+ Dictionary containing Docker config
29
+
30
+ Raises:
31
+ FileNotFoundError: If the config file does not exist
32
+ json.JSONDecodeError: If the config file is not valid JSON
33
+ """
34
+ if not config_path:
35
+ docker_config_env = os.environ.get("DOCKER_CONFIG")
36
+ if docker_config_env:
37
+ config_path = Path(docker_config_env) / _CONFIG_JSON
38
+ else:
39
+ config_path = Path(_DEFAULT_CONFIG_PATH).expanduser()
40
+ else:
41
+ config_path = Path(config_path).expanduser()
42
+
43
+ with open(config_path) as f:
44
+ return json.load(f)
45
+
46
+
47
+ def _get_credential_helper(config: dict[str, Any], registry: str | None = None) -> str | None:
48
+ """Get credential helper for registry or global default."""
49
+ if registry and _CRED_HELPERS in config and registry in config[_CRED_HELPERS]:
50
+ return config[_CRED_HELPERS].get(registry)
51
+ return config.get(_CREDS_STORE)
52
+
53
+
54
+ def _get_credentials_from_helper(helper: str, registry: str) -> tuple[str, str] | None:
55
+ """
56
+ Get credentials from system credential helper.
57
+
58
+ Args:
59
+ helper: Name of the credential helper (e.g., "osxkeychain", "wincred")
60
+ registry: Registry hostname to get credentials for
61
+
62
+ Returns:
63
+ Tuple of (username, password) or None if credentials cannot be retrieved
64
+ """
65
+ helper_cmd = f"docker-credential-{helper}"
66
+
67
+ try:
68
+ process = subprocess.Popen(
69
+ [helper_cmd, "get"],
70
+ stdin=subprocess.PIPE,
71
+ stdout=subprocess.PIPE,
72
+ stderr=subprocess.PIPE,
73
+ text=True,
74
+ )
75
+ output, error = process.communicate(input=registry)
76
+
77
+ if process.returncode != 0:
78
+ logger.error(f"Credential helper error: {error}")
79
+ return None
80
+
81
+ creds = json.loads(output)
82
+ return creds.get("Username"), creds.get("Secret")
83
+ except FileNotFoundError:
84
+ logger.error(f"Credential helper {helper_cmd} not found in PATH")
85
+ return None
86
+ except Exception as e:
87
+ logger.error(f"Error getting credentials: {e!s}")
88
+ return None
89
+
90
+
91
+ def create_dockerconfigjson_from_config(
92
+ registries: list[str] | None = None,
93
+ docker_config_path: str | Path | None = None,
94
+ ) -> str:
95
+ """
96
+ Create a dockerconfigjson string from existing Docker config.
97
+
98
+ This function extracts Docker registry credentials from the user's Docker config file
99
+ and creates a JSON string containing only the credentials for the specified registries.
100
+ It handles credentials stored directly in the config file as well as those managed by
101
+ credential helpers.
102
+
103
+ Args:
104
+ registries: List of registries to extract credentials for. If None, all registries
105
+ from the config will be used.
106
+ docker_config_path: Path to the Docker config file. If None, the function will look
107
+ for the config file in the standard locations.
108
+
109
+ Returns:
110
+ JSON string in dockerconfigjson format: {"auths": {"registry": {"auth": "..."}}}
111
+
112
+ Raises:
113
+ FileNotFoundError: If Docker config file cannot be found
114
+ ValueError: If no credentials can be extracted
115
+ """
116
+ config = _load_docker_config(docker_config_path)
117
+
118
+ # Create new config structure with empty auths
119
+ new_config: dict[str, Any] = {"auths": {}}
120
+
121
+ # Use specified registries or all from config
122
+ target_registries = registries or list(config.get("auths", {}).keys())
123
+
124
+ if not target_registries:
125
+ raise ValueError("No registries found in Docker config and none specified")
126
+
127
+ for registry in target_registries:
128
+ registry_config = config.get("auths", {}).get(registry, {})
129
+ if registry_config.get("auth"):
130
+ # Direct auth token exists
131
+ new_config["auths"][registry] = {"auth": registry_config["auth"]}
132
+ else:
133
+ # Try to get credentials from helper
134
+ helper = _get_credential_helper(config, registry)
135
+ if helper:
136
+ creds = _get_credentials_from_helper(helper, registry)
137
+ if creds:
138
+ username, password = creds
139
+ auth_string = f"{username}:{password}"
140
+ new_config["auths"][registry] = {"auth": base64.b64encode(auth_string.encode()).decode()}
141
+ else:
142
+ logger.warning(f"Could not retrieve credentials for {registry} from credential helper")
143
+ else:
144
+ logger.warning(f"No credentials found for {registry}")
145
+
146
+ if not new_config["auths"]:
147
+ raise ValueError(f"No credentials could be extracted for registries: {', '.join(target_registries)}")
148
+
149
+ return json.dumps(new_config)
150
+
151
+
152
+ def create_dockerconfigjson_from_credentials(
153
+ registry: str,
154
+ username: str,
155
+ password: str,
156
+ ) -> str:
157
+ """
158
+ Create a dockerconfigjson string from explicit credentials.
159
+
160
+ Args:
161
+ registry: Registry hostname (e.g., "ghcr.io", "docker.io")
162
+ username: Username or token name for the registry
163
+ password: Password or access token for the registry
164
+
165
+ Returns:
166
+ JSON string in dockerconfigjson format: {"auths": {"registry": {"auth": "..."}}}
167
+ """
168
+ auth_string = f"{username}:{password}"
169
+ auth_token = base64.b64encode(auth_string.encode()).decode()
170
+
171
+ config = {"auths": {registry: {"auth": auth_token}}}
172
+
173
+ return json.dumps(config)
@@ -6,6 +6,8 @@ from pathlib import Path
6
6
  from typing import List, Tuple
7
7
 
8
8
  import flyte.errors
9
+ from flyte._constants import FLYTE_SYS_PATH
10
+ from flyte._logging import logger
9
11
 
10
12
 
11
13
  def load_python_modules(path: Path, recursive: bool = False) -> Tuple[List[str], List[Tuple[Path, str]]]:
@@ -87,3 +89,16 @@ def _load_module_from_file(file_path: Path) -> str | None:
87
89
 
88
90
  except Exception as e:
89
91
  raise flyte.errors.ModuleLoadError(f"Failed to load module from {file_path}: {e}") from e
92
+
93
+
94
+ def adjust_sys_path():
95
+ """
96
+ Adjust sys.path to include local sys.path entries under the root directory.
97
+ """
98
+ if "." not in sys.path or os.getcwd() not in sys.path:
99
+ sys.path.insert(0, ".")
100
+ logger.info(f"Added {os.getcwd()} to sys.path")
101
+ for p in os.environ.get(FLYTE_SYS_PATH, "").split(":"):
102
+ if p and p not in sys.path:
103
+ sys.path.insert(0, p)
104
+ logger.info(f"Added {p} to sys.path")
flyte/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '2.0.0b25'
32
- __version_tuple__ = version_tuple = (2, 0, 0, 'b25')
31
+ __version__ = version = '2.0.0b28'
32
+ __version_tuple__ = version_tuple = (2, 0, 0, 'b28')
33
33
 
34
- __commit_id__ = commit_id = 'gf704e4b75'
34
+ __commit_id__ = commit_id = 'gb62a542c8'
flyte/cli/_common.py CHANGED
@@ -120,6 +120,7 @@ class CLIConfig:
120
120
  domain: str | None = None,
121
121
  root_dir: str | None = None,
122
122
  images: tuple[str, ...] | None = None,
123
+ sync_local_sys_paths: bool = True,
123
124
  ):
124
125
  from flyte.config._config import TaskConfig
125
126
 
@@ -140,7 +141,13 @@ class CLIConfig:
140
141
 
141
142
  updated_config = self.config.with_params(platform_cfg, task_cfg)
142
143
 
143
- flyte.init_from_config(updated_config, log_level=self.log_level, root_dir=root_dir, images=images)
144
+ flyte.init_from_config(
145
+ updated_config,
146
+ log_level=self.log_level,
147
+ root_dir=root_dir,
148
+ images=images,
149
+ sync_local_sys_paths=sync_local_sys_paths,
150
+ )
144
151
 
145
152
 
146
153
  class InvokeBaseMixin:
@@ -440,7 +447,12 @@ def parse_images(cfg: Config, values: tuple[str, ...] | None) -> None:
440
447
 
441
448
  @lru_cache()
442
449
  def initialize_config(
443
- ctx: click.Context, project: str, domain: str, root_dir: str | None = None, images: tuple[str, ...] | None = None
450
+ ctx: click.Context,
451
+ project: str,
452
+ domain: str,
453
+ root_dir: str | None = None,
454
+ images: tuple[str, ...] | None = None,
455
+ sync_local_sys_paths: bool = True,
444
456
  ):
445
457
  obj: CLIConfig | None = ctx.obj
446
458
  if obj is None:
@@ -448,5 +460,5 @@ def initialize_config(
448
460
 
449
461
  obj = CLIConfig(flyte.config.auto(), ctx)
450
462
 
451
- obj.init(project, domain, root_dir, images)
463
+ obj.init(project, domain, root_dir, images, sync_local_sys_paths)
452
464
  return obj
flyte/cli/_create.py CHANGED
@@ -24,18 +24,49 @@ def create():
24
24
  prompt="Enter secret value",
25
25
  hide_input=True,
26
26
  cls=MutuallyExclusiveOption,
27
- mutually_exclusive=["from_file"],
27
+ mutually_exclusive=["from_file", "from_docker_config", "registry"],
28
28
  )
29
29
  @click.option(
30
30
  "--from-file",
31
31
  type=click.Path(exists=True),
32
32
  help="Path to the file with the binary secret.",
33
33
  cls=MutuallyExclusiveOption,
34
- mutually_exclusive=["value"],
34
+ mutually_exclusive=["value", "from_docker_config", "registry"],
35
35
  )
36
36
  @click.option(
37
37
  "--type", type=click.Choice(get_args(SecretTypes)), default="regular", help="Type of the secret.", show_default=True
38
38
  )
39
+ @click.option(
40
+ "--from-docker-config",
41
+ is_flag=True,
42
+ help="Create image pull secret from Docker config file (only for --type image_pull).",
43
+ cls=MutuallyExclusiveOption,
44
+ mutually_exclusive=["value", "from_file", "registry", "username", "password"],
45
+ )
46
+ @click.option(
47
+ "--docker-config-path",
48
+ type=click.Path(exists=True),
49
+ help="Path to Docker config file (defaults to ~/.docker/config.json or $DOCKER_CONFIG).",
50
+ )
51
+ @click.option(
52
+ "--registries",
53
+ help="Comma-separated list of registries to include (only with --from-docker-config).",
54
+ )
55
+ @click.option(
56
+ "--registry",
57
+ help="Registry hostname (e.g., ghcr.io, docker.io) for explicit credentials (only for --type image_pull).",
58
+ cls=MutuallyExclusiveOption,
59
+ mutually_exclusive=["value", "from_file", "from_docker_config"],
60
+ )
61
+ @click.option(
62
+ "--username",
63
+ help="Username for the registry (only with --registry).",
64
+ )
65
+ @click.option(
66
+ "--password",
67
+ help="Password for the registry (only with --registry). If not provided, will prompt.",
68
+ hide_input=True,
69
+ )
39
70
  @click.pass_obj
40
71
  def secret(
41
72
  cfg: common.CLIConfig,
@@ -43,6 +74,12 @@ def secret(
43
74
  value: str | bytes | None = None,
44
75
  from_file: str | None = None,
45
76
  type: SecretTypes = "regular",
77
+ from_docker_config: bool = False,
78
+ docker_config_path: str | None = None,
79
+ registries: str | None = None,
80
+ registry: str | None = None,
81
+ username: str | None = None,
82
+ password: str | None = None,
46
83
  project: str | None = None,
47
84
  domain: str | None = None,
48
85
  ):
@@ -73,9 +110,24 @@ def secret(
73
110
  Other secrets should be specified as `regular`.
74
111
  If no type is specified, `regular` is assumed.
75
112
 
113
+ For image pull secrets, you have several options:
114
+
115
+ 1. Interactive mode (prompts for registry, username, password):
76
116
  ```bash
77
117
  $ flyte create secret my_secret --type image_pull
78
118
  ```
119
+
120
+ 2. With explicit credentials:
121
+ ```bash
122
+ $ flyte create secret my_secret --type image_pull --registry ghcr.io --username myuser
123
+ ```
124
+
125
+ 3. Lastly, you can create a secret from your existing Docker installation (i.e., you've run `docker login` in
126
+ the past) and you just want to pull from those credentials. Since you may have logged in to multiple registries,
127
+ you can specify which registries to include. If no registries are specified, all registries are added.
128
+ ```bash
129
+ $ flyte create secret my_secret --type image_pull --from-docker-config --registries ghcr.io,docker.io
130
+ ```
79
131
  """
80
132
  from flyte.remote import Secret
81
133
 
@@ -84,9 +136,54 @@ def secret(
84
136
  project = "" if project is None else project
85
137
  domain = "" if domain is None else domain
86
138
  cfg.init(project, domain)
87
- if from_file:
139
+
140
+ # Handle image pull secret creation
141
+ if type == "image_pull":
142
+ if project != "" or domain != "":
143
+ raise click.ClickException("Project and domain must not be set when creating an image pull secret.")
144
+
145
+ if from_docker_config:
146
+ # Mode 3: From Docker config
147
+ from flyte._utils.docker_credentials import create_dockerconfigjson_from_config
148
+
149
+ registry_list = [r.strip() for r in registries.split(",")] if registries else None
150
+ try:
151
+ value = create_dockerconfigjson_from_config(
152
+ registries=registry_list,
153
+ docker_config_path=docker_config_path,
154
+ )
155
+ except Exception as e:
156
+ raise click.ClickException(f"Failed to create dockerconfigjson from Docker config: {e}") from e
157
+
158
+ elif registry:
159
+ # Mode 2: Explicit credentials
160
+ from flyte._utils.docker_credentials import create_dockerconfigjson_from_credentials
161
+
162
+ if not username:
163
+ username = click.prompt("Username")
164
+ if not password:
165
+ password = click.prompt("Password", hide_input=True)
166
+
167
+ value = create_dockerconfigjson_from_credentials(registry, username, password)
168
+
169
+ else:
170
+ # Mode 1: Interactive prompts
171
+ from flyte._utils.docker_credentials import create_dockerconfigjson_from_credentials
172
+
173
+ registry = click.prompt("Registry (e.g., ghcr.io, docker.io)")
174
+ username = click.prompt("Username")
175
+ password = click.prompt("Password", hide_input=True)
176
+
177
+ value = create_dockerconfigjson_from_credentials(registry, username, password)
178
+
179
+ elif from_file:
88
180
  with open(from_file, "rb") as f:
89
181
  value = f.read()
182
+
183
+ # Encode string values to bytes
184
+ if isinstance(value, str):
185
+ value = value.encode("utf-8")
186
+
90
187
  Secret.create(name=name, value=value, type=type)
91
188
 
92
189
 
flyte/cli/_deploy.py CHANGED
@@ -83,6 +83,19 @@ class DeployArguments:
83
83
  )
84
84
  },
85
85
  )
86
+ no_sync_local_sys_paths: bool = field(
87
+ default=True,
88
+ metadata={
89
+ "click.option": click.Option(
90
+ ["--no-sync-local-sys-paths"],
91
+ is_flag=True,
92
+ flag_value=True,
93
+ default=False,
94
+ help="Disable synchronization of local sys.path entries under the root directory "
95
+ "to the remote container.",
96
+ )
97
+ },
98
+ )
86
99
 
87
100
  @classmethod
88
101
  def from_dict(cls, d: Dict[str, Any]) -> "DeployArguments":
@@ -107,7 +120,12 @@ class DeployEnvCommand(click.RichCommand):
107
120
  console = common.get_console()
108
121
  console.print(f"Deploying root - environment: {self.env_name}")
109
122
  obj: CLIConfig = ctx.obj
110
- obj.init(self.deploy_args.project, self.deploy_args.domain, root_dir=self.deploy_args.root_dir)
123
+ obj.init(
124
+ self.deploy_args.project,
125
+ self.deploy_args.domain,
126
+ root_dir=self.deploy_args.root_dir,
127
+ sync_local_sys_paths=not self.deploy_args.no_sync_local_sys_paths,
128
+ )
111
129
  with console.status("Deploying...", spinner="dots"):
112
130
  deployment = flyte.deploy(
113
131
  self.env,
@@ -161,7 +179,11 @@ class DeployEnvRecursiveCommand(click.Command):
161
179
  f"Failed to load {len(failed_paths)} files. Use --ignore-load-errors to ignore these errors."
162
180
  )
163
181
  # Now start connection and deploy all environments
164
- obj.init(self.deploy_args.project, self.deploy_args.domain)
182
+ obj.init(
183
+ self.deploy_args.project,
184
+ self.deploy_args.domain,
185
+ sync_local_sys_paths=not self.deploy_args.no_sync_local_sys_paths,
186
+ )
165
187
  with console.status("Deploying...", spinner="dots"):
166
188
  deployments = flyte.deploy(
167
189
  *all_envs,
@@ -190,11 +212,23 @@ class EnvPerFileGroup(common.ObjectsPerFileGroup):
190
212
  return {k: v for k, v in module.__dict__.items() if isinstance(v, flyte.Environment)}
191
213
 
192
214
  def list_commands(self, ctx):
193
- common.initialize_config(ctx, self.deploy_args.project, self.deploy_args.domain, self.deploy_args.root_dir)
215
+ common.initialize_config(
216
+ ctx,
217
+ self.deploy_args.project,
218
+ self.deploy_args.domain,
219
+ self.deploy_args.root_dir,
220
+ sync_local_sys_paths=not self.deploy_args.no_sync_local_sys_paths,
221
+ )
194
222
  return super().list_commands(ctx)
195
223
 
196
224
  def get_command(self, ctx, obj_name):
197
- common.initialize_config(ctx, self.deploy_args.project, self.deploy_args.domain, self.deploy_args.root_dir)
225
+ common.initialize_config(
226
+ ctx,
227
+ self.deploy_args.project,
228
+ self.deploy_args.domain,
229
+ self.deploy_args.root_dir,
230
+ sync_local_sys_paths=not self.deploy_args.no_sync_local_sys_paths,
231
+ )
198
232
  return super().get_command(ctx, obj_name)
199
233
 
200
234
  def _get_command_for_obj(self, ctx: click.Context, obj_name: str, obj: Any) -> click.Command: