sweatstack-cli 0.8.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/PKG-INFO +2 -2
  2. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/README.md +1 -1
  3. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/pyproject.toml +1 -1
  4. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/__init__.py +1 -1
  5. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/commands/app.py +33 -2
  6. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/commands/page.py +67 -2
  7. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/sweatstack.toml +3 -0
  8. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_commands/test_app.py +104 -1
  9. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/uv.lock +1 -1
  10. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/settings.local.json +0 -0
  11. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/skills/sweatstack/SKILL.md +0 -0
  12. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/skills/sweatstack/api.md +0 -0
  13. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/skills/sweatstack/auth.md +0 -0
  14. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/skills/sweatstack/cli.md +0 -0
  15. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/skills/sweatstack/pyscript.md +0 -0
  16. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.claude/skills/sweatstack/webapp.md +0 -0
  17. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.gitignore +0 -0
  18. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/.python-version +0 -0
  19. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/CREATE_PRIVATE_APP.md +0 -0
  20. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/DEVELOPMENT.md +0 -0
  21. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/Makefile +0 -0
  22. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/PLAN.md +0 -0
  23. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/api/__init__.py +0 -0
  24. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/api/client.py +0 -0
  25. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/auth/__init__.py +0 -0
  26. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
  27. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/auth/jwt.py +0 -0
  28. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/auth/pkce.py +0 -0
  29. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/auth/tokens.py +0 -0
  30. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/commands/__init__.py +0 -0
  31. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/commands/auth.py +0 -0
  32. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/config.py +0 -0
  33. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/console.py +0 -0
  34. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/exceptions.py +0 -0
  35. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/main.py +0 -0
  36. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/project.py +0 -0
  37. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/py.typed +0 -0
  38. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/src/sweatstack_cli/version_check.py +0 -0
  39. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/__init__.py +0 -0
  40. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/conftest.py +0 -0
  41. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_api/__init__.py +0 -0
  42. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_api/test_client.py +0 -0
  43. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_auth/__init__.py +0 -0
  44. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_auth/test_callback_server.py +0 -0
  45. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_auth/test_jwt.py +0 -0
  46. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_auth/test_pkce.py +0 -0
  47. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_auth/test_tokens.py +0 -0
  48. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_commands/__init__.py +0 -0
  49. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_commands/test_cli.py +0 -0
  50. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_project.py +0 -0
  51. {sweatstack_cli-0.8.0 → sweatstack_cli-0.10.0}/tests/test_version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack-cli
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Command-line interface for SweatStack — the sports data platform for developers
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://docs.sweatstack.no
@@ -88,7 +88,7 @@ sweatstack page deploy
88
88
 
89
89
  | Command | Description |
90
90
  |---------|-------------|
91
- | `sweatstack app create NAME` | Create a new private OAuth2 application |
91
+ | `sweatstack app create [NAME]` | Create a new private OAuth2 application |
92
92
  | `sweatstack app link` | Link current directory to an existing application |
93
93
 
94
94
  #### `app create` options
@@ -58,7 +58,7 @@ sweatstack page deploy
58
58
 
59
59
  | Command | Description |
60
60
  |---------|-------------|
61
- | `sweatstack app create NAME` | Create a new private OAuth2 application |
61
+ | `sweatstack app create [NAME]` | Create a new private OAuth2 application |
62
62
  | `sweatstack app link` | Link current directory to an existing application |
63
63
 
