cloudinary-cli 1.16.0__tar.gz → 1.16.1__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 (98) hide show
  1. {cloudinary_cli-1.16.0/cloudinary_cli.egg-info → cloudinary_cli-1.16.1}/PKG-INFO +1 -1
  2. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/__init__.py +2 -2
  3. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/auth.py +16 -0
  4. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/config.py +4 -7
  5. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/defaults.py +4 -7
  6. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/config_utils.py +40 -6
  7. cloudinary_cli-1.16.1/cloudinary_cli/version.py +1 -0
  8. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1/cloudinary_cli.egg-info}/PKG-INFO +1 -1
  9. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_config_oauth.py +60 -16
  10. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_default_config.py +86 -1
  11. cloudinary_cli-1.16.0/cloudinary_cli/version.py +0 -1
  12. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/LICENSE +0 -0
  13. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/MANIFEST.in +0 -0
  14. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/README.md +0 -0
  15. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/__init__.py +0 -0
  16. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/callback_page.py +0 -0
  17. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/flow.py +0 -0
  18. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/loopback_server.py +0 -0
  19. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/oauth_config.py +0 -0
  20. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/refresh.py +0 -0
  21. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/auth/session.py +0 -0
  22. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/cli.py +0 -0
  23. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/cli_group.py +0 -0
  24. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/__init__.py +0 -0
  25. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/admin.py +0 -0
  26. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/agent.py +0 -0
  27. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/overrides.py +0 -0
  28. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/provisioning.py +0 -0
  29. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/search.py +0 -0
  30. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/uploader.py +0 -0
  31. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/core/utils.py +0 -0
  32. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/__init__.py +0 -0
  33. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/clone.py +0 -0
  34. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/make.py +0 -0
  35. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/migrate.py +0 -0
  36. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/regen_derived.py +0 -0
  37. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/sync.py +0 -0
  38. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/modules/upload_dir.py +0 -0
  39. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/samples/__init__.py +0 -0
  40. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/html/media_library_widget +0 -0
  41. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/html/product_gallery +0 -0
  42. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/html/upload_widget +0 -0
  43. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/html/video_player +0 -0
  44. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/node/upload +0 -0
  45. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/python/base +0 -0
  46. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/python/explicit +0 -0
  47. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
  48. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/python/upload +0 -0
  49. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/templates/ruby/upload +0 -0
  50. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/__init__.py +0 -0
  51. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/api_utils.py +0 -0
  52. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/config_listing.py +0 -0
  53. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/config_resolver.py +0 -0
  54. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/file_utils.py +0 -0
  55. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/json_utils.py +0 -0
  56. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/search_utils.py +0 -0
  57. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/url_utils.py +0 -0
  58. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli/utils/utils.py +0 -0
  59. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli.egg-info/SOURCES.txt +0 -0
  60. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
  61. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli.egg-info/entry_points.txt +0 -0
  62. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli.egg-info/not-zip-safe +0 -0
  63. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli.egg-info/requires.txt +0 -0
  64. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/cloudinary_cli.egg-info/top_level.txt +0 -0
  65. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/requirements.txt +0 -0
  66. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/setup.cfg +0 -0
  67. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/setup.py +0 -0
  68. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/__init__.py +0 -0
  69. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/conftest.py +0 -0
  70. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/helper_test.py +0 -0
  71. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/oauth_helpers.py +0 -0
  72. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_auth_flow.py +0 -0
  73. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_auth_loopback.py +0 -0
  74. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_auth_region.py +0 -0
  75. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_auth_session.py +0 -0
  76. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli.py +0 -0
  77. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_agent.py +0 -0
  78. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_api.py +0 -0
  79. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_config.py +0 -0
  80. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_samples.py +0 -0
  81. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_search_api.py +0 -0
  82. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_url.py +0 -0
  83. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_cli_utils.py +0 -0
  84. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_config_cache.py +0 -0
  85. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_config_concurrency.py +0 -0
  86. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_config_permissions.py +0 -0
  87. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_file_utils.py +0 -0
  88. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_json_utils.py +0 -0
  89. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_modules/__init__.py +0 -0
  90. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_modules/test_cli_clone.py +0 -0
  91. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_modules/test_cli_make.py +0 -0
  92. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_modules/test_cli_sync.py +0 -0
  93. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_modules/test_cli_upload_dir.py +0 -0
  94. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_oauth_multiprocess.py +0 -0
  95. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_oauth_retry.py +0 -0
  96. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_oauth_token_seam.py +0 -0
  97. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_search_utils.py +0 -0
  98. {cloudinary_cli-1.16.0 → cloudinary_cli-1.16.1}/test/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.16.0
