lumos-identity-cli 0.0.0.dev1__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.
@@ -0,0 +1,90 @@
1
+ import functools
2
+ import os
3
+
4
+ from click_extra import confirm, echo, prompt
5
+ from pick import pick
6
+
7
+ from lumos.common.client import AuthClient
8
+ from lumos.common.keyhelpers import key_file_path, read_key, write_key
9
+ from lumos.common.models import App, Permission, SupportRequestStatus
10
+
11
+
12
+ def authenticate(func):
13
+ """Makes sure client is authenticated first"""
14
+
15
+ @functools.wraps(func)
16
+ def wrapper_authenticate(*args, **kwargs):
17
+ setup(show_prompt=True)
18
+ return func(*args, **kwargs)
19
+
20
+ return wrapper_authenticate
21
+
22
+
23
+ def setup(show_prompt: bool = False, show_overwrite_prompt: bool = False):
24
+ key_file = key_file_path()
25
+ # if the key file exists, ask if they want to overwrite
26
+ if key_file.exists():
27
+ read_key()
28
+ if not show_overwrite_prompt or not confirm(
29
+ "You already have a key setup. Do you want to overwrite?", default=True
30
+ ):
31
+ return
32
+ if show_prompt and not confirm(
33
+ " 🛠️ You need to authenticate to use this application. Do you want to do that now?",
34
+ default=True,
35
+ ):
36
+ raise SystemExit(1)
37
+ selected, _ = pick(["OAuth 2.0", "API key"], "How do you want to authenticate?")
38
+ if selected == "API key":
39
+ echo(" ⚙️ Go to your Lumos account > Settings > API Tokens > Add an API Token, and copy the token.")
40
+ api_key = prompt("API key", hide_input=True, confirmation_prompt=True)
41
+ write_key(api_key)
42
+ else:
43
+ login()
44
+
45
+ read_key()
46
+
47
+
48
+ def login(admin: bool | None = False):
49
+ AuthClient().authenticate(admin or False)
50
+
51
+
52
+ def logout():
53
+ key_file = key_file_path()
54
+ key_file.unlink(missing_ok=True)
55
+ if os.environ.get("API_KEY"):
56
+ os.environ["API_KEY"] = None
57
+
58
+
59
+ def get_statuses(status: list[SupportRequestStatus], pending: bool, past: bool) -> set[SupportRequestStatus]:
60
+ if not status:
61
+ status = []
62
+ if pending:
63
+ status += SupportRequestStatus.PENDING_STATUSES
64
+ if past:
65
+ for e in SupportRequestStatus.ALL_STATUSES:
66
+ if e in SupportRequestStatus.PENDING_STATUSES:
67
+ continue
68
+ status.append(e)
69
+
70
+ if len(status) == 0:
71
+ return None
72
+ return set(status)
73
+
74
+
75
+ def check_current_apps(
76
+ apps: list[App], selected_app: App, selected_permissions: list[Permission] | None
77
+ ) -> tuple[App, str | None]:
78
+ for app in apps:
79
+ if str(app.app_id) == str(selected_app.id):
80
+ if len(app.requestable_permissions) > 0 and selected_permissions:
81
+ for permission in [str(r.id) for r in app.requestable_permissions]:
82
+ if permission in [str(r.id) for r in selected_permissions]:
83
+ return (
84
+ app,
85
+ "There's already a request for this app and permission",
86
+ )
87
+ elif len(app.requestable_permissions) == 0 and not selected_permissions:
88
+ return app, "There's already a request for this app"
89
+
90
+ return None, None
@@ -0,0 +1,41 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from lumos.common.logging import logdebug
5
+
6
+
7
+ def key_file_path() -> Path:
8
+ if os.environ.get("DEV_MODE"):
9
+ return Path.home() / ".lumos-dev"
10
+ return Path.home() / ".lumos"
11
+
12
+
13
+ def write_key(key: str | None, scope: str | None = None) -> None:
14
+ if not key:
15
+ return
16
+
17
+ os.environ["API_KEY"] = key
18
+ os.environ["SCOPE"] = scope or ""
19
+
20
+ if scope:
21
+ key = f"{scope}:{key}"
22
+
23
+ key_file = key_file_path()
24
+ logdebug(f"Writing token [{key[:9]}...] to {key_file}")
25
+ with key_file.open("w") as f:
26
+ f.write(key)
27
+
28
+
29
+ def read_key() -> str:
30
+ api_key = os.environ.get("API_KEY")
31
+ if api_key:
32
+ return api_key
33
+
34
+ key_file = key_file_path()
35
+ with key_file.open("r") as f:
36
+ api_key = f.read().strip()
37
+ if api_key.count(":") == 1:
38
+ scope, api_key = api_key.split(":")
39
+ os.environ["SCOPE"] = scope
40
+ os.environ["API_KEY"] = api_key
41
+ return api_key
@@ -0,0 +1,30 @@
1
+ import os
2
+
3
+ from click_extra import secho
4
+
5
+
6
+ def logdebug_request(url, headers, body, params, method) -> None:
7
+ logdebug(method + " " + url)
8
+ headers_copy = headers.copy()
9
+ if headers_copy.get("Authorization"):
10
+ headers_copy["Authorization"] = headers_copy["Authorization"][:12] + "..."
11
+ logdebug("HEADERS: " + str(headers_copy))
12
+ logdebug("BODY: " + str(body))
13
+ logdebug("PARAMETERS: " + str(params))
14
+
15
+
16
+ def logdebug_response(response) -> None:
17
+ logdebug("\nRESPONSE: " + str(response.status_code))
18
+ try:
19
+ json = response.json()
20
+ if json.get("access_token"):
21
+ json["access_token"] = json["access_token"][:5] + "..."
22
+ logdebug("HEADERS: " + str(response.headers))
23
+ logdebug("CONTENT: " + str(json))
24
+ except Exception:
25
+ logdebug("CONTENT: " + str(response.content))
26
+
27
+
28
+ def logdebug(msg: str) -> None:
29
+ if os.environ.get("DEBUG"):
30
+ secho(msg, fg="red")
lumos/common/models.py ADDED
@@ -0,0 +1,236 @@
1
+ from abc import abstractmethod
2
+ from enum import Enum
3
+ from typing import ClassVar
4
+ from uuid import UUID
5
+
6
+ import pytz
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class ProvisioningMethodOption(Enum):
11
+ DIRECT_TO_USER = "DIRECT_TO_USER"
12
+ GROUPS_AND_HIDDEN = "GROUPS_AND_HIDDEN"
13
+ GROUPS_AND_VISIBLE = "GROUPS_AND_VISIBLE"
14
+
15
+
16
+ class LumosModel(BaseModel):
17
+ @abstractmethod
18
+ def tabulate(self) -> list[str]:
19
+ pass
20
+
21
+
22
+ class AppSettingProvisioning(BaseModel):
23
+ groups_provisioning: ProvisioningMethodOption
24
+ time_based_access: list[str] = []
25
+ allow_multiple_permission_selection: bool
26
+
27
+
28
+ class AppSetting(BaseModel):
29
+ custom_request_instructions: str
30
+ provisioning: AppSettingProvisioning
31
+
32
+
33
+ class App(LumosModel):
34
+ id: UUID
35
+ user_friendly_label: str
36
+ app_class_id: str
37
+ instance_id: str
38
+
39
+ def __str__(self):
40
+ return self.user_friendly_label
41
+
42
+ def tabulate(self):
43
+ return [str(self.id), self.user_friendly_label]
44
+
45
+ @staticmethod
46
+ def headers():
47
+ return ["ID", "App"]
48
+
49
+
50
+ class Group(LumosModel):
51
+ id: UUID
52
+ name: str
53
+ description: str
54
+ app_id: str
55
+ source_app_id: str
56
+ integration_specific_id: str
57
+
58
+ @staticmethod
59
+ def headers():
60
+ return ["ID", "Name", "Description"]
61
+
62
+ def tabulate(self):
63
+ return [str(self.id), self.name, self.description]
64
+
65
+
66
+ class Permission(LumosModel):
67
+ id: UUID
68
+ label: str
69
+ app_id: str
70
+ app_class_id: str
71
+ duration_options: list[str] = []
72
+
73
+ def __str__(self):
74
+ return self.label
75
+
76
+ def tabulate(self):
77
+ return [str(self.id), self.label, ", ".join(self.duration_options)]
78
+
79
+ @staticmethod
80
+ def headers():
81
+ return ["ID", "Permission", "Access length options"]
82
+
83
+
84
+ class User(LumosModel):
85
+ id: UUID
86
+ given_name: str
87
+ family_name: str
88
+ email: str
89
+
90
+ def __str__(self):
91
+ return f"{self.given_name} {self.family_name} ({self.email})"
92
+
93
+ def tabulate(self):
94
+ return [f"{self.given_name} {self.family_name}", self.email, str(self.id)]
95
+
96
+ @staticmethod
97
+ def headers():
98
+ return ["Name", "Email", "ID"]
99
+
100
+
101
+ class AccessRequest(LumosModel):
102
+ id: UUID
103
+ app_id: UUID
104
+ app_name: str
105
+ status: str
106
+ requested_at: str
107
+ expires_at: str | None
108
+ requester_user: User
109
+ supporter_user: User | None
110
+ target_user: User
111
+ requestable_permission_ids: list[UUID] | None = None
112
+ requestable_permissions: list[Permission] | None = None
113
+
114
+ @staticmethod
115
+ def _convert_to_human_date(inp: str) -> str:
116
+ if not inp:
117
+ return ""
118
+ import datetime
119
+
120
+ # Parse the input UTC time string to a datetime object
121
+ utc_time = datetime.datetime.strptime(inp, "%Y-%m-%dT%H:%M:%S")
122
+
123
+ # Set the timezone to UTC for the parsed datetime
124
+ utc_time = utc_time.replace(tzinfo=pytz.utc)
125
+
126
+ # Convert UTC time to EST
127
+ est_time = utc_time.astimezone(pytz.timezone("America/New_York"))
128
+
129
+ # Format the EST time into a more human-readable string
130
+ human_date = est_time.strftime("%a %b %d, %Y %I:%M %p EST")
131
+
132
+ return human_date
133
+
134
+ def __str__(self):
135
+ return f"{self.app_name} ({self.status})"
136
+
137
+ def tabulate(self):
138
+ return [str(self.id), *self._tabulate()]
139
+
140
+ def tabulate_as_app(self):
141
+ return [self.app_id, *self._tabulate()]
142
+
143
+ def _tabulate(self):
144
+ return [
145
+ self.app_name,
146
+ "\n".join([p.label for p in self.requestable_permissions]) if self.requestable_permissions else "-----",
147
+ self.requester_user.given_name + " " + self.requester_user.family_name,
148
+ self.target_user.given_name + " " + self.target_user.family_name
149
+ if self.requester_user.id != self.target_user.id
150
+ else "(self)",
151
+ self.status,
152
+ self._convert_to_human_date(self.requested_at),
153
+ self._convert_to_human_date(self.expires_at),
154
+ ]
155
+
156
+ @staticmethod
157
+ def headers():
158
+ return ["ID", "App", "Permissions", "Requester", "For", "Status", "Requested at", "Expires at"]
159
+
160
+
161
+ # If this enum is updated both this table as well as SupportRequestComments must be updated.
162
+ class SupportRequestStatus:
163
+ # initial status, right after creating a request, no action should be taken on the request
164
+ # at this stage
165
+ PENDING = "PENDING"
166
+ # after recognizing the manager approval for the request is required, it's waiting for manager
167
+ PENDING_MANAGER_APPROVAL = "PENDING_MANAGER_APPROVAL"
168
+ # after a manager approves it
169
+ MANAGER_APPROVED = "MANAGER_APPROVED"
170
+ # when a manager rejects it
171
+ MANAGER_DENIED = "MANAGER_DENIED"
172
+ # after recognizing the approval for the request is required, it's waiting for end-user
173
+ PENDING_APPROVAL = "PENDING_APPROVAL"
174
+ # after an approver approves it or assigned automatically when there is no need for approval
175
+ APPROVED = "APPROVED"
176
+ # when an approver rejects it
177
+ DENIED = "DENIED"
178
+ # assigned automatically when neither the approver or admin completes the request in time
179
+ EXPIRED = "EXPIRED"
180
+ # after user cancels the request
181
+ CANCELLED = "CANCELLED"
182
+ # when the request enters provisioning and it's still not clear whether it'll happen
183
+ # automatically or we will need to ask for manual provisioning
184
+ PENDING_PROVISIONING = "PENDING_PROVISIONING"
185
+ # after recognizing the provisioning for the request needs to be done manually,
186
+ # it's waiting for end-user
187
+ PENDING_MANUAL_PROVISIONING = "PENDING_MANUAL_PROVISIONING"
188
+ # when admin denied the request
189
+ DENIED_PROVISIONING = "DENIED_PROVISIONING"
190
+ # after either the manual or automatic provisioning,
191
+ # when it's waiting for the final "cleanup" on our end
192
+ PROVISIONED = "PROVISIONED"
193
+ # when a time based request has been completed and the time has elapsed but automatic removal
194
+ # failed and access needs to be manually removed
195
+ PENDING_MANUAL_DEPROVISIONING = "PENDING_MANUAL_DEPROVISIONING"
196
+ # when the request has been completed and access has been removed because the request was
197
+ # only for a limited amount of time
198
+ TIME_BASED_EXPIRED = "TIME_BASED_EXPIRED"
199
+ # when the request has been fully finalized and there are no more actions pending
200
+ COMPLETED = "COMPLETED"
201
+ # when the request has been marked to be reverted by a user via the "Undo an action" flow
202
+ # but before the actual reverting is completed
203
+ REVERTING = "REVERTING"
204
+ # when the request has been fully reverted
205
+ REVERTED = "REVERTED"
206
+
207
+ PENDING_STATUSES: ClassVar[list[str]] = [
208
+ PENDING,
209
+ PENDING_MANAGER_APPROVAL,
210
+ PENDING_MANUAL_PROVISIONING,
211
+ PENDING_APPROVAL,
212
+ PENDING_PROVISIONING,
213
+ ]
214
+
215
+ SUCCESS_STATUSES: ClassVar[list[str]] = [APPROVED, COMPLETED, PROVISIONED]
216
+
217
+ ALL_STATUSES: ClassVar[list[str]] = [
218
+ PENDING,
219
+ PENDING_MANAGER_APPROVAL,
220
+ MANAGER_APPROVED,
221
+ MANAGER_DENIED,
222
+ PENDING_APPROVAL,
223
+ APPROVED,
224
+ DENIED,
225
+ EXPIRED,
226
+ CANCELLED,
227
+ PENDING_PROVISIONING,
228
+ PENDING_MANUAL_PROVISIONING,
229
+ DENIED_PROVISIONING,
230
+ PROVISIONED,
231
+ PENDING_MANUAL_DEPROVISIONING,
232
+ TIME_BASED_EXPIRED,
233
+ COMPLETED,
234
+ REVERTING,
235
+ REVERTED,
236
+ ]
@@ -0,0 +1 @@
1
+ from .cli import list_group as list_group
@@ -0,0 +1,289 @@
1
+ from json import dumps
2
+ from uuid import UUID
3
+
4
+ from click_extra import group, option
5
+ from tabulate import tabulate
6
+
7
+ from lumos.common.client import ApiClient
8
+ from lumos.common.helpers import authenticate, get_statuses
9
+ from lumos.common.models import AccessRequest, LumosModel
10
+
11
+ client = ApiClient()
12
+
13
+
14
+ @group(name="list")
15
+ def list_group():
16
+ """List various Lumos resources"""
17
+ pass
18
+
19
+
20
+ @list_group.command("users", help="List users in Lumos")
21
+ @option("--like", default=None, help="Search by name or email")
22
+ @option("--csv", is_flag=True, help="Output as CSV")
23
+ @option("--json", "output_json", is_flag=True, help="Output as JSON")
24
+ @option("--paginate/--no-paginate", default=True, help="Pagination")
25
+ @option("--page-size", default=100, help="Page size")
26
+ @option("--page", default=1, help="Page")
27
+ @option("--id-only", is_flag=True, help="Output ID only")
28
+ @authenticate
29
+ def list_users(
30
+ like: str | None,
31
+ csv: bool,
32
+ output_json: bool,
33
+ paginate: bool,
34
+ page_size: int,
35
+ page: int,
36
+ id_only: bool,
37
+ ) -> None:
38
+ all = csv or output_json or not paginate
39
+ users, count, total = client.get_users(like=like, all=all, page=page, page_size=page_size)
40
+ display(
41
+ "users",
42
+ users,
43
+ count,
44
+ total,
45
+ csv,
46
+ output_json,
47
+ page=page,
48
+ page_size=page_size,
49
+ id_only=id_only,
50
+ )
51
+
52
+
53
+ @list_group.command("permissions", help="List permissions for a given app")
54
+ @option("--app", required=True, type=str, help="App UUID")
55
+ @option("--like", default=None, help="Filters permissions")
56
+ @option("--csv", is_flag=True, help="Output as CSV")
57
+ @option("--json", "output_json", is_flag=True, help="Output as JSON")
58
+ @option("--paginate/--no-paginate", default=True, help="Pagination")
59
+ @option("--page-size", default=100, help="Page size")
60
+ @option("--page", default=1, help="Page")
61
+ @option("--id-only", is_flag=True, help="Output ID only")
62
+ @authenticate
63
+ def list_permissions(
64
+ app: str,
65
+ like: str | None,
66
+ csv: bool,
67
+ output_json: bool,
68
+ paginate: bool,
69
+ page_size: int,
70
+ page: int,
71
+ id_only: bool,
72
+ ) -> None:
73
+ app_uuid = UUID(app)
74
+ all = csv or output_json or not paginate
75
+ permissions, count, total = client.get_app_requestable_permissions(
76
+ app_id=app_uuid, search_term=like, all=all, page=page, page_size=page_size
77
+ )
78
+
79
+ display(
80
+ "permissions",
81
+ permissions,
82
+ count,
83
+ total,
84
+ csv,
85
+ output_json,
86
+ page=page,
87
+ page_size=page_size,
88
+ id_only=id_only,
89
+ )
90
+
91
+
92
+ @list_group.command("groups", help="List groups for the domain or the specified --app")
93
+ @option(
94
+ "--app",
95
+ default=None,
96
+ type=str,
97
+ help="App ID to filter groups by. If not provided, lists all groups.",
98
+ )
99
+ @option("--like", default=None, help="Filters groups")
100
+ @option("--csv", is_flag=True, help="Output as CSV")
101
+ @option("--json", "output_json", is_flag=True, help="Output as JSON")
102
+ @option("--paginate/--no-paginate", default=True, help="Pagination")
103
+ @option("--page-size", default=100, help="Page size")
104
+ @option("--page", default=1, help="Page")
105
+ @option("--id-only", is_flag=True, help="Output ID only")
106
+ @authenticate
107
+ def list_groups(
108
+ app: str | None,
109
+ like: str | None,
110
+ csv: bool,
111
+ output_json: bool,
112
+ paginate: bool,
113
+ page_size: int,
114
+ page: int,
115
+ id_only: bool,
116
+ ) -> None:
117
+ app_uuid = UUID(app) if app else None
118
+ all = csv or output_json or not paginate
119
+ groups, count, total = client.get_groups(app_id=app_uuid, search_term=like, all=all, page=page, page_size=page_size)
120
+
121
+ display(
122
+ "groups",
123
+ groups,
124
+ count,
125
+ total,
126
+ csv,
127
+ output_json,
128
+ page=page,
129
+ page_size=page_size,
130
+ id_only=id_only,
131
+ )
132
+
133
+
134
+ @list_group.command("requests", help="List access requests")
135
+ @option(
136
+ "--for-user",
137
+ default=None,
138
+ type=str,
139
+ help="Show only requests for ('targetting') a particular user",
140
+ )
141
+ @option(
142
+ "--mine",
143
+ is_flag=True,
144
+ help="Show only requests for ('targetting') me. Takes precedence over --for-user.",
145
+ )
146
+ @option(
147
+ "--status",
148
+ multiple=True,
149
+ help="One of `PENDING`, `COMPLETED`, `DENIED_PROVISIONING`, etc",
150
+ )
151
+ @option("--pending", is_flag=True, help="Show only pending requests")
152
+ @option("--past", is_flag=True, help="Show only past requests")
153
+ @option("--csv", is_flag=True, help="Output as CSV")
154
+ @option("--json", "output_json", is_flag=True, help="Output as JSON")
155
+ @option("--paginate/--no-paginate", default=True, help="Pagination")
156
+ @option("--page-size", default=100, help="Page size")
157
+ @option("--page", default=1, help="Page")
158
+ @option("--id-only", is_flag=True, help="Output ID only")
159
+ @authenticate
160
+ def list_requests(
161
+ for_user: str | None,
162
+ mine: bool,
163
+ status: tuple,
164
+ pending: bool,
165
+ past: bool,
166
+ csv: bool,
167
+ output_json: bool,
168
+ paginate: bool,
169
+ page_size: int,
170
+ page: int,
171
+ id_only: bool,
172
+ ) -> None:
173
+ user_uuid = None
174
+ if mine:
175
+ user_uuid = client.get_current_user_id()
176
+ elif for_user:
177
+ user_uuid = UUID(for_user)
178
+
179
+ status_list = list(status) if status else None
180
+ status_set = get_statuses(status_list, pending, past)
181
+
182
+ all = csv or output_json or not paginate
183
+ access_requests, count, total, _, _ = client.get_access_requests(
184
+ target_user_id=user_uuid,
185
+ status=status_set,
186
+ all=all,
187
+ page=page,
188
+ page_size=page_size,
189
+ )
190
+
191
+ access_requests = sorted(access_requests, key=lambda x: x.requested_at, reverse=True)
192
+
193
+ display(
194
+ "requests",
195
+ access_requests,
196
+ count,
197
+ total,
198
+ csv,
199
+ output_json,
200
+ page_size=page_size,
201
+ page=page,
202
+ id_only=id_only,
203
+ search=False,
204
+ )
205
+
206
+
207
+ @list_group.command("apps", help="List apps in the appstore")
208
+ @option("--like", default=None, help="Filters apps by search term")
209
+ @option("--mine", is_flag=True, help="Show only my apps.")
210
+ @option("--csv", is_flag=True, help="Output as CSV")
211
+ @option("--json", "output_json", is_flag=True, help="Output as JSON")
212
+ @option("--paginate/--no-paginate", default=True, help="Pagination")
213
+ @option("--page-size", default=100, help="Page size")
214
+ @option("--page", default=1, help="Page")
215
+ @option("--id-only", is_flag=True, help="Output ID only")
216
+ @authenticate
217
+ def list_apps(
218
+ like: str | None,
219
+ mine: bool,
220
+ csv: bool,
221
+ output_json: bool,
222
+ paginate: bool,
223
+ page_size: int,
224
+ page: int,
225
+ id_only: bool,
226
+ ) -> None:
227
+ all = csv or output_json or not paginate
228
+ if mine:
229
+ access_requests = client.get_my_apps()
230
+ if len(access_requests) > 0:
231
+ print(
232
+ tabulate(
233
+ [req.tabulate_as_app() for req in access_requests],
234
+ headers=AccessRequest.headers(),
235
+ ),
236
+ "\n",
237
+ )
238
+ else:
239
+ print("No apps found.")
240
+ return
241
+ apps, count, total = client.get_appstore_apps(name_search=like, all=all, page_size=page_size, page=page)
242
+ display(
243
+ "apps",
244
+ apps,
245
+ count,
246
+ total,
247
+ csv,
248
+ output_json,
249
+ page=page,
250
+ page_size=page_size,
251
+ id_only=id_only,
252
+ )
253
+
254
+
255
+ def display(
256
+ description: str,
257
+ data: list[LumosModel],
258
+ count: int,
259
+ total: int,
260
+ csv: bool,
261
+ json: bool,
262
+ page: int,
263
+ page_size: int,
264
+ id_only: bool = False,
265
+ search: bool = True,
266
+ ):
267
+ if len(data) == 0:
268
+ print(f"No {description} found.")
269
+ return
270
+ if csv:
271
+ for row in data:
272
+ print(",".join([str(cell).replace(", ", "|") for cell in row.tabulate()]))
273
+ return
274
+ if json:
275
+ print(dumps([d.__dict__ for d in data], default=str, indent=2))
276
+ return
277
+ if id_only:
278
+ if len(data) > 0:
279
+ for row in data:
280
+ print(row.tabulate()[0])
281
+ return
282
+ headers = data[0].headers()
283
+ print(tabulate([d.tabulate() for d in data], headers=headers), "\n")
284
+ remaining = total - count - page_size * (page - 1)
285
+ if remaining > 0:
286
+ if search:
287
+ print(f"There are {remaining} more {description} that match your search. Use --like to search.\n")
288
+ else:
289
+ print(f"There are {remaining} more {description} not shown.\n")
@@ -0,0 +1 @@
1
+ from .cli import request as request