google-analytics-cli 0.1.0__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.
- ga_cli/__init__.py +8 -0
- ga_cli/api/__init__.py +0 -0
- ga_cli/api/client.py +142 -0
- ga_cli/auth/__init__.py +35 -0
- ga_cli/auth/credentials.py +126 -0
- ga_cli/auth/oauth.py +322 -0
- ga_cli/auth/service_account.py +155 -0
- ga_cli/commands/__init__.py +0 -0
- ga_cli/commands/access_bindings.py +254 -0
- ga_cli/commands/access_reports.py +201 -0
- ga_cli/commands/account_summaries.py +68 -0
- ga_cli/commands/accounts.py +297 -0
- ga_cli/commands/agent_cmd.py +776 -0
- ga_cli/commands/annotations.py +264 -0
- ga_cli/commands/audiences.py +223 -0
- ga_cli/commands/auth_cmd.py +205 -0
- ga_cli/commands/bigquery_links.py +309 -0
- ga_cli/commands/calculated_metrics.py +312 -0
- ga_cli/commands/channel_groups.py +223 -0
- ga_cli/commands/completions_cmd.py +55 -0
- ga_cli/commands/config_cmd.py +113 -0
- ga_cli/commands/custom_dimensions.py +272 -0
- ga_cli/commands/custom_metrics.py +305 -0
- ga_cli/commands/data_retention.py +153 -0
- ga_cli/commands/data_streams.py +277 -0
- ga_cli/commands/event_create_rules.py +250 -0
- ga_cli/commands/event_edit_rules.py +292 -0
- ga_cli/commands/firebase_links.py +142 -0
- ga_cli/commands/google_ads_links.py +225 -0
- ga_cli/commands/key_events.py +269 -0
- ga_cli/commands/mp_secrets.py +265 -0
- ga_cli/commands/properties.py +330 -0
- ga_cli/commands/property_settings.py +287 -0
- ga_cli/commands/reports.py +726 -0
- ga_cli/commands/upgrade_cmd.py +148 -0
- ga_cli/config/__init__.py +0 -0
- ga_cli/config/constants.py +61 -0
- ga_cli/config/store.py +115 -0
- ga_cli/main.py +110 -0
- ga_cli/utils/__init__.py +20 -0
- ga_cli/utils/describe.py +129 -0
- ga_cli/utils/dry_run.py +40 -0
- ga_cli/utils/errors.py +150 -0
- ga_cli/utils/output.py +209 -0
- ga_cli/utils/pagination.py +93 -0
- google_analytics_cli-0.1.0.dist-info/METADATA +321 -0
- google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
- google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
- google_analytics_cli-0.1.0.dist-info/entry_points.txt +2 -0
ga_cli/utils/errors.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Error handling utilities.
|
|
2
|
+
|
|
3
|
+
Provides consistent error formatting for Google API errors.
|
|
4
|
+
When the output format is JSON, errors are emitted as structured JSON
|
|
5
|
+
to stderr with distinct exit codes for different failure categories.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import NoReturn
|
|
13
|
+
|
|
14
|
+
from .output import error as print_error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_api_error(err: Exception) -> str:
|
|
18
|
+
"""Extract a human-readable message from a Google API error.
|
|
19
|
+
|
|
20
|
+
Handles googleapiclient.errors.HttpError and generic exceptions.
|
|
21
|
+
"""
|
|
22
|
+
# google-api-python-client raises HttpError
|
|
23
|
+
from googleapiclient.errors import HttpError
|
|
24
|
+
|
|
25
|
+
if isinstance(err, HttpError):
|
|
26
|
+
try:
|
|
27
|
+
detail = json.loads(err.content.decode())
|
|
28
|
+
message = detail.get("error", {}).get("message", str(err))
|
|
29
|
+
return message
|
|
30
|
+
except (json.JSONDecodeError, AttributeError):
|
|
31
|
+
return str(err)
|
|
32
|
+
|
|
33
|
+
return str(err)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def classify_error(err: Exception) -> tuple[int, str]:
|
|
37
|
+
"""Classify an exception into (exit_code, category).
|
|
38
|
+
|
|
39
|
+
Exit codes:
|
|
40
|
+
1 — client_error: bad input, missing flags, validation failure
|
|
41
|
+
2 — auth_error: not authenticated, token expired, permission denied (401/403)
|
|
42
|
+
3 — api_error: Google returned 4xx (not auth) or 5xx
|
|
43
|
+
4 — network_error: connection timeout, DNS failure, unreachable
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
(exit_code, category)
|
|
47
|
+
"""
|
|
48
|
+
from googleapiclient.errors import HttpError
|
|
49
|
+
|
|
50
|
+
if isinstance(err, HttpError):
|
|
51
|
+
status = err.resp.status
|
|
52
|
+
if status in (401, 403):
|
|
53
|
+
return 2, "auth_error"
|
|
54
|
+
return 3, "api_error"
|
|
55
|
+
|
|
56
|
+
# Auth-specific exceptions
|
|
57
|
+
try:
|
|
58
|
+
from google.auth.exceptions import DefaultCredentialsError, RefreshError
|
|
59
|
+
|
|
60
|
+
if isinstance(err, (RefreshError, DefaultCredentialsError)):
|
|
61
|
+
return 2, "auth_error"
|
|
62
|
+
except ImportError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# Network errors
|
|
66
|
+
try:
|
|
67
|
+
from requests.exceptions import ConnectionError as RequestsConnectionError
|
|
68
|
+
from requests.exceptions import Timeout
|
|
69
|
+
|
|
70
|
+
if isinstance(err, (RequestsConnectionError, Timeout)):
|
|
71
|
+
return 4, "network_error"
|
|
72
|
+
except ImportError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
from urllib3.exceptions import NewConnectionError
|
|
77
|
+
|
|
78
|
+
if isinstance(err, NewConnectionError):
|
|
79
|
+
return 4, "network_error"
|
|
80
|
+
except ImportError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if isinstance(err, OSError) and _is_network_os_error(err):
|
|
84
|
+
return 4, "network_error"
|
|
85
|
+
|
|
86
|
+
# RuntimeError from our own auth code ("Not authenticated...")
|
|
87
|
+
if isinstance(err, RuntimeError) and "auth" in str(err).lower():
|
|
88
|
+
return 2, "auth_error"
|
|
89
|
+
|
|
90
|
+
return 3, "api_error"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_network_os_error(err: OSError) -> bool:
|
|
94
|
+
"""Check if an OSError is network-related (vs file I/O, permissions, etc.)."""
|
|
95
|
+
import errno
|
|
96
|
+
|
|
97
|
+
network_errnos = {
|
|
98
|
+
errno.ECONNREFUSED,
|
|
99
|
+
errno.ECONNRESET,
|
|
100
|
+
errno.ECONNABORTED,
|
|
101
|
+
errno.ETIMEDOUT,
|
|
102
|
+
errno.EHOSTUNREACH,
|
|
103
|
+
errno.ENETUNREACH,
|
|
104
|
+
}
|
|
105
|
+
return err.errno in network_errnos
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def handle_error(err: Exception) -> NoReturn:
|
|
109
|
+
"""Print error and exit with a classified exit code.
|
|
110
|
+
|
|
111
|
+
When output format is JSON (explicit or non-TTY), emit structured
|
|
112
|
+
JSON to stderr. Otherwise, emit human-readable Rich text.
|
|
113
|
+
"""
|
|
114
|
+
from .output import get_current_output_format
|
|
115
|
+
|
|
116
|
+
exit_code, category = classify_error(err)
|
|
117
|
+
message = format_api_error(err)
|
|
118
|
+
fmt = get_current_output_format()
|
|
119
|
+
|
|
120
|
+
if fmt == "json":
|
|
121
|
+
payload: dict = {
|
|
122
|
+
"error": True,
|
|
123
|
+
"exit_code": exit_code,
|
|
124
|
+
"category": category,
|
|
125
|
+
"message": message,
|
|
126
|
+
}
|
|
127
|
+
from googleapiclient.errors import HttpError
|
|
128
|
+
|
|
129
|
+
if isinstance(err, HttpError):
|
|
130
|
+
payload["status_code"] = err.resp.status
|
|
131
|
+
|
|
132
|
+
# Write raw JSON to stderr — avoid Rich Console which may wrap lines
|
|
133
|
+
print(json.dumps(payload, default=str), file=sys.stderr)
|
|
134
|
+
else:
|
|
135
|
+
print_error(message)
|
|
136
|
+
|
|
137
|
+
sys.exit(exit_code)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def require_options(options: dict, required: list[str]) -> None:
|
|
141
|
+
"""Validate that required options are present.
|
|
142
|
+
|
|
143
|
+
Raises typer.BadParameter if any are missing.
|
|
144
|
+
"""
|
|
145
|
+
import typer
|
|
146
|
+
|
|
147
|
+
missing = [k for k in required if not options.get(k)]
|
|
148
|
+
if missing:
|
|
149
|
+
formatted = [f"--{k.replace('_', '-')}" for k in missing]
|
|
150
|
+
raise typer.BadParameter(f"Missing required options: {', '.join(formatted)}")
|
ga_cli/utils/output.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Output formatting utilities.
|
|
2
|
+
|
|
3
|
+
Supports three formats:
|
|
4
|
+
- json: Machine-readable JSON (default when piping)
|
|
5
|
+
- table: Human-readable Rich tables (default for TTY)
|
|
6
|
+
- compact: Minimal ID + name output
|
|
7
|
+
|
|
8
|
+
Equivalent to GTM CLI's utils/output.ts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
err_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
OutputFormat = str # "json" | "table" | "compact"
|
|
24
|
+
|
|
25
|
+
# Global flags set from main.py callback
|
|
26
|
+
_quiet = False
|
|
27
|
+
_current_output_format: str = "table"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_output_format(fmt: str) -> None:
|
|
31
|
+
"""Store the resolved output format for use by error handling."""
|
|
32
|
+
global _current_output_format
|
|
33
|
+
_current_output_format = fmt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_current_output_format() -> str:
|
|
37
|
+
"""Return the current output format. Used by errors.py for structured errors."""
|
|
38
|
+
return _current_output_format
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_quiet(value: bool) -> None:
|
|
42
|
+
"""Enable or disable quiet mode (suppresses info/warn/success)."""
|
|
43
|
+
global _quiet
|
|
44
|
+
_quiet = value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_no_color(value: bool) -> None:
|
|
48
|
+
"""Enable or disable no-color mode on both consoles."""
|
|
49
|
+
global console, err_console
|
|
50
|
+
if value:
|
|
51
|
+
console = Console(no_color=True, highlight=False)
|
|
52
|
+
err_console = Console(stderr=True, no_color=True, highlight=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_tty() -> bool:
|
|
56
|
+
"""Check if stdout is a terminal."""
|
|
57
|
+
return sys.stdout.isatty()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_output_format(requested: Optional[str] = None) -> str:
|
|
61
|
+
"""Get effective output format. Defaults to JSON when piping."""
|
|
62
|
+
if requested:
|
|
63
|
+
return requested
|
|
64
|
+
return "table" if is_tty() else "json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_output_format(cli_value: Optional[str] = None) -> str:
|
|
68
|
+
"""Resolve and track the output format from a subcommand's -o flag.
|
|
69
|
+
|
|
70
|
+
Replaces the common pattern:
|
|
71
|
+
effective_format = get_effective_value(output_format, "output_format") or "table"
|
|
72
|
+
|
|
73
|
+
This resolves CLI flag > config > TTY detection, then updates the
|
|
74
|
+
global format so that handle_error() uses the correct format.
|
|
75
|
+
"""
|
|
76
|
+
from ..config.store import get_effective_value
|
|
77
|
+
|
|
78
|
+
fmt = get_effective_value(cli_value, "output_format") or ("table" if is_tty() else "json")
|
|
79
|
+
set_output_format(fmt)
|
|
80
|
+
return fmt
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def output(
|
|
84
|
+
data: Any,
|
|
85
|
+
fmt: str = "table",
|
|
86
|
+
columns: Optional[list[str]] = None,
|
|
87
|
+
headers: Optional[list[str]] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Output data in the specified format.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
data: The data to display (list of dicts, or a single dict).
|
|
93
|
+
fmt: Output format — "json", "table", or "compact".
|
|
94
|
+
columns: Which keys to show in table mode.
|
|
95
|
+
headers: Column headers for table mode.
|
|
96
|
+
"""
|
|
97
|
+
effective_fmt = get_output_format(fmt)
|
|
98
|
+
|
|
99
|
+
if effective_fmt == "json":
|
|
100
|
+
print(json.dumps(data, indent=2, default=str))
|
|
101
|
+
|
|
102
|
+
elif effective_fmt == "compact":
|
|
103
|
+
if isinstance(data, list):
|
|
104
|
+
for item in data:
|
|
105
|
+
if isinstance(item, dict):
|
|
106
|
+
name = item.get("name", item.get("displayName", ""))
|
|
107
|
+
display = item.get("displayName", "")
|
|
108
|
+
print(f"{name}\t{display}")
|
|
109
|
+
else:
|
|
110
|
+
print(str(item))
|
|
111
|
+
else:
|
|
112
|
+
print(json.dumps(data, default=str))
|
|
113
|
+
|
|
114
|
+
else: # table
|
|
115
|
+
if isinstance(data, list) and len(data) > 0:
|
|
116
|
+
_output_table(data, columns, headers)
|
|
117
|
+
elif isinstance(data, dict):
|
|
118
|
+
_output_object(data)
|
|
119
|
+
elif isinstance(data, list) and len(data) == 0:
|
|
120
|
+
console.print("No results found.")
|
|
121
|
+
else:
|
|
122
|
+
print(str(data))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _output_table(
|
|
126
|
+
data: list[dict],
|
|
127
|
+
columns: Optional[list[str]] = None,
|
|
128
|
+
headers: Optional[list[str]] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Render a list of dicts as a Rich table."""
|
|
131
|
+
if not data:
|
|
132
|
+
console.print("No results found.")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
cols = columns or _get_default_columns(data[0])
|
|
136
|
+
hdrs = headers or [_format_header(c) for c in cols]
|
|
137
|
+
|
|
138
|
+
table = Table(show_lines=True)
|
|
139
|
+
for h in hdrs:
|
|
140
|
+
table.add_column(h, style="bold")
|
|
141
|
+
|
|
142
|
+
for item in data:
|
|
143
|
+
row = [_format_value(item.get(c)) for c in cols]
|
|
144
|
+
table.add_row(*row)
|
|
145
|
+
|
|
146
|
+
console.print(table)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _output_object(data: dict) -> None:
|
|
150
|
+
"""Render a single dict as key-value pairs."""
|
|
151
|
+
table = Table(show_lines=True)
|
|
152
|
+
table.add_column("Key", style="bold")
|
|
153
|
+
table.add_column("Value")
|
|
154
|
+
|
|
155
|
+
for key, value in data.items():
|
|
156
|
+
if value is not None:
|
|
157
|
+
table.add_row(_format_header(key), _format_value(value))
|
|
158
|
+
|
|
159
|
+
console.print(table)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_default_columns(item: dict) -> list[str]:
|
|
163
|
+
"""Pick sensible default columns for GA resources."""
|
|
164
|
+
priority = ["name", "displayName", "type", "createTime", "updateTime"]
|
|
165
|
+
return [c for c in priority if c in item] or list(item.keys())[:5]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _format_header(key: str) -> str:
|
|
169
|
+
"""Convert snake_case or camelCase to Title Case."""
|
|
170
|
+
import re
|
|
171
|
+
# camelCase → spaces
|
|
172
|
+
s = re.sub(r"([A-Z])", r" \1", key)
|
|
173
|
+
# snake_case → spaces
|
|
174
|
+
s = s.replace("_", " ")
|
|
175
|
+
return s.strip().title()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _format_value(value: Any) -> str:
|
|
179
|
+
"""Format a value for table display."""
|
|
180
|
+
if value is None:
|
|
181
|
+
return ""
|
|
182
|
+
if isinstance(value, bool):
|
|
183
|
+
return "[green]Yes[/green]" if value else "[red]No[/red]"
|
|
184
|
+
if isinstance(value, list):
|
|
185
|
+
return f"[{len(value)} items]" if value else "[]"
|
|
186
|
+
if isinstance(value, dict):
|
|
187
|
+
return json.dumps(value, default=str)
|
|
188
|
+
return str(value)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# Convenience functions for styled messages (equivalent to GTM CLI)
|
|
192
|
+
def success(message: str) -> None:
|
|
193
|
+
if not _quiet:
|
|
194
|
+
err_console.print(f"[green]OK[/green] {message}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def error(message: str) -> None:
|
|
198
|
+
# Errors are NEVER suppressed, even in quiet mode
|
|
199
|
+
err_console.print(f"[red]Error:[/red] {message}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def warn(message: str) -> None:
|
|
203
|
+
if not _quiet:
|
|
204
|
+
err_console.print(f"[yellow]Warning:[/yellow] {message}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def info(message: str) -> None:
|
|
208
|
+
if not _quiet:
|
|
209
|
+
err_console.print(f"[blue]Info:[/blue] {message}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Pagination helpers.
|
|
2
|
+
|
|
3
|
+
Provides both:
|
|
4
|
+
- paginate_all(): API-level pagination (fetches all pages from Google API)
|
|
5
|
+
- paginate(): Client-side pagination (slices a local list for display)
|
|
6
|
+
|
|
7
|
+
Equivalent to GTM CLI's pagination.ts + paginateAll() in client.ts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Callable, TypeVar
|
|
14
|
+
|
|
15
|
+
from ..config.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def paginate_all(
|
|
21
|
+
list_fn: Callable[..., Any],
|
|
22
|
+
result_key: str,
|
|
23
|
+
**kwargs: Any,
|
|
24
|
+
) -> list:
|
|
25
|
+
"""Paginate through all results from a Google API list operation.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
list_fn: The API list method (e.g., admin.accounts().list)
|
|
29
|
+
result_key: The key in the response containing the items array
|
|
30
|
+
**kwargs: Additional arguments to pass to the list method
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
All items across all pages.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
accounts = paginate_all(
|
|
37
|
+
lambda **kw: admin.accounts().list(**kw).execute(),
|
|
38
|
+
"accounts",
|
|
39
|
+
pageSize=200,
|
|
40
|
+
)
|
|
41
|
+
"""
|
|
42
|
+
all_results = []
|
|
43
|
+
page_token = None
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
if page_token:
|
|
47
|
+
kwargs["pageToken"] = page_token
|
|
48
|
+
|
|
49
|
+
response = list_fn(**kwargs)
|
|
50
|
+
items = response.get(result_key, [])
|
|
51
|
+
all_results.extend(items)
|
|
52
|
+
|
|
53
|
+
page_token = response.get("nextPageToken")
|
|
54
|
+
if not page_token:
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
return all_results
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class PaginatedResult:
|
|
62
|
+
"""Result of client-side pagination."""
|
|
63
|
+
items: list
|
|
64
|
+
page: int
|
|
65
|
+
page_size: int
|
|
66
|
+
total_items: int
|
|
67
|
+
total_pages: int
|
|
68
|
+
has_next_page: bool
|
|
69
|
+
has_prev_page: bool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def paginate(items: list, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE) -> PaginatedResult:
|
|
73
|
+
"""Client-side pagination of a local list.
|
|
74
|
+
|
|
75
|
+
Equivalent to GTM CLI's paginate() in pagination.ts.
|
|
76
|
+
"""
|
|
77
|
+
page = max(1, page)
|
|
78
|
+
page_size = min(MAX_PAGE_SIZE, max(1, page_size))
|
|
79
|
+
|
|
80
|
+
total_items = len(items)
|
|
81
|
+
total_pages = max(1, (total_items + page_size - 1) // page_size)
|
|
82
|
+
start = (page - 1) * page_size
|
|
83
|
+
end = min(start + page_size, total_items)
|
|
84
|
+
|
|
85
|
+
return PaginatedResult(
|
|
86
|
+
items=items[start:end],
|
|
87
|
+
page=page,
|
|
88
|
+
page_size=page_size,
|
|
89
|
+
total_items=total_items,
|
|
90
|
+
total_pages=total_pages,
|
|
91
|
+
has_next_page=page < total_pages,
|
|
92
|
+
has_prev_page=page > 1,
|
|
93
|
+
)
|