logion-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.
- cli/__init__.py +2 -0
- cli/_config.py +51 -0
- cli/_confirm.py +16 -0
- cli/_context.py +17 -0
- cli/_course_bundle.py +46 -0
- cli/_course_capabilities.py +580 -0
- cli/_credentials.py +104 -0
- cli/_errors.py +82 -0
- cli/_first_run.py +90 -0
- cli/_harness/__init__.py +68 -0
- cli/_harness/base.py +106 -0
- cli/_harness/claude_code.py +168 -0
- cli/_harness/codex.py +79 -0
- cli/_harness/custom.py +55 -0
- cli/_harness/hermes.py +93 -0
- cli/_harness/opencode.py +255 -0
- cli/_local_state.py +1053 -0
- cli/_options.py +36 -0
- cli/_output.py +47 -0
- cli/_parser.py +73 -0
- cli/_recall_calibration.py +90 -0
- cli/_recall_ranker.py +74 -0
- cli/_taxonomy.py +120 -0
- cli/_update_policy.py +152 -0
- cli/_utils.py +16 -0
- cli/_version.py +26 -0
- cli/commands/__init__.py +2 -0
- cli/commands/admin.py +535 -0
- cli/commands/bounties.py +490 -0
- cli/commands/course_reviews/__init__.py +6 -0
- cli/commands/course_reviews/_download_handler.py +104 -0
- cli/commands/course_reviews/_render.py +129 -0
- cli/commands/course_reviews/handlers.py +197 -0
- cli/commands/course_reviews/parser.py +93 -0
- cli/commands/courses/__init__.py +6 -0
- cli/commands/courses/_capability_render.py +183 -0
- cli/commands/courses/_cmd_help.py +18 -0
- cli/commands/courses/_purchase.py +76 -0
- cli/commands/courses/_review_helpers.py +93 -0
- cli/commands/courses/_taxonomy_data.py +173 -0
- cli/commands/courses/_upload_bundle_validation.py +28 -0
- cli/commands/courses/_uploads_push.py +243 -0
- cli/commands/courses/capabilities.py +250 -0
- cli/commands/courses/capability_frontmatter.py +150 -0
- cli/commands/courses/handlers.py +50 -0
- cli/commands/courses/mutations.py +217 -0
- cli/commands/courses/parser.py +66 -0
- cli/commands/courses/parser_capabilities.py +95 -0
- cli/commands/courses/parser_sections.py +239 -0
- cli/commands/courses/parser_uploads.py +84 -0
- cli/commands/courses/parser_utils.py +65 -0
- cli/commands/courses/publication.py +60 -0
- cli/commands/courses/report_usage.py +131 -0
- cli/commands/courses/reviews.py +237 -0
- cli/commands/courses/taxonomy_handler.py +61 -0
- cli/commands/courses/taxonomy_suggest.py +197 -0
- cli/commands/courses/uploads.py +142 -0
- cli/commands/courses/versions.py +65 -0
- cli/commands/credits/__init__.py +6 -0
- cli/commands/credits/_helpers.py +153 -0
- cli/commands/credits/handlers.py +218 -0
- cli/commands/credits/parser.py +115 -0
- cli/commands/docs/__init__.py +6 -0
- cli/commands/docs/handlers.py +137 -0
- cli/commands/docs/parser.py +27 -0
- cli/commands/health/__init__.py +6 -0
- cli/commands/health/handlers.py +26 -0
- cli/commands/health/parser.py +20 -0
- cli/commands/identity/__init__.py +6 -0
- cli/commands/identity/_autopost.py +97 -0
- cli/commands/identity/_closing_copy.py +89 -0
- cli/commands/identity/_companion.py +232 -0
- cli/commands/identity/_companion_source.py +135 -0
- cli/commands/identity/_harness_select.py +85 -0
- cli/commands/identity/_onboarding_helpers.py +168 -0
- cli/commands/identity/handlers.py +173 -0
- cli/commands/identity/onboarding.py +246 -0
- cli/commands/identity/parser.py +72 -0
- cli/commands/listings/__init__.py +6 -0
- cli/commands/listings/handlers.py +135 -0
- cli/commands/listings/parser.py +57 -0
- cli/commands/notifications/__init__.py +6 -0
- cli/commands/notifications/handlers.py +120 -0
- cli/commands/notifications/parser.py +49 -0
- cli/commands/payments/__init__.py +6 -0
- cli/commands/payments/_orders_helpers.py +114 -0
- cli/commands/payments/handlers.py +138 -0
- cli/commands/payments/parser.py +97 -0
- cli/commands/recall/__init__.py +7 -0
- cli/commands/recall/handlers.py +87 -0
- cli/commands/recall/parser.py +70 -0
- cli/commands/referrals/__init__.py +6 -0
- cli/commands/referrals/_helpers.py +63 -0
- cli/commands/referrals/handlers.py +100 -0
- cli/commands/referrals/parser.py +65 -0
- cli/commands/reports/__init__.py +6 -0
- cli/commands/reports/handlers.py +57 -0
- cli/commands/reports/parser.py +52 -0
- cli/commands/skills/__init__.py +7 -0
- cli/commands/skills/_agent_symlink.py +161 -0
- cli/commands/skills/_finalize.py +112 -0
- cli/commands/skills/_inspect_handler.py +218 -0
- cli/commands/skills/_install_helpers.py +186 -0
- cli/commands/skills/_query_handlers.py +83 -0
- cli/commands/skills/_search_handler.py +136 -0
- cli/commands/skills/_update_handler.py +110 -0
- cli/commands/skills/_verify_handler.py +109 -0
- cli/commands/skills/handlers.py +202 -0
- cli/commands/skills/parser.py +154 -0
- cli/commands/workspace.py +406 -0
- cli/docs/README.md +5 -0
- cli/docs/__init__.py +1 -0
- cli/docs/bounties-and-referrals.md +18 -0
- cli/docs/concepts.md +47 -0
- cli/docs/creating-courses.md +25 -0
- cli/docs/credits-and-purchases.md +30 -0
- cli/docs/credits-terms.md +23 -0
- cli/docs/getting-started.md +95 -0
- cli/docs/marketplace-loop.md +108 -0
- cli/docs/privacy.md +30 -0
- cli/docs/referral-terms.md +24 -0
- cli/docs/reviews.md +47 -0
- cli/docs/safety.md +28 -0
- cli/docs/terms.md +54 -0
- cli/main.py +84 -0
- cli/templates/__init__.py +2 -0
- cli/templates/course_capabilities.template.yaml +189 -0
- cli/templates/course_license_apache-2.0.template.txt +30 -0
- cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
- cli/templates/course_license_mit.template.txt +21 -0
- logion_cli-0.1.0.dist-info/METADATA +49 -0
- logion_cli-0.1.0.dist-info/RECORD +135 -0
- logion_cli-0.1.0.dist-info/WHEEL +4 -0
- logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
- logion_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Course purchase handler — credit-spend gated by ``--yes``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from cli._config import resolve_config_from_args
|
|
10
|
+
from cli._confirm import require_yes
|
|
11
|
+
from cli._context import make_client
|
|
12
|
+
from cli._errors import handle_error, print_err, validate_uuid_id
|
|
13
|
+
from cli._output import emit, emit_json, to_data
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_purchase(args: argparse.Namespace) -> int:
|
|
17
|
+
"""Execute the courses purchase command."""
|
|
18
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
19
|
+
if bad_id is not None:
|
|
20
|
+
return bad_id
|
|
21
|
+
refusal = require_yes(args.yes, "spend credits and purchase this course")
|
|
22
|
+
if refusal is not None:
|
|
23
|
+
return refusal
|
|
24
|
+
config = resolve_config_from_args(args)
|
|
25
|
+
client = make_client(config)
|
|
26
|
+
try:
|
|
27
|
+
result = client.v1.courses.purchase(
|
|
28
|
+
course_id=args.course_id,
|
|
29
|
+
expected_price_cents=args.expected_price_cents,
|
|
30
|
+
idempotency_key=args.idempotency_key,
|
|
31
|
+
)
|
|
32
|
+
data = to_data(result)
|
|
33
|
+
if config.json_output:
|
|
34
|
+
emit_json("logion.courses.purchase", data)
|
|
35
|
+
else:
|
|
36
|
+
_emit_purchase_human(data)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
if _is_insufficient_credit_error(exc):
|
|
39
|
+
print_err(
|
|
40
|
+
"Insufficient credits. Check `logion credits balance` and "
|
|
41
|
+
"create a user-approved top-up with `logion credits top-up`."
|
|
42
|
+
)
|
|
43
|
+
return handle_error(exc)
|
|
44
|
+
else:
|
|
45
|
+
return 0
|
|
46
|
+
finally:
|
|
47
|
+
client.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_insufficient_credit_error(exc: Exception) -> bool:
|
|
51
|
+
"""Return whether an API error reports an insufficient credit balance."""
|
|
52
|
+
detail = getattr(exc, "detail", "")
|
|
53
|
+
text = str(detail).lower()
|
|
54
|
+
return "insufficient" in text and "credit" in text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _emit_purchase_human(data: dict[str, object]) -> None:
|
|
58
|
+
"""Render course purchase result with explicit credit spend lines."""
|
|
59
|
+
lines: list[str] = []
|
|
60
|
+
amount = data.get("amount_cents")
|
|
61
|
+
if amount is not None:
|
|
62
|
+
lines.append(f"cost_cents: {amount}")
|
|
63
|
+
before = data.get("balance_before_cents")
|
|
64
|
+
after = data.get("balance_after_cents")
|
|
65
|
+
if before is not None and after is not None:
|
|
66
|
+
lines.append(f"balance_cents: {before} -> {after}")
|
|
67
|
+
flow = data.get("purchase_flow")
|
|
68
|
+
if flow is not None:
|
|
69
|
+
lines.append(f"purchase_flow: {flow}")
|
|
70
|
+
entitlement = data.get("entitlement_granted")
|
|
71
|
+
if entitlement is not None:
|
|
72
|
+
lines.append(f"entitlement_granted: {entitlement}")
|
|
73
|
+
if lines:
|
|
74
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
75
|
+
else:
|
|
76
|
+
emit(data, json_output=False)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Helper functions for courses reviews commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cli._output import to_data, truncate_summary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def data_or_model_dump(result: object) -> dict[str, Any]:
|
|
12
|
+
"""Return model_dump() for Pydantic models, else to_data."""
|
|
13
|
+
if hasattr(result, "model_dump"):
|
|
14
|
+
return result.model_dump(mode="json") # type: ignore[union-attr]
|
|
15
|
+
return to_data(result)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def collect_reviews(
|
|
19
|
+
client: object,
|
|
20
|
+
course_id: str,
|
|
21
|
+
version: str | None,
|
|
22
|
+
limit: int | None,
|
|
23
|
+
) -> list[dict[str, Any]]:
|
|
24
|
+
"""Paginate through list_reviews and return all review dicts."""
|
|
25
|
+
from cli._utils import only_not_none
|
|
26
|
+
|
|
27
|
+
reviews: list[dict[str, Any]] = []
|
|
28
|
+
cursor: str | None = None
|
|
29
|
+
page_size = limit or 100
|
|
30
|
+
while True:
|
|
31
|
+
kwargs = only_not_none(
|
|
32
|
+
{"course_id": course_id},
|
|
33
|
+
version=version,
|
|
34
|
+
limit=page_size,
|
|
35
|
+
cursor=cursor,
|
|
36
|
+
)
|
|
37
|
+
result = client.v1.courses.list_reviews(**kwargs) # type: ignore[attr-defined]
|
|
38
|
+
if hasattr(result, "model_dump"):
|
|
39
|
+
data: dict[str, Any] = result.model_dump(mode="json")
|
|
40
|
+
elif isinstance(result, dict):
|
|
41
|
+
data = result
|
|
42
|
+
else:
|
|
43
|
+
import json
|
|
44
|
+
|
|
45
|
+
data = json.loads(json.dumps(result, default=str))
|
|
46
|
+
batch = data.get("reviews", [])
|
|
47
|
+
reviews.extend(batch)
|
|
48
|
+
next_cursor = data.get("next_cursor")
|
|
49
|
+
if not next_cursor or (limit and len(reviews) >= limit):
|
|
50
|
+
break
|
|
51
|
+
cursor = next_cursor
|
|
52
|
+
return reviews[:limit] if limit is not None else reviews
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_summary(
|
|
56
|
+
course_id: str,
|
|
57
|
+
all_reviews: list[dict[str, Any]],
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Build the summary dict from a list of review dicts."""
|
|
60
|
+
counted = [r for r in all_reviews if r.get("counts_toward_rating", True)]
|
|
61
|
+
review_count = len(counted)
|
|
62
|
+
rating_histogram = {str(score): 0 for score in range(1, 6)}
|
|
63
|
+
for review in counted:
|
|
64
|
+
rating = review.get("rating")
|
|
65
|
+
if isinstance(rating, int) and 1 <= rating <= 5:
|
|
66
|
+
rating_histogram[str(rating)] += 1
|
|
67
|
+
|
|
68
|
+
summary: dict[str, Any] = {
|
|
69
|
+
"course_id": course_id,
|
|
70
|
+
"review_count": review_count,
|
|
71
|
+
"rating_avg": None,
|
|
72
|
+
"rating_histogram": rating_histogram,
|
|
73
|
+
}
|
|
74
|
+
if review_count > 0:
|
|
75
|
+
avg_rating = sum(r.get("rating", 0) for r in counted) / review_count
|
|
76
|
+
summary["rating_avg"] = round(avg_rating, 2)
|
|
77
|
+
return summary
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compact_review(review: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""Build the compact review payload required by the CLI contract."""
|
|
82
|
+
return {
|
|
83
|
+
"review_id": review.get("review_id", review.get("id")),
|
|
84
|
+
"rating": review.get("rating"),
|
|
85
|
+
"body_excerpt": truncate_summary(
|
|
86
|
+
review.get("body") if isinstance(review.get("body"), str) else None
|
|
87
|
+
),
|
|
88
|
+
"agent_id": review.get("agent_id", review.get("reviewer_agent_id")),
|
|
89
|
+
"version_id": review.get(
|
|
90
|
+
"version_id", review.get("course_version_id")
|
|
91
|
+
),
|
|
92
|
+
"created_at": review.get("created_at", review.get("submitted_at")),
|
|
93
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Static keyword mappings for deterministic taxonomy suggestions.
|
|
3
|
+
|
|
4
|
+
Kept separate from the suggestion logic to respect the CLI architecture
|
|
5
|
+
source-file size limit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# Deterministic keyword → category mapping. Order matters only for
|
|
11
|
+
# readability; each keyword set is disjoint in practice.
|
|
12
|
+
CATEGORY_KEYWORDS: dict[str, frozenset[str]] = {
|
|
13
|
+
"automation": frozenset({
|
|
14
|
+
"automation",
|
|
15
|
+
"automate",
|
|
16
|
+
"workflow",
|
|
17
|
+
"pipeline",
|
|
18
|
+
"ci",
|
|
19
|
+
"cd",
|
|
20
|
+
}),
|
|
21
|
+
"code-review": frozenset({
|
|
22
|
+
"review",
|
|
23
|
+
"pr",
|
|
24
|
+
"pull-request",
|
|
25
|
+
"lint",
|
|
26
|
+
"linter",
|
|
27
|
+
"code-review",
|
|
28
|
+
}),
|
|
29
|
+
"data": frozenset({
|
|
30
|
+
"data",
|
|
31
|
+
"dataset",
|
|
32
|
+
"database",
|
|
33
|
+
"sql",
|
|
34
|
+
"etl",
|
|
35
|
+
"warehouse",
|
|
36
|
+
}),
|
|
37
|
+
"devops": frozenset({
|
|
38
|
+
"devops",
|
|
39
|
+
"terraform",
|
|
40
|
+
"kubernetes",
|
|
41
|
+
"docker",
|
|
42
|
+
"helm",
|
|
43
|
+
"deploy",
|
|
44
|
+
}),
|
|
45
|
+
"documentation": frozenset({
|
|
46
|
+
"docs",
|
|
47
|
+
"documentation",
|
|
48
|
+
"readme",
|
|
49
|
+
"manual",
|
|
50
|
+
"guide",
|
|
51
|
+
}),
|
|
52
|
+
"finance": frozenset({
|
|
53
|
+
"finance",
|
|
54
|
+
"accounting",
|
|
55
|
+
"invoice",
|
|
56
|
+
"ledger",
|
|
57
|
+
"payment",
|
|
58
|
+
}),
|
|
59
|
+
"marketing": frozenset({
|
|
60
|
+
"marketing",
|
|
61
|
+
"seo",
|
|
62
|
+
"campaign",
|
|
63
|
+
"email",
|
|
64
|
+
"newsletter",
|
|
65
|
+
}),
|
|
66
|
+
"media": frozenset({
|
|
67
|
+
"video",
|
|
68
|
+
"audio",
|
|
69
|
+
"image",
|
|
70
|
+
"media",
|
|
71
|
+
"transcode",
|
|
72
|
+
"editing",
|
|
73
|
+
}),
|
|
74
|
+
"productivity": frozenset({
|
|
75
|
+
"productivity",
|
|
76
|
+
"task",
|
|
77
|
+
"todo",
|
|
78
|
+
"notes",
|
|
79
|
+
"calendar",
|
|
80
|
+
"schedule",
|
|
81
|
+
}),
|
|
82
|
+
"research": frozenset({
|
|
83
|
+
"research",
|
|
84
|
+
"analysis",
|
|
85
|
+
"survey",
|
|
86
|
+
"study",
|
|
87
|
+
"literature",
|
|
88
|
+
}),
|
|
89
|
+
"security": frozenset({
|
|
90
|
+
"security",
|
|
91
|
+
"vulnerability",
|
|
92
|
+
"cve",
|
|
93
|
+
"pentest",
|
|
94
|
+
"audit",
|
|
95
|
+
}),
|
|
96
|
+
"testing": frozenset({
|
|
97
|
+
"test",
|
|
98
|
+
"testing",
|
|
99
|
+
"qa",
|
|
100
|
+
"unit-test",
|
|
101
|
+
"integration-test",
|
|
102
|
+
"pytest",
|
|
103
|
+
}),
|
|
104
|
+
"writing": frozenset({
|
|
105
|
+
"writing",
|
|
106
|
+
"blog",
|
|
107
|
+
"article",
|
|
108
|
+
"content",
|
|
109
|
+
"copy",
|
|
110
|
+
"prose",
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Words that appear frequently but carry no taxonomy signal.
|
|
115
|
+
STOP_WORDS: frozenset[str] = frozenset({
|
|
116
|
+
"the",
|
|
117
|
+
"and",
|
|
118
|
+
"for",
|
|
119
|
+
"with",
|
|
120
|
+
"your",
|
|
121
|
+
"this",
|
|
122
|
+
"that",
|
|
123
|
+
"from",
|
|
124
|
+
"into",
|
|
125
|
+
"using",
|
|
126
|
+
"use",
|
|
127
|
+
"can",
|
|
128
|
+
"will",
|
|
129
|
+
"you",
|
|
130
|
+
"are",
|
|
131
|
+
"not",
|
|
132
|
+
"but",
|
|
133
|
+
"how",
|
|
134
|
+
"what",
|
|
135
|
+
"when",
|
|
136
|
+
"who",
|
|
137
|
+
"all",
|
|
138
|
+
"new",
|
|
139
|
+
"one",
|
|
140
|
+
"two",
|
|
141
|
+
"get",
|
|
142
|
+
"set",
|
|
143
|
+
"run",
|
|
144
|
+
"make",
|
|
145
|
+
"via",
|
|
146
|
+
"out",
|
|
147
|
+
"our",
|
|
148
|
+
"own",
|
|
149
|
+
"any",
|
|
150
|
+
"has",
|
|
151
|
+
"had",
|
|
152
|
+
"was",
|
|
153
|
+
"were",
|
|
154
|
+
"been",
|
|
155
|
+
"have",
|
|
156
|
+
"they",
|
|
157
|
+
"them",
|
|
158
|
+
"their",
|
|
159
|
+
"there",
|
|
160
|
+
"here",
|
|
161
|
+
"some",
|
|
162
|
+
"such",
|
|
163
|
+
"than",
|
|
164
|
+
"then",
|
|
165
|
+
"also",
|
|
166
|
+
"only",
|
|
167
|
+
"just",
|
|
168
|
+
"like",
|
|
169
|
+
"able",
|
|
170
|
+
"over",
|
|
171
|
+
"more",
|
|
172
|
+
"most",
|
|
173
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Course-bundle validation for upload-time CLI flows."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cli._course_bundle import CourseBundleError, validate_course_bundle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_bundle_files_for_upload(
|
|
13
|
+
*,
|
|
14
|
+
file_map: dict[str, Path],
|
|
15
|
+
paid: bool,
|
|
16
|
+
) -> tuple[bool, str | None]:
|
|
17
|
+
"""Validate the local upload set as a publishable course bundle."""
|
|
18
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
19
|
+
bundle_dir = Path(tmp)
|
|
20
|
+
for upload_path, local_path in file_map.items():
|
|
21
|
+
target = bundle_dir / upload_path
|
|
22
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
target.write_bytes(local_path.read_bytes())
|
|
24
|
+
try:
|
|
25
|
+
validate_course_bundle(bundle_dir, paid=paid)
|
|
26
|
+
except CourseBundleError as exc:
|
|
27
|
+
return False, str(exc)
|
|
28
|
+
return True, None
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Push bytes to S3 for a previously created upload session."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cli._config import resolve_config_from_args
|
|
14
|
+
from cli._context import make_client
|
|
15
|
+
from cli._errors import print_err, validate_uuid_id
|
|
16
|
+
from cli._output import emit_json
|
|
17
|
+
|
|
18
|
+
from ._upload_bundle_validation import validate_bundle_files_for_upload
|
|
19
|
+
from .uploads import _resolve_upload_files
|
|
20
|
+
|
|
21
|
+
# Exit codes used by ``handle_uploads_push``.
|
|
22
|
+
EXIT_OK = 0
|
|
23
|
+
EXIT_BAD_ARGS = 2
|
|
24
|
+
EXIT_SESSION_MISMATCH = 3
|
|
25
|
+
EXIT_MISSING_FILES = 4
|
|
26
|
+
EXIT_UPLOAD_FAILED = 6
|
|
27
|
+
|
|
28
|
+
DEFAULT_MAX_RETRIES = 3
|
|
29
|
+
DEFAULT_TIMEOUT_S = 60.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_session(path: str) -> dict[str, Any] | None:
|
|
33
|
+
"""Load the upload-session JSON from *path* (``-`` for stdin)."""
|
|
34
|
+
try:
|
|
35
|
+
raw = (
|
|
36
|
+
sys.stdin.read()
|
|
37
|
+
if path == "-"
|
|
38
|
+
else Path(path).read_text(encoding="utf-8")
|
|
39
|
+
)
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
print_err(f"could not read session file: {exc}")
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(raw)
|
|
45
|
+
except json.JSONDecodeError as exc:
|
|
46
|
+
print_err(f"session file is not valid JSON: {exc}")
|
|
47
|
+
return None
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
print_err("session JSON must be an object")
|
|
50
|
+
return None
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _put_one(
|
|
55
|
+
upload: dict[str, Any],
|
|
56
|
+
local_path: Path,
|
|
57
|
+
max_retries: int,
|
|
58
|
+
timeout: float,
|
|
59
|
+
) -> tuple[bool, str]:
|
|
60
|
+
"""PUT *local_path* to ``upload['put_url']`` with retries.
|
|
61
|
+
|
|
62
|
+
Returns ``(success, message)``. Retries on transient httpx
|
|
63
|
+
errors and HTTP 5xx; aborts immediately on 4xx (presigned URL
|
|
64
|
+
rejected — usually a content-length / header mismatch).
|
|
65
|
+
"""
|
|
66
|
+
import httpx
|
|
67
|
+
|
|
68
|
+
url = upload.get("put_url")
|
|
69
|
+
headers = upload.get("required_headers") or {}
|
|
70
|
+
if not isinstance(url, str) or not url:
|
|
71
|
+
return False, "missing put_url"
|
|
72
|
+
|
|
73
|
+
last_err = ""
|
|
74
|
+
for attempt in range(max_retries + 1):
|
|
75
|
+
try:
|
|
76
|
+
with local_path.open("rb") as fh:
|
|
77
|
+
response = httpx.request(
|
|
78
|
+
upload.get("method", "PUT"),
|
|
79
|
+
url,
|
|
80
|
+
content=fh,
|
|
81
|
+
headers=headers,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
)
|
|
84
|
+
except httpx.RequestError as exc:
|
|
85
|
+
last_err = f"network error: {exc}"
|
|
86
|
+
else:
|
|
87
|
+
if 200 <= response.status_code < 300:
|
|
88
|
+
return True, f"HTTP {response.status_code}"
|
|
89
|
+
if 400 <= response.status_code < 500:
|
|
90
|
+
# Client errors won't recover on retry.
|
|
91
|
+
return False, (
|
|
92
|
+
f"HTTP {response.status_code}: {response.text[:200]}"
|
|
93
|
+
)
|
|
94
|
+
last_err = f"HTTP {response.status_code}"
|
|
95
|
+
if attempt < max_retries:
|
|
96
|
+
# Linear backoff is enough — presigned URLs are short-lived.
|
|
97
|
+
time.sleep(0.5 * (attempt + 1))
|
|
98
|
+
return False, last_err or "exhausted retries"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_filename_map(
|
|
102
|
+
file_specs: list[str],
|
|
103
|
+
) -> dict[str, Path] | None:
|
|
104
|
+
"""Resolve --file specs to a ``{upload_path: local_path}`` map."""
|
|
105
|
+
resolved = _resolve_upload_files(file_specs)
|
|
106
|
+
if resolved is None:
|
|
107
|
+
return None
|
|
108
|
+
return dict(resolved)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_session_ids(
|
|
112
|
+
session: dict[str, Any],
|
|
113
|
+
course_id: str,
|
|
114
|
+
version_id: str,
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""Refuse to push if the session is for a different course/version."""
|
|
117
|
+
s_course = str(session.get("course_id", ""))
|
|
118
|
+
s_version = str(session.get("version_id", ""))
|
|
119
|
+
if s_course and s_course != course_id:
|
|
120
|
+
print_err(
|
|
121
|
+
f"session is for course {s_course}, "
|
|
122
|
+
f"but command was invoked with {course_id}"
|
|
123
|
+
)
|
|
124
|
+
return False
|
|
125
|
+
if s_version and s_version != version_id:
|
|
126
|
+
print_err(
|
|
127
|
+
f"session is for version {s_version}, "
|
|
128
|
+
f"but command was invoked with {version_id}"
|
|
129
|
+
)
|
|
130
|
+
return False
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _prepare_push(
|
|
135
|
+
args: argparse.Namespace,
|
|
136
|
+
) -> tuple[int, list[dict[str, Any]] | None, dict[str, Path] | None]:
|
|
137
|
+
"""Validate args + session and return ``(rc, uploads, file_map)``.
|
|
138
|
+
|
|
139
|
+
On any validation failure the returned ``rc`` is non-zero and the
|
|
140
|
+
other two values are ``None``. Split out of ``handle_uploads_push``
|
|
141
|
+
purely to keep the handler under the project's cyclomatic-complexity
|
|
142
|
+
budget — the logic is otherwise sequential.
|
|
143
|
+
"""
|
|
144
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
145
|
+
if bad_id is not None:
|
|
146
|
+
return bad_id, None, None
|
|
147
|
+
bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
|
|
148
|
+
if bad_id is not None:
|
|
149
|
+
return bad_id, None, None
|
|
150
|
+
if not args.files:
|
|
151
|
+
print_err("Error: at least one --file is required")
|
|
152
|
+
return EXIT_BAD_ARGS, None, None
|
|
153
|
+
session = _read_session(args.session_file)
|
|
154
|
+
if session is None:
|
|
155
|
+
return EXIT_BAD_ARGS, None, None
|
|
156
|
+
if not _validate_session_ids(session, args.course_id, args.version_id):
|
|
157
|
+
return EXIT_SESSION_MISMATCH, None, None
|
|
158
|
+
file_map = _build_filename_map(args.files)
|
|
159
|
+
if file_map is None:
|
|
160
|
+
return EXIT_BAD_ARGS, None, None
|
|
161
|
+
uploads = session.get("uploads") or []
|
|
162
|
+
if not isinstance(uploads, list) or not uploads:
|
|
163
|
+
print_err("session has no uploads to push")
|
|
164
|
+
return EXIT_BAD_ARGS, None, None
|
|
165
|
+
missing = [
|
|
166
|
+
u.get("filename")
|
|
167
|
+
for u in uploads
|
|
168
|
+
if isinstance(u, dict) and u.get("filename") not in file_map
|
|
169
|
+
]
|
|
170
|
+
if missing:
|
|
171
|
+
print_err(
|
|
172
|
+
"no local file provided for: " + ", ".join(str(m) for m in missing)
|
|
173
|
+
)
|
|
174
|
+
return EXIT_MISSING_FILES, None, None
|
|
175
|
+
return EXIT_OK, uploads, file_map
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _emit_results(
|
|
179
|
+
results: list[dict[str, Any]],
|
|
180
|
+
failures: int,
|
|
181
|
+
json_output: bool,
|
|
182
|
+
) -> None:
|
|
183
|
+
payload = {"results": results, "failures": failures}
|
|
184
|
+
if json_output:
|
|
185
|
+
emit_json("logion.courses.uploads.push", payload)
|
|
186
|
+
return
|
|
187
|
+
for r in results:
|
|
188
|
+
status = "OK" if r["ok"] else "FAIL"
|
|
189
|
+
print(f" [{status}] {r['filename']}: {r['detail']}")
|
|
190
|
+
print(f"Pushed {len(results) - failures}/{len(results)} files.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _course_price_cents(course: Any) -> int:
|
|
194
|
+
"""Read ``price_cents`` from a SDK response object or plain dict."""
|
|
195
|
+
if isinstance(course, dict):
|
|
196
|
+
return int(course.get("price_cents", 0) or 0)
|
|
197
|
+
return int(getattr(course, "price_cents", 0) or 0)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def handle_uploads_push(args: argparse.Namespace) -> int:
|
|
201
|
+
"""Push every file in the upload session to its presigned URL."""
|
|
202
|
+
rc, uploads, file_map = _prepare_push(args)
|
|
203
|
+
if rc != EXIT_OK or uploads is None or file_map is None:
|
|
204
|
+
return rc
|
|
205
|
+
|
|
206
|
+
config = resolve_config_from_args(args)
|
|
207
|
+
client = make_client(config)
|
|
208
|
+
try:
|
|
209
|
+
course = client.v1.courses.get(course_id=args.course_id)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
print_err(f"could not fetch course metadata: {exc}")
|
|
212
|
+
return EXIT_BAD_ARGS
|
|
213
|
+
finally:
|
|
214
|
+
client.close()
|
|
215
|
+
|
|
216
|
+
ok, error = validate_bundle_files_for_upload(
|
|
217
|
+
file_map=file_map,
|
|
218
|
+
paid=_course_price_cents(course) > 0,
|
|
219
|
+
)
|
|
220
|
+
if not ok:
|
|
221
|
+
print_err(f"invalid course bundle for upload: {error}")
|
|
222
|
+
return EXIT_BAD_ARGS
|
|
223
|
+
|
|
224
|
+
results: list[dict[str, Any]] = []
|
|
225
|
+
failures = 0
|
|
226
|
+
# COMMON_PARSER leaves --max-retries / --timeout as None when the
|
|
227
|
+
# caller omits them; fall back to push-specific defaults.
|
|
228
|
+
max_retries = (
|
|
229
|
+
args.max_retries
|
|
230
|
+
if args.max_retries is not None
|
|
231
|
+
else DEFAULT_MAX_RETRIES
|
|
232
|
+
)
|
|
233
|
+
timeout = args.timeout if args.timeout is not None else DEFAULT_TIMEOUT_S
|
|
234
|
+
for upload in uploads:
|
|
235
|
+
filename = upload.get("filename", "")
|
|
236
|
+
local = file_map[filename]
|
|
237
|
+
ok, msg = _put_one(upload, local, max_retries, timeout)
|
|
238
|
+
results.append({"filename": filename, "ok": ok, "detail": msg})
|
|
239
|
+
if not ok:
|
|
240
|
+
failures += 1
|
|
241
|
+
|
|
242
|
+
_emit_results(results, failures, config.json_output)
|
|
243
|
+
return EXIT_OK if failures == 0 else EXIT_UPLOAD_FAILED
|