64
64
  #### `app create` options
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack-cli"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "Command-line interface for SweatStack — the sports data platform for developers"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Aart Goossens", email = "aart@sweatstack.no" }]
@@ -1,3 +1,3 @@
1
1
  """SweatStack CLI — Command-line interface for the SweatStack platform."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.10.0"
@@ -17,9 +17,14 @@ from sweatstack_cli.project import CONFIG_FILENAME, write_config
17
17
  app = typer.Typer(name="app", help="Manage OAuth2 applications.")
18
18
 
19
19
 
20
+ def _is_interactive() -> bool:
21
+ """Check if stdin is a TTY (interactive terminal)."""
22
+ return sys.stdin.isatty()
23
+
24
+
20
25
  @app.command()
21
26
  def create(
22
- name: str = typer.Argument(..., help="Application name."),
27
+ name: str | None = typer.Argument(None, help="Application name."),
23
28
  description: str | None = typer.Option(
24
29
  None,
25
30
  "--description",
@@ -59,6 +64,32 @@ def create(
59
64
  console.print("[red]Error:[/red] --env and --env-file are mutually exclusive.")
60
65
  raise typer.Exit(1)
61
66
 
67
+ interactive = _is_interactive()
68
+
69
+ # Resolve name
70
+ if name is None:
71
+ if not interactive:
72
+ console.print("[red]Error:[/red] App name is required.")
73
+ raise typer.Exit(1)
74
+ name = typer.prompt("App name")
75
+
76
+ # Prompt for optional fields in interactive mode
77
+ if interactive:
78
+ if description is None:
79
+ description = typer.prompt(
80
+ "Description (optional)", default="", show_default=False
81
+ )
82
+ if not description:
83
+ description = None
84
+ if page is None:
85
+ page = typer.prompt(
86
+ "Page slug (optional)", default="", show_default=False
87
+ )
88
+ if not page:
89
+ page = None
90
+ if not secret:
91
+ secret = typer.confirm("Generate client secret?", default=False)
92
+
62
93
  body: dict[str, str | bool] = {"name": name}
63
94
 
64
95
  if description:
@@ -90,7 +121,7 @@ def create(
90
121
  # Write sweatstack.toml (protect existing file)
91
122
  config_path = Path(CONFIG_FILENAME)
92
123
  if config_path.exists():
93
- if not sys.stdin.isatty():
124
+ if not interactive:
94
125
  console.print(
95
126
  f"[red]Error:[/red] {CONFIG_FILENAME} already exists. "
96
127
  "Remove it first or run interactively."
@@ -1,8 +1,10 @@
1
- """Page commands: deploy static sites."""
1
+ """Page commands: deploy and serve static sites."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import mimetypes
6
+ import socket
7
+ import subprocess
6
8
  import sys
7
9
  from pathlib import Path
8
10
 
@@ -117,7 +119,7 @@ def deploy(
117
119
  f"Deploying to '{slug}' but "
118
120
  f"{CONFIG_FILENAME} has slug '{config.page.slug}'"
119
121
  )
