skilark 0.1.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.
@@ -0,0 +1,27 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ .env
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+
11
+ # Go binary
12
+ api/job-pulse-api
13
+
14
+ # Large data files
15
+ data.dump
16
+ schema.sql
17
+
18
+ # Generated site
19
+ site/dist/
20
+
21
+ # Terraform
22
+ terraform/.terraform/
23
+ terraform/*.tfstate
24
+ terraform/*.tfstate.backup
25
+ terraform/*.tfvars
26
+ terraform/.terraform.lock.hcl
27
+ *.old
skilark-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: skilark
3
+ Version: 0.1.0
4
+ Summary: Daily coding challenges from market intelligence
5
+ Project-URL: Homepage, https://skilark.com
6
+ Author-email: Skilark <skilark.feedback@gmail.com>
7
+ License-Expression: MIT
8
+ Keywords: challenges,cli,coding,learning,practice
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Education
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: inquirerpy>=0.3
21
+ Requires-Dist: pyyaml>=6.0
22
+ Requires-Dist: rich>=13.0
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "skilark"
3
+ version = "0.1.0"
4
+ description = "Daily coding challenges from market intelligence"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Skilark", email = "skilark.feedback@gmail.com" },
9
+ ]
10
+ keywords = ["coding", "challenges", "practice", "cli", "learning"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Education",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27",
24
+ "rich>=13.0",
25
+ "InquirerPy>=0.3",
26
+ "pyyaml>=6.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://skilark.com"
31
+
32
+ [project.scripts]
33
+ skilark = "skilark_cli.main:main"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/skilark_cli"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=8.0",
48
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,30 @@
1
+ import re
2
+
3
+
4
+ def normalize(s: str) -> str:
5
+ """Normalize answer string for comparison.
6
+
7
+ Strips whitespace, lowercases, removes bracket/paren wrappers,
8
+ normalizes commas to spaces, and collapses internal whitespace.
9
+ This lets users write answers in natural forms like "(1, 2)" or
10
+ "[1, 2, 3]" and still match the canonical expected value "1 2 3".
11
+ """
12
+ s = s.strip().lower()
13
+ # Remove common wrappers: parentheses, brackets, braces
14
+ s = re.sub(r'[\[\]\(\)\{\}]', '', s)
15
+ # Normalize commas to spaces
16
+ s = s.replace(',', ' ')
17
+ # Collapse multiple spaces
18
+ s = re.sub(r'\s+', ' ', s)
19
+ return s.strip()
20
+
21
+
22
+ def check_answer(user_answer: str, expected: str) -> bool:
23
+ """Check if user's answer matches expected, with normalization.
24
+
25
+ Returns False for blank input so callers can distinguish
26
+ "no answer given" from a wrong answer without special-casing.
27
+ """
28
+ if not user_answer.strip():
29
+ return False
30
+ return normalize(user_answer) == normalize(expected)
@@ -0,0 +1,107 @@
1
+ """
2
+ SkilarkClient: thin httpx wrapper for the Skilark API.
3
+
4
+ Every method maps 1-to-1 to a REST endpoint. The client is synchronous
5
+ because the CLI is a short-lived interactive process — async overhead is
6
+ not warranted here.
7
+
8
+ The `transport` constructor argument exists solely for testing: pass an
9
+ httpx.MockTransport to intercept calls without hitting the network.
10
+ """
11
+
12
+ import httpx
13
+
14
+
15
+ class SkilarkClient:
16
+ """HTTP client for the Skilark API.
17
+
18
+ Args:
19
+ base_url: Root URL of the API (e.g. "https://api.skilark.com").
20
+ user_id: UUID for the authenticated user. When set, every request
21
+ carries an ``X-Skilark-User`` header so the server can
22
+ associate actions with the right account.
23
+ transport: Optional httpx transport override, used in tests.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ base_url: str,
29
+ user_id: str | None = None,
30
+ transport: httpx.BaseTransport | None = None,
31
+ ):
32
+ kwargs: dict = {"base_url": base_url, "timeout": 15.0}
33
+ if transport is not None:
34
+ kwargs["transport"] = transport
35
+ self._http = httpx.Client(**kwargs)
36
+ self._user_id = user_id
37
+
38
+ def _headers(self) -> dict:
39
+ """Return request headers that identify the current user, if known."""
40
+ h: dict = {}
41
+ if self._user_id:
42
+ h["X-Skilark-User"] = self._user_id
43
+ return h
44
+
45
+ def create_user(self, topics: list[str]) -> dict:
46
+ """Register a new CLI user and return the created user object.
47
+
48
+ Called once during ``skilark setup``. The returned dict contains
49
+ at minimum ``id`` (UUID) and ``topics``.
50
+ """
51
+ resp = self._http.post("/cli/users", json={"topics": topics})
52
+ resp.raise_for_status()
53
+ return resp.json()
54
+
55
+ def get_next_challenge(self, topics: list[str]) -> dict | None:
56
+ """Fetch the next unseen challenge for the given topics.
57
+
58
+ Returns None when the server responds with 404, meaning there are no
59
+ more challenges available right now (the user has exhausted today's
60
+ pool or there are simply no challenges for the requested topics).
61
+ """
62
+ resp = self._http.get(
63
+ "/cli/challenges/next",
64
+ params={"topics": ",".join(topics)},
65
+ headers=self._headers(),
66
+ )
67
+ if resp.status_code == 404:
68
+ return None
69
+ resp.raise_for_status()
70
+ return resp.json()
71
+
72
+ def complete_challenge(
73
+ self,
74
+ challenge_id: str,
75
+ answer: str,
76
+ correct: bool,
77
+ self_corrected: bool,
78
+ ) -> None:
79
+ """Record the user's attempt for a challenge.
80
+
81
+ Args:
82
+ challenge_id: The challenge UUID returned by get_next_challenge.
83
+ answer: The raw text the user typed.
84
+ correct: Whether the system judged the answer correct.
85
+ self_corrected: Whether the user self-assessed after seeing the
86
+ expected answer (used for partial-credit scoring).
87
+ """
88
+ resp = self._http.post(
89
+ f"/cli/challenges/{challenge_id}/complete",
90
+ json={
91
+ "answer": answer,
92
+ "correct": correct,
93
+ "self_corrected": self_corrected,
94
+ },
95
+ headers=self._headers(),
96
+ )
97
+ resp.raise_for_status()
98
+
99
+ def get_stats(self) -> dict:
100
+ """Return practice statistics for the current user.
101
+
102
+ The returned dict contains at minimum ``streak`` and
103
+ ``total_completed``.
104
+ """
105
+ resp = self._http.get("/cli/users/me/stats", headers=self._headers())
106
+ resp.raise_for_status()
107
+ return resp.json()
File without changes
@@ -0,0 +1,54 @@
1
+ """
2
+ `skilark config` command.
3
+
4
+ Allows the user to interactively update their topic preferences.
5
+ Presents a pre-populated checkbox prompt showing current selections,
6
+ then persists any changes to the config file on disk.
7
+ Exits early with a friendly message if the user has not yet run setup.
8
+ """
9
+
10
+ from InquirerPy import inquirer
11
+ from rich.console import Console
12
+
13
+ from skilark_cli.config_store import ConfigStore
14
+ from skilark_cli.onboarding import TOPIC_CHOICES
15
+
16
+ console = Console()
17
+
18
+
19
+ def run() -> None:
20
+ """Interactively update the user's topic preferences."""
21
+ config_store = ConfigStore()
22
+
23
+ if config_store.is_first_run():
24
+ console.print("[dim]Run 'skilark today' first to get started.[/dim]")
25
+ return
26
+
27
+ config = config_store.load()
28
+ current_topics = set(config.get("topics", []))
29
+
30
+ console.print(f"\n[bold]Current topics:[/bold] {', '.join(sorted(current_topics))}\n")
31
+
32
+ choices = [
33
+ {"name": t["name"], "value": t["value"], "enabled": t["value"] in current_topics}
34
+ for t in TOPIC_CHOICES
35
+ ]
36
+
37
+ selected = inquirer.checkbox(
38
+ message="Update topics (space to toggle, enter to confirm):",
39
+ choices=choices,
40
+ validate=lambda result: len(result) >= 1,
41
+ invalid_message="Pick at least one topic.",
42
+ ).execute()
43
+
44
+ config_store.update_topics(selected)
45
+
46
+ added = set(selected) - current_topics
47
+ removed = current_topics - set(selected)
48
+ if added:
49
+ console.print(f"[green]+ Added:[/green] {', '.join(sorted(added))}")
50
+ if removed:
51
+ console.print(f"[red]- Removed:[/red] {', '.join(sorted(removed))}")
52
+ if not added and not removed:
53
+ console.print("[dim]No changes.[/dim]")
54
+ console.print()
@@ -0,0 +1,29 @@
1
+ """
2
+ `skilark status` command.
3
+
4
+ Fetches and displays the user's learning statistics (streak, weekly
5
+ progress, per-topic breakdown, last session) from the Skilark API.
6
+ Exits early with a friendly message if the user has not yet run setup.
7
+ """
8
+
9
+ from rich.console import Console
10
+
11
+ from skilark_cli.client import SkilarkClient
12
+ from skilark_cli.config_store import ConfigStore
13
+ from skilark_cli.display import render_stats
14
+
15
+ console = Console()
16
+
17
+
18
+ def run() -> None:
19
+ """Display the current user's learning statistics."""
20
+ config_store = ConfigStore()
21
+
22
+ if config_store.is_first_run():
23
+ console.print("[dim]Run 'skilark today' first to get started.[/dim]")
24
+ return
25
+
26
+ config = config_store.load()
27
+ client = SkilarkClient(base_url=config["api_url"], user_id=config["user_id"])
28
+ stats = client.get_stats()
29
+ render_stats(console, stats)
@@ -0,0 +1,138 @@
1
+ """
2
+ today command — the core challenge loop.
3
+
4
+ Entry point: `skilark` or `skilark today`
5
+
6
+ Fetches the next unseen challenge, prompts for an answer, checks it,
7
+ records the result, and offers another round. The interactive loop is
8
+ extracted into `run_challenge_loop()` so tests can call it directly
9
+ without touching ConfigStore or performing network I/O.
10
+ """
11
+
12
+ import json
13
+
14
+ from rich.console import Console
15
+ from rich.prompt import Confirm, Prompt
16
+
17
+ from skilark_cli.answer_checker import check_answer
18
+ from skilark_cli.client import SkilarkClient
19
+ from skilark_cli.config_store import ConfigStore
20
+ from skilark_cli.display import render_challenge, render_hint, render_result
21
+ from skilark_cli.onboarding import run_onboarding
22
+
23
+ console = Console()
24
+
25
+
26
+ def run() -> None:
27
+ """Entry point called from main.py for `skilark today`."""
28
+ config_store = ConfigStore()
29
+
30
+ if config_store.is_first_run():
31
+ run_onboarding(config_store, api_url="https://api.skilark.com")
32
+
33
+ config = config_store.load()
34
+ client = SkilarkClient(base_url=config["api_url"], user_id=config["user_id"])
35
+
36
+ # Use total_completed to drive the displayed day counter. If the stats
37
+ # endpoint is unavailable (e.g. no network), fall back to day 1 silently
38
+ # so the user can still practice.
39
+ try:
40
+ stats = client.get_stats()
41
+ day = stats.get("total_completed", 0) + 1
42
+ except Exception:
43
+ day = 1
44
+
45
+ run_challenge_loop(client, topics=config["topics"], day=day)
46
+
47
+
48
+ def run_challenge_loop(client: SkilarkClient, topics: list[str], day: int) -> None:
49
+ """Interactive challenge loop.
50
+
51
+ Fetches one challenge at a time, handles the answer/hint/skip/quit
52
+ interactions, records completions, and asks whether to continue.
53
+
54
+ Separated from `run()` so tests can inject a mock client and drive the
55
+ loop via patched Rich prompts without touching the filesystem or network.
56
+
57
+ Args:
58
+ client: SkilarkClient instance (or mock in tests).
59
+ topics: Topic filter passed to get_next_challenge.
60
+ day: Starting day number displayed in the challenge header.
61
+ """
62
+ while True:
63
+ challenge = client.get_next_challenge(topics=topics)
64
+ if not challenge:
65
+ console.print(
66
+ "\n[dim]No more challenges available for your topics. "
67
+ "Try adding more![/dim]\n"
68
+ )
69
+ return
70
+
71
+ render_challenge(console, challenge, day=day)
72
+
73
+ # Hints may arrive as a JSON string or as an already-parsed list.
74
+ # Normalise to a list here so the rest of the loop is clean.
75
+ hints = challenge.get("hints") or []
76
+ if isinstance(hints, str):
77
+ try:
78
+ hints = json.loads(hints)
79
+ except (json.JSONDecodeError, TypeError):
80
+ hints = []
81
+
82
+ hint_idx = 0
83
+
84
+ while True:
85
+ answer = Prompt.ask(" Your answer")
86
+
87
+ if answer.lower() == "h":
88
+ if hint_idx < len(hints):
89
+ render_hint(console, hints[hint_idx])
90
+ hint_idx += 1
91
+ else:
92
+ console.print("[dim] No more hints.[/dim]\n")
93
+ continue
94
+
95
+ if answer.lower() == "s":
96
+ console.print("[dim] Skipped.[/dim]\n")
97
+ break
98
+
99
+ if answer.lower() == "q":
100
+ return
101
+
102
+ correct = check_answer(answer, challenge["expected_answer"])
103
+ self_corrected = False
104
+
105
+ if not correct:
106
+ render_result(
107
+ console,
108
+ correct=False,
109
+ expected_answer=challenge["expected_answer"],
110
+ explanation=challenge["explanation"],
111
+ deep_link=challenge["deep_link"],
112
+ )
113
+ self_corrected = Confirm.ask(
114
+ " Were you actually right?", default=False
115
+ )
116
+ if self_corrected:
117
+ correct = True
118
+ else:
119
+ render_result(
120
+ console,
121
+ correct=True,
122
+ explanation=challenge["explanation"],
123
+ deep_link=challenge["deep_link"],
124
+ )
125
+
126
+ client.complete_challenge(
127
+ challenge["id"],
128
+ answer=answer,
129
+ correct=correct,
130
+ self_corrected=self_corrected,
131
+ )
132
+ break
133
+
134
+ day += 1
135
+
136
+ if not Confirm.ask("\n Another?", default=True):
137
+ console.print("[dim] See you tomorrow.[/dim]\n")
138
+ return
@@ -0,0 +1,62 @@
1
+ """
2
+ ConfigStore: reads and writes ~/.skilark/config.yaml.
3
+
4
+ The config file is created on first setup and contains:
5
+ user_id - UUID assigned by the API on registration
6
+ topics - list of skill topics the user cares about
7
+ api_url - base URL for the Skilark API (default: production)
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ DEFAULT_API_URL = "https://api.skilark.com"
15
+
16
+
17
+ class ConfigStore:
18
+ """Manages the on-disk configuration for the Skilark CLI.
19
+
20
+ Uses a configurable config_dir so that tests can point at a tmp_path
21
+ instead of touching the real ~/.skilark directory.
22
+ """
23
+
24
+ def __init__(self, config_dir: Path | None = None):
25
+ self.config_dir = config_dir or Path.home() / ".skilark"
26
+ self.config_file = self.config_dir / "config.yaml"
27
+
28
+ def is_first_run(self) -> bool:
29
+ """Return True if no config file exists yet (i.e. not yet set up)."""
30
+ return not self.config_file.exists()
31
+
32
+ def save(
33
+ self,
34
+ user_id: str,
35
+ topics: list[str],
36
+ api_url: str = DEFAULT_API_URL,
37
+ ) -> None:
38
+ """Write a fresh config file, creating the directory if needed.
39
+
40
+ Overwrites any existing config entirely — intended for initial setup
41
+ or a full reset.
42
+ """
43
+ self.config_dir.mkdir(parents=True, exist_ok=True)
44
+ data = {"user_id": user_id, "topics": topics, "api_url": api_url}
45
+ self.config_file.write_text(yaml.dump(data, default_flow_style=False))
46
+
47
+ def load(self) -> dict:
48
+ """Load and return the config as a plain dict.
49
+
50
+ Raises FileNotFoundError if is_first_run() is True (file not yet
51
+ created). Callers should check is_first_run() before calling load().
52
+ """
53
+ return yaml.safe_load(self.config_file.read_text())
54
+
55
+ def update_topics(self, topics: list[str]) -> None:
56
+ """Replace the stored topic list without touching other fields.
57
+
58
+ Raises FileNotFoundError if config has not been created yet.
59
+ """
60
+ config = self.load()
61
+ config["topics"] = topics
62
+ self.config_file.write_text(yaml.dump(config, default_flow_style=False))