3
+ Version: 1.16.1
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -23,7 +23,7 @@ from cloudinary_cli.auth.refresh import (
23
23
  from cloudinary_cli.defaults import logger, normalize_region, DEFAULT_REGION, CLOUDINARY_REGION
24
24
  from cloudinary_cli.utils.config_utils import (
25
25
  load_config,
26
- remove_config_keys,
26
+ remove_named_config,
27
27
  save_named_config,
28
28
  is_reserved_config_name,
29
29
  )
@@ -78,7 +78,7 @@ def logout(name):
78
78
  return "not_oauth"
79
79
 
80
80
  revoked = _revoke_login(name, saved[name])
81
- remove_config_keys(name)
81
+ remove_named_config(name)
82
82
  return "removed" if revoked else "revoke_failed"
83
83
 
84
84
 
@@ -74,6 +74,9 @@ def _select_oauth_login():
74
74
  logger.info("No saved OAuth logins to log out of.")
75
75
  return "none", None
76
76
 
77
+ if len(names) == 1:
78
+ return _confirm_sole_login(names[0])
79
+
77
80
  echo("Saved OAuth logins:")
78
81
  for i, name in enumerate(names, start=1):
79
82
  echo(f" {i}) {name}")
@@ -92,3 +95,16 @@ def _select_oauth_login():
92
95
  logger.error(f"Invalid selection '{choice}'. Expected a number between 1 and {len(names)}.")
93
96
  return "invalid", None
94
97
  return "selected", names[int(choice) - 1]
98
+
99
+
100
+ def _confirm_sole_login(name):
101
+ """With a single saved login there is nothing to choose, so confirm that one rather than
102
+ presenting a one-item menu. Logging out still revokes a token, so it is not done unasked."""
103
+ choice = prompt_user(
104
+ f"Log out of '{name}'? [y/N]: ",
105
+ noninteractive_hint="Pass the configuration name directly: `cld logout <name>`.")
106
+ if choice is None:
107
+ return "invalid", None
108
+ if choice.strip().lower() not in ("y", "yes"):
109
+ return "cancelled", None
110
+ return "selected", name
@@ -6,11 +6,10 @@ from cloudinary_cli.utils.config_utils import (
6
6
  load_config,
7
7
  verify_cloudinary_url,
8
8
  save_named_config,
9
- remove_config_keys,
9
+ remove_named_config,
10
10
  show_cloudinary_config,
11
11
  is_valid_cloudinary_config,
12
12
  user_config_names,
13
- get_default_config_name,
14
13
  set_default_config,
15
14
  clear_default_config,
16
15
  is_reserved_config_name,
@@ -101,12 +100,10 @@ def config_command(new, ls, as_json, show, rm, from_url, default, set_default, u
101
100
  clear_default_config()
102
101
  logger.info("Default configuration cleared.")
103
102
  elif rm:
104
- if remove_config_keys(rm):
105
- logger.warning(f"Configuration '{rm}' not found.")
106
- else:
107
- if get_default_config_name() == rm:
108
- clear_default_config()
103
+ if remove_named_config(rm):
109
104
  logger.info(f"Configuration '{rm}' deleted.")
105
+ else:
106
+ logger.warning(f"Configuration '{rm}' not found.")
110
107
  elif ls:
111
108
  rows = list_configs()
112
109
  if as_json:
@@ -37,13 +37,10 @@ ACCOUNT_EMAIL_PARAM = "account_email"
37
37
  # "warning:" prefix, so the copy-pasteable command lines stay clean.
38
38
  NO_CONFIG_MESSAGE = (
39
39
  "No Cloudinary configuration found.\n"
40
- " - Log in with OAuth: cld login\n"
41
- " - Add an API-key config: cld config -n <name> "
42
- "cloudinary://<api_key>:<api_secret>@<cloud_name> --set-default\n"
43
- " - Set an existing config\n"
44
- " as the default: cld config -d <name>\n"
45
- " - AI agents only, create\n"
46
- " an account for a human: cld agent signup <email> <framework> <model> <goal>"
40
+ " - Log in with OAuth: cld login\n"
41
+ " - Add an API-key config: cld config -n <name> cloudinary://<api_key>:<api_secret>@<cloud_name> --set-default\n"
42
+ " - Set an existing config as the default: cld config -d <name>\n"
43
+ " - AI agents - provision an environment to be claimed by a human: cld agent signup <email> <framework> <model> <goal>"
47
44
  )
48
45
 
49
46
  # Shown when saved configs exist but none is active (no default set, no environment config, and no
@@ -72,10 +72,21 @@ def load_config():
72
72
  if stat is not None and stat == _config_cache_stat and _config_cache is not None:
73
73
  return dict(_config_cache) # copy: callers mutate the result in place (e.g. cfg.update(...))
74
74
  cfg = read_json_from_file(CLOUDINARY_CLI_CONFIG_FILE, does_not_exist_ok=True)
75
+ _rectify_config(cfg)
75
76
  _config_cache, _config_cache_stat = cfg, stat
76
77
  return dict(cfg)
77
78
 
78
79
 
80
+ def _rectify_config(cfg):
81
+ """Correct invalid state in a freshly-read config, in place, so no caller has to guard against
82
+ it. The repair reaches disk on the next write, since every mutation rebuilds the file from a
83
+ load_config result."""
84
+ default = cfg.get(DEFAULT_CONFIG_KEY)
85
+ if default is not None and default not in [k for k in cfg if k != DEFAULT_CONFIG_KEY]:
86
+ del cfg[DEFAULT_CONFIG_KEY]
87
+ logger.debug(f"Ignoring stored default '{default}': no such saved configuration.")
88
+
89
+
79
90
  def save_config(config):
80
91
  # 0600 from the start: the config file holds secrets (api_secret, account_url, OAuth tokens),
81
92
  # and writing the temp file 0600 before the atomic replace means it is never momentarily
@@ -140,13 +151,36 @@ def save_named_config(name, cloudinary_url, set_default=False):
140
151
  return "no"
141
152
 
142
153
 
154
+ def remove_named_config(name):
155
+ """
156
+ Delete a named configuration, and re-point the stored default when it named that config: a lone
157
+ surviving config is promoted, otherwise the default is cleared. The counterpart to
158
+ save_named_config: the single way to remove a saved config. Returns True if the configuration
159
+ existed and was removed.
160
+ """
161
+ with config_lock():
162
+ cfg = load_config()
163
+ if name not in user_config_names(cfg):
164
+ return False
165
+ del cfg[name]
166
+ if cfg.get(DEFAULT_CONFIG_KEY) == name:
167
+ del cfg[DEFAULT_CONFIG_KEY]
168
+ remaining = user_config_names(cfg)
169
+ if remaining and _is_sole_usable_config(remaining[0], cfg):
170
+ cfg[DEFAULT_CONFIG_KEY] = remaining[0]
171
+ save_config(cfg)
172
+ return True
173
+
174
+
143
175
  def _should_auto_default(name):
144
- cfg = load_config()
145
- return (
146
- user_config_names(cfg) == [name]
147
- and not is_env_configured()
148
- and not get_default_config_name()
149
- )
176
+ return _is_sole_usable_config(name) and not get_default_config_name()
177
+
178
+
179
+ def _is_sole_usable_config(name, cfg=None):
180
+ """Whether name is the only config a bare `cld <command>` could use: the only saved config, with
181
+ nothing configured in the environment. A stored default outranks the environment, so defaulting
182
+ to a saved config while CLOUDINARY_URL is set would silently override the user's choice."""
183
+ return user_config_names(cfg) == [name] and not is_env_configured()
150
184
 
151
185
 
152
186
  def user_config_names(cfg=None):
@@ -0,0 +1 @@
1
+ __version__ = "1.16.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.16.0
3
+ Version: 1.16.1
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -2,6 +2,7 @@ import json
2
2
  import os
3
3
  import time
4
4
  import unittest
5
+ from contextlib import contextmanager
5
6
  from unittest.mock import patch
6
7
 
7
8
  import cloudinary
@@ -11,6 +12,7 @@ from cloudinary_cli.auth.session import Session, to_cloudinary_url
11
12
  from test.oauth_helpers import jwt_access_token
12
13
  from cloudinary_cli.cli import cli
13
14
  from cloudinary_cli.utils.config_resolver import config_to_api_kwargs, get_cloudinary_config
15
+ from cloudinary_cli.utils import config_utils
14
16
  from cloudinary_cli.utils.config_utils import config_to_dict, show_cloudinary_config
15
17
 
16
18
 
@@ -20,6 +22,27 @@ def _oauth_url(cloud="eu-cloud", region="api-eu"):
20
22
  expires_at=int(time.time()) + 300, region=region, issuer="https://oauth.cloudinary.com/"))
21
23
 
22
24
 
25
+ @contextmanager
26
+ def _patched_config_store(initial=None):
27
+ """Back load_config/save_config with an in-memory dict (no real config.json or lock)."""
28
+ store = {"cfg": dict(initial or {})}
29
+
30
+ @contextmanager
31
+ def _noop_lock():
32
+ yield
33
+
34
+ def _load():
35
+ cfg = dict(store["cfg"])
36
+ config_utils._rectify_config(cfg) # the real load_config rectifies; mirror it here
37
+ return cfg
38
+
39
+ with patch("cloudinary_cli.utils.config_utils.load_config", side_effect=_load), \
40
+ patch("cloudinary_cli.utils.config_utils.save_config",
41
+ side_effect=lambda cfg: store.__setitem__("cfg", dict(cfg))), \
42
+ patch("cloudinary_cli.utils.config_utils.config_lock", _noop_lock):
43
+ yield store
44
+
45
+
23
46
  class _RestoresSdkConfig(unittest.TestCase):
24
47
  def setUp(self):
25
48
  self._env_snapshot = dict(os.environ)
@@ -43,7 +66,7 @@ class TestLogoutScope(unittest.TestCase):
43
66
  from cloudinary_cli.auth import logout
44
67
  saved = {"eu-cloud": _oauth_url()}
45
68
  with patch("cloudinary_cli.auth.load_config", return_value=saved), \
46
- patch("cloudinary_cli.auth.remove_config_keys") as remove, \
69
+ patch("cloudinary_cli.auth.remove_named_config") as remove, \
47
70
  patch("cloudinary_cli.auth.flow.revoke") as revoke:
48
71
  self.assertEqual("removed", logout("eu-cloud"))
49
72
  remove.assert_called_once_with("eu-cloud")
@@ -54,7 +77,7 @@ class TestLogoutScope(unittest.TestCase):
54
77
  from cloudinary_cli.auth import logout
55
78
  saved = {"eu-cloud": _oauth_url()}
56
79
  with patch("cloudinary_cli.auth.load_config", return_value=saved), \
57
- patch("cloudinary_cli.auth.remove_config_keys") as remove, \
80
+ patch("cloudinary_cli.auth.remove_named_config") as remove, \
58
81
  patch("cloudinary_cli.auth.flow.revoke", side_effect=requests.ConnectionError()):
59
82
  self.assertEqual("revoke_failed", logout("eu-cloud"))
60
83
  remove.assert_called_once_with("eu-cloud") # local entry removed despite revoke failure
@@ -63,14 +86,14 @@ class TestLogoutScope(unittest.TestCase):
63
86
  from cloudinary_cli.auth import logout
64
87
  saved = {"mykey": "cloudinary://key:secret@cloud"}
65
88
  with patch("cloudinary_cli.auth.load_config", return_value=saved), \
66
- patch("cloudinary_cli.auth.remove_config_keys") as remove:
89
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
67
90
  self.assertEqual("not_oauth", logout("mykey"))
68
91
  remove.assert_not_called()
69
92
 
70
93
  def test_missing_name(self):
71
94
  from cloudinary_cli.auth import logout
72
95
  with patch("cloudinary_cli.auth.load_config", return_value={}), \
73
- patch("cloudinary_cli.auth.remove_config_keys") as remove:
96
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
74
97
  self.assertEqual("not_found", logout("nope"))
75
98
  remove.assert_not_called()
76
99
 
@@ -79,13 +102,15 @@ class TestLogoutInteractiveSelect(unittest.TestCase):
79
102
  """`cld logout` with no name lists OAuth logins and removes the chosen one."""
80
103
 
81
104
  runner = CliRunner()
105
+ # The numbered menu only appears with more than one login, so menu tests need two.
106
+ _TWO_LOGINS = {"cloud-a": _oauth_url("cloud-a"), "cloud-b": _oauth_url("cloud-b")}
82
107
 
83
108
  def test_lists_only_oauth_and_removes_selected(self):
84
109
  saved = {"mykey": "cloudinary://key:secret@cloud",
85
110
  "cloud-a": _oauth_url("cloud-a"), "cloud-b": _oauth_url("cloud-b")}
86
111
  with patch("cloudinary_cli.auth.load_config", return_value=saved), \
87
112
  patch("cloudinary_cli.auth.refresh.load_config", return_value=saved), \
88
- patch("cloudinary_cli.auth.remove_config_keys") as remove, \
113
+ patch("cloudinary_cli.auth.remove_named_config") as remove, \
89
114
  patch("cloudinary_cli.auth.flow.revoke"):
90
115
  result = self.runner.invoke(cli, ["logout"], input="2\n")
91
116
  self.assertIn("cloud-a", result.output)
@@ -96,40 +121,60 @@ class TestLogoutInteractiveSelect(unittest.TestCase):
96
121
  def test_no_oauth_logins(self):
97
122
  with patch("cloudinary_cli.auth.refresh.load_config",
98
123
  return_value={"mykey": "cloudinary://key:secret@cloud"}), \
99
- patch("cloudinary_cli.auth.remove_config_keys") as remove:
124
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
100
125
  result = self.runner.invoke(cli, ["logout"], input="\n")
101
126
  self.assertIn("No saved OAuth logins", result.output)
102
127
  remove.assert_not_called()
103
128
 
104
129
  def test_cancel_on_empty_input(self):
105
130
  with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
106
- patch("cloudinary_cli.auth.remove_config_keys") as remove:
131
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
107
132
  result = self.runner.invoke(cli, ["logout"], input="\n")
108
133
  remove.assert_not_called()
109
134
  self.assertEqual(0, result.exit_code)
110
135
 
111
136
  def test_invalid_non_numeric_errors(self):
112
- with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
113
- patch("cloudinary_cli.auth.remove_config_keys") as remove:
137
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._TWO_LOGINS), \
138
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
114
139
  result = self.runner.invoke(cli, ["logout"], input="sdfdsf\n", standalone_mode=False)