120
- if config.page.directory and rel_dir != config.page.directory:
122
+ if config.page.directory and rel_dir != config.page.directory.rstrip("/"):
121
123
  mismatches.append(
122
124
  f"Deploying '{rel_dir}' but "
123
125
  f"{CONFIG_FILENAME} has directory '{config.page.directory}'"
@@ -211,3 +213,66 @@ def deploy(
211
213
  console.print("Your app is private. Ready to go public?")
212
214
  publish_url = f"{base_url}/applications/{app_id}/publish"
213
215
  console.print(f" {publish_url}")
216
+
217
+
218
+ def _find_available_port(start: int = 8000, max_attempts: int = 100) -> int:
219
+ """Find the first available port starting from `start`."""
220
+ for port in range(start, start + max_attempts):
221
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
222
+ try:
223
+ s.bind(("localhost", port))
224
+ return port
225
+ except OSError:
226
+ continue
227
+ console.print(f"[red]Error:[/red] No available port found in range {start}-{start + max_attempts - 1}.")
228
+ raise typer.Exit(1)
229
+
230
+
231
+ @app.command(
232
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
233
+ )
234
+ def serve(
235
+ ctx: typer.Context,
236
+ ) -> None:
237
+ """Serve the static site locally using Python's http.server.
238
+
239
+ Extra arguments are passed through to python -m http.server.
240
+ """
241
+ config = ProjectConfig.load()
242
+ if not config or not config.page.directory:
243
+ console.print(
244
+ f"[red]Error:[/red] No directory configured in {CONFIG_FILENAME}."
245
+ )
246
+ console.print(f"Add a [page] section with a directory to {CONFIG_FILENAME}.")
247
+ raise typer.Exit(1)
248
+
249
+ directory = Path(config.page.directory)
250
+ if not directory.is_dir():
251
+ console.print(f"[red]Error:[/red] '{directory}' is not a directory.")
252
+ raise typer.Exit(1)
253
+
254
+ extra_args = ctx.args
255
+
256
+ # http.server takes port as a positional arg — check if user already passed one
257
+ has_port_arg = any(not arg.startswith("-") for arg in extra_args)
258
+ port_args: list[str] = []
259
+ if not has_port_arg:
260
+ port = _find_available_port()
261
+ port_args = [str(port)]
262
+
263
+ cmd = [
264
+ sys.executable, "-m", "http.server",
265
+ "--directory", str(directory),
266
+ *port_args,
267
+ *extra_args,
268
+ ]
269
+
270
+ if not has_port_arg:
271
+ console.print(f"Serving [bold]{directory}[/bold] at [cyan]http://localhost:{port}[/cyan]")
272
+ else:
273
+ console.print(f"Serving [bold]{directory}[/bold]")
274
+
275
+ try:
276
+ raise SystemExit(subprocess.call(cmd))
277
+ except KeyboardInterrupt:
278
+ pass
@@ -1,3 +1,6 @@
1
1
  [app]
2
2
  name = "Test App"
3
3
  client_id = "abc123"
4
+
5
+ [page]
6
+ directory = "src/"
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from unittest.mock import MagicMock, patch
6
+ from unittest.mock import MagicMock, PropertyMock, patch
7
7
 
8
8
  import pytest
9
9
  from typer.testing import CliRunner
@@ -247,3 +247,106 @@ class TestAppCreate:
247
247
  assert result.exit_code == 0
248
248
  assert "won't be shown again" not in result.stdout
249
249
  assert "written to" in result.stdout
250
+
251
+ def test_create_noninteractive_requires_name(self) -> None:
252
+ """Should error when name is missing in non-interactive mode."""
253
+ result = runner.invoke(app, ["app", "create"])
254
+
255
+ assert result.exit_code == 1
256
+ assert "App name is required" in result.stdout
257
+
258
+ def test_create_interactive_full(self, mock_api: MagicMock, tmp_path: Path) -> None:
259
+ """Should prompt for all fields when no args given interactively."""
260
+ mock_api.post.return_value = {"name": "My App", "id": "abc123"}
261
+
262
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
263
+ with runner.isolated_filesystem(temp_dir=tmp_path):
264
+ result = runner.invoke(
265
+ app, ["app", "create"], input="My App\n\n\nn\n"
266
+ )
267
+
268
+ assert result.exit_code == 0
269
+ assert "My App" in result.stdout
270
+ mock_api.post.assert_called_once_with(
271
+ "/api/v1/applications",
272
+ json={"name": "My App"},
273
+ )
274
+
275
+ def test_create_interactive_with_values(
276
+ self, mock_api: MagicMock, tmp_path: Path
277
+ ) -> None:
278
+ """Should send all interactively-provided values to the API."""
279
+ mock_api.post.return_value = {
280
+ "name": "My App",
281
+ "id": "abc123",
282
+ "client_secret": "secret456",
283
+ }
284
+
285
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
286
+ with runner.isolated_filesystem(temp_dir=tmp_path):
287
+ result = runner.invoke(
288
+ app,
289
+ ["app", "create"],
290
+ input="My App\nA cool app\nmy-page\ny\n",
291
+ )
292
+
293
+ assert result.exit_code == 0
294
+ mock_api.post.assert_called_once_with(
295
+ "/api/v1/applications",
296
+ json={
297
+ "name": "My App",
298
+ "description": "A cool app",
299
+ "page": "my-page",
300
+ "generate_secret": True,
301
+ },
302
+ )
303
+
304
+ def test_create_interactive_partial(
305
+ self, mock_api: MagicMock, tmp_path: Path
306
+ ) -> None:
307
+ """Should only prompt for fields not provided via args/flags."""
308
+ mock_api.post.return_value = {"name": "My App", "id": "abc123"}
309
+
310
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
311
+ with runner.isolated_filesystem(temp_dir=tmp_path):
312
+ result = runner.invoke(
313
+ app, ["app", "create", "My App"], input="\n\nn\n"
314
+ )
315
+
316
+ assert result.exit_code == 0
317
+ assert "App name" not in result.stdout
318
+ mock_api.post.assert_called_once_with(
319
+ "/api/v1/applications",
320
+ json={"name": "My App"},
321
+ )
322
+
323
+ def test_create_interactive_skips_provided_flags(
324
+ self, mock_api: MagicMock, tmp_path: Path
325
+ ) -> None:
326
+ """Should skip prompts for fields already provided via flags."""
327
+ mock_api.post.return_value = {
328
+ "name": "Test App",
329
+ "id": "abc123",
330
+ "client_secret": "secret456",
331
+ }
332
+
333
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
334
+ with runner.isolated_filesystem(temp_dir=tmp_path):
335
+ # --secret and --description provided; only page is prompted
336
+ result = runner.invoke(
337
+ app,
338
+ ["app", "create", "Test App", "--secret", "-d", "Desc"],
339
+ input="\n",
340
+ )
341
+
342
+ assert result.exit_code == 0
343
+ assert "Generate client secret?" not in result.stdout
344
+ assert "Description" not in result.stdout
345
+ mock_api.post.assert_called_once_with(
346
+ "/api/v1/applications",
347
+ json={
348
+ "name": "Test App",
349
+ "description": "Desc",
350
+ "generate_secret": True,
351
+ },
352
+ )
@@ -463,7 +463,7 @@ wheels = [
463
463
 
464
464
  [[package]]
465
465
  name = "sweatstack-cli"
466
- version = "0.8.0"
466
+ version = "0.10.0"
467
467
  source = { editable = "." }
468
468
  dependencies = [
469
469
  { name = "httpx" },
File without changes
File without changes