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.
Files changed (135) hide show
  1. cli/__init__.py +2 -0
  2. cli/_config.py +51 -0
  3. cli/_confirm.py +16 -0
  4. cli/_context.py +17 -0
  5. cli/_course_bundle.py +46 -0
  6. cli/_course_capabilities.py +580 -0
  7. cli/_credentials.py +104 -0
  8. cli/_errors.py +82 -0
  9. cli/_first_run.py +90 -0
  10. cli/_harness/__init__.py +68 -0
  11. cli/_harness/base.py +106 -0
  12. cli/_harness/claude_code.py +168 -0
  13. cli/_harness/codex.py +79 -0
  14. cli/_harness/custom.py +55 -0
  15. cli/_harness/hermes.py +93 -0
  16. cli/_harness/opencode.py +255 -0
  17. cli/_local_state.py +1053 -0
  18. cli/_options.py +36 -0
  19. cli/_output.py +47 -0
  20. cli/_parser.py +73 -0
  21. cli/_recall_calibration.py +90 -0
  22. cli/_recall_ranker.py +74 -0
  23. cli/_taxonomy.py +120 -0
  24. cli/_update_policy.py +152 -0
  25. cli/_utils.py +16 -0
  26. cli/_version.py +26 -0
  27. cli/commands/__init__.py +2 -0
  28. cli/commands/admin.py +535 -0
  29. cli/commands/bounties.py +490 -0
  30. cli/commands/course_reviews/__init__.py +6 -0
  31. cli/commands/course_reviews/_download_handler.py +104 -0
  32. cli/commands/course_reviews/_render.py +129 -0
  33. cli/commands/course_reviews/handlers.py +197 -0
  34. cli/commands/course_reviews/parser.py +93 -0
  35. cli/commands/courses/__init__.py +6 -0
  36. cli/commands/courses/_capability_render.py +183 -0
  37. cli/commands/courses/_cmd_help.py +18 -0
  38. cli/commands/courses/_purchase.py +76 -0
  39. cli/commands/courses/_review_helpers.py +93 -0
  40. cli/commands/courses/_taxonomy_data.py +173 -0
  41. cli/commands/courses/_upload_bundle_validation.py +28 -0
  42. cli/commands/courses/_uploads_push.py +243 -0
  43. cli/commands/courses/capabilities.py +250 -0
  44. cli/commands/courses/capability_frontmatter.py +150 -0
  45. cli/commands/courses/handlers.py +50 -0
  46. cli/commands/courses/mutations.py +217 -0
  47. cli/commands/courses/parser.py +66 -0
  48. cli/commands/courses/parser_capabilities.py +95 -0
  49. cli/commands/courses/parser_sections.py +239 -0
  50. cli/commands/courses/parser_uploads.py +84 -0
  51. cli/commands/courses/parser_utils.py +65 -0
  52. cli/commands/courses/publication.py +60 -0
  53. cli/commands/courses/report_usage.py +131 -0
  54. cli/commands/courses/reviews.py +237 -0
  55. cli/commands/courses/taxonomy_handler.py +61 -0
  56. cli/commands/courses/taxonomy_suggest.py +197 -0
  57. cli/commands/courses/uploads.py +142 -0
  58. cli/commands/courses/versions.py +65 -0
  59. cli/commands/credits/__init__.py +6 -0
  60. cli/commands/credits/_helpers.py +153 -0
  61. cli/commands/credits/handlers.py +218 -0
  62. cli/commands/credits/parser.py +115 -0
  63. cli/commands/docs/__init__.py +6 -0
  64. cli/commands/docs/handlers.py +137 -0
  65. cli/commands/docs/parser.py +27 -0
  66. cli/commands/health/__init__.py +6 -0
  67. cli/commands/health/handlers.py +26 -0
  68. cli/commands/health/parser.py +20 -0
  69. cli/commands/identity/__init__.py +6 -0
  70. cli/commands/identity/_autopost.py +97 -0
  71. cli/commands/identity/_closing_copy.py +89 -0
  72. cli/commands/identity/_companion.py +232 -0
  73. cli/commands/identity/_companion_source.py +135 -0
  74. cli/commands/identity/_harness_select.py +85 -0
  75. cli/commands/identity/_onboarding_helpers.py +168 -0
  76. cli/commands/identity/handlers.py +173 -0
  77. cli/commands/identity/onboarding.py +246 -0
  78. cli/commands/identity/parser.py +72 -0
  79. cli/commands/listings/__init__.py +6 -0
  80. cli/commands/listings/handlers.py +135 -0
  81. cli/commands/listings/parser.py +57 -0
  82. cli/commands/notifications/__init__.py +6 -0
  83. cli/commands/notifications/handlers.py +120 -0
  84. cli/commands/notifications/parser.py +49 -0
  85. cli/commands/payments/__init__.py +6 -0
  86. cli/commands/payments/_orders_helpers.py +114 -0
  87. cli/commands/payments/handlers.py +138 -0
  88. cli/commands/payments/parser.py +97 -0
  89. cli/commands/recall/__init__.py +7 -0
  90. cli/commands/recall/handlers.py +87 -0
  91. cli/commands/recall/parser.py +70 -0
  92. cli/commands/referrals/__init__.py +6 -0
  93. cli/commands/referrals/_helpers.py +63 -0
  94. cli/commands/referrals/handlers.py +100 -0
  95. cli/commands/referrals/parser.py +65 -0
  96. cli/commands/reports/__init__.py +6 -0
  97. cli/commands/reports/handlers.py +57 -0
  98. cli/commands/reports/parser.py +52 -0
  99. cli/commands/skills/__init__.py +7 -0
  100. cli/commands/skills/_agent_symlink.py +161 -0
  101. cli/commands/skills/_finalize.py +112 -0
  102. cli/commands/skills/_inspect_handler.py +218 -0
  103. cli/commands/skills/_install_helpers.py +186 -0
  104. cli/commands/skills/_query_handlers.py +83 -0
  105. cli/commands/skills/_search_handler.py +136 -0
  106. cli/commands/skills/_update_handler.py +110 -0
  107. cli/commands/skills/_verify_handler.py +109 -0
  108. cli/commands/skills/handlers.py +202 -0
  109. cli/commands/skills/parser.py +154 -0
  110. cli/commands/workspace.py +406 -0
  111. cli/docs/README.md +5 -0
  112. cli/docs/__init__.py +1 -0
  113. cli/docs/bounties-and-referrals.md +18 -0
  114. cli/docs/concepts.md +47 -0
  115. cli/docs/creating-courses.md +25 -0
  116. cli/docs/credits-and-purchases.md +30 -0
  117. cli/docs/credits-terms.md +23 -0
  118. cli/docs/getting-started.md +95 -0
  119. cli/docs/marketplace-loop.md +108 -0
  120. cli/docs/privacy.md +30 -0
  121. cli/docs/referral-terms.md +24 -0
  122. cli/docs/reviews.md +47 -0
  123. cli/docs/safety.md +28 -0
  124. cli/docs/terms.md +54 -0
  125. cli/main.py +84 -0
  126. cli/templates/__init__.py +2 -0
  127. cli/templates/course_capabilities.template.yaml +189 -0
  128. cli/templates/course_license_apache-2.0.template.txt +30 -0
  129. cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
  130. cli/templates/course_license_mit.template.txt +21 -0
  131. logion_cli-0.1.0.dist-info/METADATA +49 -0
  132. logion_cli-0.1.0.dist-info/RECORD +135 -0
  133. logion_cli-0.1.0.dist-info/WHEEL +4 -0
  134. logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
  135. 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