115
140
  self.assertIn("Invalid selection", result.output)
116
141
  self.assertFalse(result.return_value) # main() maps falsy -> exit 1
117
142
  remove.assert_not_called()
118
143
 
119
144
  def test_out_of_range_errors(self):
120
- with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
121
- patch("cloudinary_cli.auth.remove_config_keys") as remove:
145
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._TWO_LOGINS), \
146
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
122
147
  result = self.runner.invoke(cli, ["logout"], input="5\n", standalone_mode=False)
123
148
  self.assertIn("Invalid selection", result.output)
124
149
  self.assertFalse(result.return_value)
125
150
  remove.assert_not_called()
126
151
 
152
+ def test_sole_login_confirms_instead_of_listing(self):
153
+ saved = {"cloud-a": _oauth_url("cloud-a")}
154
+ with patch("cloudinary_cli.auth.load_config", return_value=saved), \
155
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=saved), \
156
+ patch("cloudinary_cli.auth.remove_named_config") as remove, \
157
+ patch("cloudinary_cli.auth.flow.revoke"):
158
+ result = self.runner.invoke(cli, ["logout"], input="y\n")
159
+ self.assertNotIn("Saved OAuth logins:", result.output) # no one-item menu
160
+ self.assertIn("cloud-a", result.output)
161
+ remove.assert_called_once_with("cloud-a")
162
+
163
+ def test_sole_login_declined_removes_nothing(self):
164
+ saved = {"cloud-a": _oauth_url("cloud-a")}
165
+ with patch("cloudinary_cli.auth.load_config", return_value=saved), \
166
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=saved), \
167
+ patch("cloudinary_cli.auth.remove_named_config") as remove:
168
+ result = self.runner.invoke(cli, ["logout"], input="n\n")
169
+ remove.assert_not_called()
170
+ self.assertEqual(0, result.exit_code)
171
+
127
172
  def test_noninteractive_stdin_errors_with_hint(self):
128
173
  # Closed stdin (no input at all): the selection cannot be made, so error with the
129
174
  # non-interactive form (`cld logout <name>`) and exit non-zero, not a silent no-op.
130
175
  import builtins
131
176
  with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
132
- patch("cloudinary_cli.auth.remove_config_keys") as remove, \
177
+ patch("cloudinary_cli.auth.remove_named_config") as remove, \
133
178
  patch.object(builtins, "input", side_effect=EOFError()):
134
179
  result = self.runner.invoke(cli, ["logout"], standalone_mode=False)
135
180
  self.assertIn("cld logout <name>", result.output)
@@ -811,12 +856,11 @@ class TestConfigDefaultCommands(_RestoresSdkConfig):
811
856
  self.assertIn("(environment)", by_name)
812
857
 
813
858
  def test_rm_of_default_clears_it(self):
814
- with patch("cloudinary_cli.core.config.remove_config_keys", return_value=[]), \
815
- patch("cloudinary_cli.core.config.get_default_config_name", return_value="prod"), \
816
- patch("cloudinary_cli.core.config.clear_default_config") as clear:
859
+ stored = {"prod": "cloudinary://k:s@prod", "__default__": "prod"}
860
+ with _patched_config_store(stored) as store:
817
861
  result = self.runner.invoke(cli, ['config', '-rm', 'prod'])
818
862
  self.assertEqual(0, result.exit_code, result.output)
819
- clear.assert_called_once()
863
+ self.assertEqual({}, store["cfg"]) # the name and the default it held both gone
820
864
 
821
865
  def test_reserved_name_rejected_on_new(self):
822
866
  result = self.runner.invoke(
@@ -12,7 +12,9 @@ def _in_memory_config(initial=None):
12
12
  store = {"cfg": dict(initial or {})}
13
13
 
14
14
  def _load():
15
- return dict(store["cfg"])
15
+ cfg = dict(store["cfg"])
16
+ config_utils._rectify_config(cfg) # the real load_config rectifies; mirror it here
17
+ return cfg
16
18
 
17
19
  def _save(cfg):
18
20
  store["cfg"] = dict(cfg)
@@ -143,6 +145,89 @@ class TestAccountEmailInUrl(unittest.TestCase):
143
145
  self.assertEqual("you@example.com", config_utils.config_to_dict(cfg).get("account_email"))
144
146
 
145
147
 
148
+ class TestDanglingDefault(unittest.TestCase):
149
+ """A __default__ naming no saved config is invalid state; reads must not observe it."""
150
+
151
+ def test_dangling_default_is_ignored(self):
152
+ with _in_memory_config({"__default__": "gone"}):
153
+ self.assertIsNone(config_utils.get_default_config_name())
154
+
155
+ def test_dangling_default_does_not_block_auto_default(self):
156
+ with _in_memory_config({"__default__": "gone"}), \
157
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
158
+ status = config_utils.save_named_config("prod", "cloudinary://k:s@prod")
159
+ self.assertEqual("made", status)
160
+ self.assertEqual("prod", config_utils.get_default_config_name())
161
+
162
+ def test_saving_under_dangling_default_name_is_not_already(self):
163
+ with _in_memory_config({"__default__": "gone"}), \
164
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
165
+ status = config_utils.save_named_config("gone", "cloudinary://k:s@gone")
166
+ self.assertEqual("made", status)
167
+ self.assertEqual("gone", config_utils.get_default_config_name())
168
+
169
+ def test_dangling_default_dropped_from_file_on_next_write(self):
170
+ with _in_memory_config({"prod": "cloudinary://k:s@prod", "__default__": "gone"}) as store:
171
+ config_utils.update_config({"staging": "cloudinary://k:s@staging"})
172
+ self.assertNotIn("__default__", store["cfg"])
173
+
174
+
175
+ class TestRemoveNamedConfig(unittest.TestCase):
176
+ def test_keeps_default_pointing_at_another_config(self):
177
+ with _in_memory_config({"prod": "cloudinary://k:s@prod",
178
+ "staging": "cloudinary://k:s@staging",
179
+ "__default__": "prod"}) as store, \
180
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
181
+ self.assertTrue(config_utils.remove_named_config("staging"))
182
+ self.assertEqual("prod", store["cfg"]["__default__"])
183
+
184
+ def test_promotes_lone_survivor_to_default(self):
185
+ with _in_memory_config({"prod": "cloudinary://k:s@prod",
186
+ "staging": "cloudinary://k:s@staging",
187
+ "__default__": "prod"}) as store, \
188
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
189
+ self.assertTrue(config_utils.remove_named_config("prod"))
190
+ self.assertEqual("staging", store["cfg"]["__default__"])
191
+
192
+ def test_no_promotion_when_env_configured(self):
193
+ with _in_memory_config({"prod": "cloudinary://k:s@prod",
194
+ "staging": "cloudinary://k:s@staging",
195
+ "__default__": "prod"}) as store, \
196
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=True):
197
+ self.assertTrue(config_utils.remove_named_config("prod"))
198
+ self.assertNotIn("__default__", store["cfg"]) # env keeps precedence
199
+
200
+ def test_no_promotion_when_several_survive(self):
201
+ with _in_memory_config({"a": "cloudinary://k:s@a", "b": "cloudinary://k:s@b",
202
+ "c": "cloudinary://k:s@c", "__default__": "a"}) as store, \
203
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
204
+ self.assertTrue(config_utils.remove_named_config("a"))
205
+ self.assertNotIn("__default__", store["cfg"]) # ambiguous, so the user picks
206
+
207
+ def test_no_promotion_when_removed_was_not_default(self):
208
+ with _in_memory_config({"prod": "cloudinary://k:s@prod",
209
+ "staging": "cloudinary://k:s@staging"}) as store, \
210
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
211
+ self.assertTrue(config_utils.remove_named_config("staging"))
212
+ self.assertNotIn("__default__", store["cfg"]) # there was no default to re-point
213
+
214
+ def test_removing_last_config_leaves_no_default(self):
215
+ with _in_memory_config({"prod": "cloudinary://k:s@prod", "__default__": "prod"}) as store, \
216
+ patch("cloudinary_cli.utils.config_utils.is_env_configured", return_value=False):
217
+ self.assertTrue(config_utils.remove_named_config("prod"))
218
+ self.assertEqual({}, store["cfg"])
219
+
220
+ def test_missing_name_reports_false(self):
221
+ with _in_memory_config({"prod": "cloudinary://k:s@prod"}) as store:
222
+ self.assertFalse(config_utils.remove_named_config("nope"))
223
+ self.assertEqual({"prod": "cloudinary://k:s@prod"}, store["cfg"])
224
+
225
+ def test_refuses_reserved_key(self):
226
+ with _in_memory_config({"prod": "cloudinary://k:s@prod", "__default__": "prod"}) as store:
227
+ self.assertFalse(config_utils.remove_named_config("__default__"))
228
+ self.assertEqual("prod", store["cfg"]["__default__"])
229
+
230
+
146
231
  class TestBuildConfigUrl(unittest.TestCase):
147
232
  def _parsed(self, url):
148
233
  import cloudinary
@@ -1 +0,0 @@
1
- __version__ = "1.16.0"
File without changes