smallsteps 0.1.1__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.
smallsteps/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from smallsteps.cli import app
2
+ from smallsteps.command_runner import CommandRunner
3
+ from smallsteps.prober import Prober
4
+ from smallsteps.ratchet import Ratchet, evaluate
5
+
6
+ __all__ = [
7
+ "app",
8
+ "Ratchet",
9
+ "evaluate",
10
+ "CommandRunner",
11
+ "Prober",
12
+ ]
@@ -0,0 +1,69 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ from smallsteps.ratchet import Ratchet
6
+
7
+
8
+ def ratchet_to_slug(ratchet: Ratchet) -> str:
9
+ """Generates a standardized environment variable key for a given ratchet name."""
10
+ return f"SMALLSTEPS_{re.sub(r'[^a-zA-Z0-9]', '_', ratchet.name).upper()}"
11
+
12
+
13
+ class GitHubCIAdapter:
14
+ """Generates a custom local GitHub Composite Action matched to the project metrics."""
15
+
16
+ def generate_action(self, ratchets: List[Ratchet]) -> str:
17
+ """Generates an action.yml manifest string with dynamic inputs, setup stubs, and mapping."""
18
+ yaml_lines = [
19
+ "name: 'Smallsteps Ratchet Evaluation'",
20
+ "description: 'Evaluates your quality goals'",
21
+ ]
22
+
23
+ # 1. Append inputs only if there are active ratchets to list
24
+ if ratchets:
25
+ yaml_lines.append("inputs:")
26
+ for r in ratchets:
27
+ slug = ratchet_to_slug(r)
28
+ yaml_lines.extend(
29
+ [
30
+ f" {slug}:",
31
+ f" description: \"Current live performance value for '{r.name}'\"",
32
+ " required: true",
33
+ ]
34
+ )
35
+
36
+ # 2. Append core composite block infrastructure and the installation placeholder
37
+ yaml_lines.extend(
38
+ [
39
+ "",
40
+ "runs:",
41
+ " using: 'composite'",
42
+ " steps:",
43
+ " - name: Install uv",
44
+ " uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0",
45
+ " with:",
46
+ " enable-cache: true",
47
+ "",
48
+ " - name: Run Smallsteps Guardrail Validation",
49
+ " shell: bash",
50
+ " run: uvx smallsteps-ratchet check",
51
+ ]
52
+ )
53
+
54
+ # 3. Append step context environment maps sequentially via loop execution
55
+ if ratchets:
56
+ yaml_lines.append(" env:")
57
+ for r in ratchets:
58
+ slug = ratchet_to_slug(r)
59
+ yaml_lines.append(f" {slug}: ${{{{ inputs.{slug} }}}}")
60
+
61
+ return "\n".join(yaml_lines) + "\n"
62
+
63
+ def save(self, path: Path, content: str) -> None:
64
+ """Writes the custom composite action metadata safely out to file."""
65
+ try:
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text(content, encoding="utf-8")
68
+ except Exception as e:
69
+ raise IOError(f"Failed to write local custom action configuration: {e}")
smallsteps/cli.py ADDED
@@ -0,0 +1,275 @@
1
+ from datetime import date, datetime, timedelta
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from smallsteps.ci_adapter import GitHubCIAdapter
7
+ from smallsteps.cli_helpers import find_git_root
8
+ from smallsteps.command_runner import (
9
+ CommandRunnerWithEnvLookUp,
10
+ OSCommandRunner,
11
+ OSEnvAdapter,
12
+ )
13
+ from smallsteps.config_adapter import TOMLConfigAdapter
14
+ from smallsteps.parsing import parse_numeric_input
15
+ from smallsteps.prober import Prober, StaticDateProvider, local_system_date_provider
16
+ from smallsteps.ratchet import (
17
+ Ratchet,
18
+ RatchetEvaluationFailure,
19
+ RatchetEvaluationSuccess,
20
+ )
21
+
22
+ app = typer.Typer(
23
+ help="Smallsteps: Monotonic progress tracking and enforcement.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ DEFAULT_CONFIG_FILE = Path("smallsteps.toml")
28
+
29
+
30
+ def resolve_config_path(explicit_path: Path | None) -> Path:
31
+ if explicit_path is not None:
32
+ return explicit_path
33
+ return find_git_root() / DEFAULT_CONFIG_FILE
34
+
35
+
36
+ @app.command(name="add")
37
+ def add(
38
+ name: str = typer.Option(
39
+ None, "--name", "-n", help="Unique name for your ratchet."
40
+ ),
41
+ goal_val: str = typer.Option(
42
+ None, "--goal", "-g", help="Goal value (e.g. .9 or 250)."
43
+ ),
44
+ command_str: str = typer.Option(
45
+ None, "--command", "-cmd", help="Shell command to compute the value."
46
+ ),
47
+ end_date: datetime | None = typer.Option(
48
+ None, "--end", "-e", help="Ratchet goal end date (YYYY-MM-DD)."
49
+ ),
50
+ config: Path | None = typer.Option(
51
+ None, "--config", "-c", help="Custom path to config file."
52
+ ),
53
+ ):
54
+ """Adds a ratchet to your configuration (Auto-creates smallsteps.toml if missing)."""
55
+ config_path = resolve_config_path(config)
56
+ config_adapter = TOMLConfigAdapter()
57
+
58
+ # 1. Collect or prompt for the name first
59
+ final_name = name or typer.prompt("Ratchet name (e.g., Test Coverage)")
60
+
61
+ existing_ratchets = config_adapter.load(config_path)
62
+ if any(r.name == final_name for r in existing_ratchets):
63
+ typer.secho(
64
+ f"❌ A ratchet named '{final_name}' already exists.",
65
+ fg=typer.colors.RED,
66
+ err=True,
67
+ )
68
+ raise typer.Exit(code=1)
69
+
70
+ # 2. Ask for command and use it for baseline
71
+ final_cmd = command_str or typer.prompt(
72
+ "Shell command to compute the ratchet value"
73
+ )
74
+ typer.echo("Running command to establish live baseline value...")
75
+ runner = OSCommandRunner()
76
+ try:
77
+ # Create a temporary Ratchet instance to satisfy our runner protocol
78
+ temp_ratchet = Ratchet(
79
+ name=final_name,
80
+ start=date.today(),
81
+ end=date.today(),
82
+ initial_value=0,
83
+ goal_value=100,
84
+ command=final_cmd,
85
+ )
86
+ discovered_baseline = runner(temp_ratchet)
87
+
88
+ except Exception as e:
89
+ typer.secho(
90
+ f"❌ Failed to calculate baseline using that command.\nError: {e}",
91
+ fg=typer.colors.RED,
92
+ err=True,
93
+ )
94
+ raise typer.Exit(code=1)
95
+
96
+ typer.echo(f"Live baseline discovered: {discovered_baseline:.2f}")
97
+ # 3. Ask for the Goal
98
+ raw_goal = goal_val or typer.prompt(
99
+ f"What is your goal value? (Current: {discovered_baseline})"
100
+ )
101
+
102
+ try:
103
+ final_goal = parse_numeric_input(raw_goal)
104
+ except ValueError as e:
105
+ typer.secho(f"❌ Invalid goal value: {e}", fg=typer.colors.RED, err=True)
106
+ raise typer.Exit(code=1)
107
+
108
+ # 4. Handle Target Goal Date
109
+ if end_date:
110
+ final_end_date = end_date.date()
111
+ else:
112
+ goal_date_str = typer.prompt(
113
+ f"What is the end date by which the goal must be met? (YYYY-MM-DD, e.g. in 30 days its {(date.today() + timedelta(days=30)).isoformat()} ) ",
114
+ )
115
+ try:
116
+ final_end_date = date.fromisoformat(goal_date_str)
117
+ except ValueError:
118
+ typer.secho(
119
+ f"❌ Invalid date format '{goal_date_str}'. Please use YYYY-MM-DD.",
120
+ fg=typer.colors.RED,
121
+ err=True,
122
+ )
123
+ raise typer.Exit(code=1)
124
+
125
+ # 5. Construct Ratchet
126
+ try:
127
+ validated_ratchet = Ratchet(
128
+ name=final_name,
129
+ start=date.today(),
130
+ end=final_end_date,
131
+ initial_value=discovered_baseline,
132
+ goal_value=final_goal,
133
+ command=final_cmd,
134
+ )
135
+ except Exception as e:
136
+ typer.secho(
137
+ f"❌ Ratchet validation failed:\n{e}", fg=typer.colors.RED, err=True
138
+ )
139
+ raise typer.Exit(code=1)
140
+
141
+ # 6. Write
142
+ config_adapter.append(config_path, validated_ratchet)
143
+ typer.secho(
144
+ f"✨ Successfully added '{validated_ratchet.name}' to {config_path.name}!",
145
+ fg=typer.colors.GREEN,
146
+ )
147
+
148
+
149
+ @app.command(name="check")
150
+ def check(
151
+ date: datetime | None = typer.Option(
152
+ None,
153
+ "--date",
154
+ "-d",
155
+ help="Timetravel to a specific date to check your ratchets at.",
156
+ ),
157
+ config: Path | None = typer.Option(None, "--config", "-c"),
158
+ ):
159
+ """Evaluates all ratchets and enforces progress."""
160
+ config_path = resolve_config_path(config)
161
+ config_adapter = TOMLConfigAdapter()
162
+
163
+ try:
164
+ ratchets = config_adapter.load(config_path)
165
+ except RuntimeError as e:
166
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
167
+ raise typer.Exit(code=1)
168
+
169
+ if not ratchets:
170
+ typer.secho(
171
+ f"No active ratchets found in {config_path.name}.",
172
+ fg=typer.colors.YELLOW,
173
+ )
174
+ return
175
+
176
+ # Wire up runner & prober pipeline
177
+ runner_with_lookup = CommandRunnerWithEnvLookUp(
178
+ env_adapter=OSEnvAdapter(), command_runner=OSCommandRunner()
179
+ )
180
+ date_provider = (
181
+ StaticDateProvider(date.date()) if date else local_system_date_provider
182
+ )
183
+ prober = Prober(command_runner=runner_with_lookup, date_provider=date_provider)
184
+
185
+ any_failed = False
186
+ typer.echo(f"Evaluating {len(ratchets)} ratchet(s)...\n")
187
+
188
+ for ratchet in ratchets:
189
+ eval_result = prober.probe(ratchet)
190
+
191
+ match eval_result:
192
+ # TRACK 1: Metric is completely healthy and on track
193
+ case RatchetEvaluationSuccess(is_healthy=True) as success:
194
+ typer.secho(
195
+ f"{ratchet.name}: ON TRACK "
196
+ f"(Current: {success.current_value:.2f}, Expected Min: {success.expected_value:.2f})",
197
+ fg=typer.colors.GREEN,
198
+ )
199
+
200
+ # TRACK 2: Command executed fine, but progress dropped below expected target bounds
201
+ case RatchetEvaluationSuccess(is_healthy=False) as below:
202
+ any_failed = True
203
+ typer.secho(
204
+ f"{ratchet.name}: BEHIND "
205
+ f"(Current: {below.current_value:.2f}, Expected Min: {below.expected_value:.2f})",
206
+ fg=typer.colors.RED,
207
+ )
208
+
209
+ # TRACK 3: Underlying shell execution exploded completely (command error, timeout, missing binary)
210
+ case RatchetEvaluationFailure() as failure:
211
+ any_failed = True
212
+
213
+ typer.secho(
214
+ f"{ratchet.name} EVALUATION FAILED! \n"
215
+ f"Details: {failure.error_message}",
216
+ fg=typer.colors.RED,
217
+ err=True,
218
+ )
219
+
220
+ if any_failed:
221
+ typer.echo("\nProgress check failed.")
222
+ raise typer.Exit(code=1)
223
+
224
+ typer.echo("All monitored project metrics are completely healthy.")
225
+
226
+
227
+ @app.command(name="ci")
228
+ def ci(
229
+ overwrite: bool = typer.Option(
230
+ False,
231
+ "--overwrite",
232
+ "-o",
233
+ help="Force overwrite the action.yml file if it already exists.",
234
+ ),
235
+ config: Path | None = typer.Option(
236
+ None, "--config", "-c", help="Custom path to the smallsteps configuration file."
237
+ ),
238
+ ):
239
+ """Generates a scaffoling for a github action"""
240
+ config_path = resolve_config_path(config)
241
+ config_adapter = TOMLConfigAdapter()
242
+ ci_adapter = GitHubCIAdapter()
243
+
244
+ # 1. Check if the target file destination already exists
245
+ action_path = Path(".github/actions/smallsteps/action.yml")
246
+ if action_path.exists() and not overwrite:
247
+ typer.secho(
248
+ f"Action manifest already exists at:\n {action_path.resolve()}\n\n"
249
+ f"To overwrite this file, re-run this command with the overwrite flag:\n"
250
+ f"uv run smallsteps ci --overwrite",
251
+ fg=typer.colors.RED,
252
+ err=True,
253
+ )
254
+ raise typer.Exit(code=1)
255
+
256
+ # 2. Extract configuration records
257
+ try:
258
+ ratchets = config_adapter.load(config_path)
259
+ except RuntimeError as e:
260
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
261
+ raise typer.Exit(code=1)
262
+
263
+ # 3. Process layout formatting maps
264
+ yaml_content = ci_adapter.generate_action(ratchets)
265
+
266
+ # 4. Save out structural changes
267
+ try:
268
+ ci_adapter.save(action_path, yaml_content)
269
+ typer.secho(
270
+ f"🚀 Custom GitHub Composite Action successfully written to:\n {action_path}",
271
+ fg=typer.colors.GREEN,
272
+ )
273
+ except Exception as e:
274
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
275
+ raise typer.Exit(code=1)
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def find_git_root() -> Path:
5
+ """
6
+ Traverses upwards from the current working directory to find the Git root.
7
+ Falls back to the current working directory if no Git repository is detected.
8
+ """
9
+ # Start at the absolute current working directory
10
+ current_dir = Path.cwd().resolve()
11
+
12
+ # Check the current directory, then walk up through all parent directories
13
+ for path in [current_dir] + list(current_dir.parents):
14
+ if (path / ".git").exists():
15
+ return path
16
+
17
+ # Fallback if we hit the filesystem root without finding a .git marker
18
+ return Path.cwd()
@@ -0,0 +1,86 @@
1
+ import os
2
+ import re
3
+ import subprocess
4
+ from typing import Optional, Protocol
5
+
6
+ from smallsteps.parsing import parse_numeric_input
7
+ from smallsteps.ratchet import Ratchet
8
+
9
+
10
+ def ratchet_to_slug(ratchet: Ratchet) -> str:
11
+ clean_name = re.sub(r"[^a-zA-Z0-9]", "_", ratchet.name).upper()
12
+ return f"SMALLSTEPS_{clean_name}"
13
+
14
+
15
+ class EnvAdapter(Protocol):
16
+ def __call__(self, key: str) -> int | float | None: ...
17
+
18
+
19
+ class CommandRunner(Protocol):
20
+ def __call__(self, ratchet: Ratchet) -> int | float: ...
21
+
22
+
23
+ class OSCommandRunner(CommandRunner):
24
+ def __init__(self, timeout_seconds: int = 30) -> None:
25
+ self.timeout_seconds = timeout_seconds
26
+
27
+ def __call__(self, ratchet: Ratchet) -> int | float:
28
+ # Guard against empty commands
29
+ if not ratchet.command or not ratchet.command.strip():
30
+ raise ValueError(
31
+ f"Ratchet '{ratchet.name}' has an empty or missing command string."
32
+ )
33
+
34
+ try:
35
+ # Execute the shell command
36
+ result = subprocess.run(
37
+ ratchet.command,
38
+ shell=True,
39
+ capture_output=True, # Captures stdout and stderr
40
+ text=True, # Returns strings instead of bytes
41
+ timeout=self.timeout_seconds, # Prevents hanging infinitely in CI
42
+ check=True, # Raises CalledProcessError if exit code != 0
43
+ )
44
+
45
+ # Extract, clean, and parse the output
46
+ raw_output = result.stdout.strip()
47
+ return parse_numeric_input(raw_output)
48
+
49
+ except subprocess.TimeoutExpired as e:
50
+ # Enrich the timeout error with explicit context
51
+ raise RuntimeError(
52
+ f"Command timed out after {self.timeout_seconds}s: '{ratchet.command}'"
53
+ ) from e
54
+
55
+ except subprocess.CalledProcessError as e:
56
+ error_details = e.stderr.strip() or e.stdout.strip() or "No output logged."
57
+ raise RuntimeError(
58
+ f"Command failed with exit code {e.returncode}.\n"
59
+ f"Command: '{ratchet.command}'\n"
60
+ f"Error Details: {error_details}"
61
+ ) from e
62
+
63
+
64
+ class OSEnvAdapter(EnvAdapter):
65
+ def __call__(self, key: str) -> Optional[int | float]:
66
+
67
+ val = os.environ.get(key)
68
+ if val is None:
69
+ return None
70
+
71
+ return parse_numeric_input(val)
72
+
73
+
74
+ class CommandRunnerWithEnvLookUp(CommandRunner):
75
+ def __init__(self, env_adapter: EnvAdapter, command_runner: CommandRunner) -> None:
76
+ self.env_adapter = env_adapter
77
+ self.command_runner = command_runner
78
+
79
+ def __call__(self, ratchet: Ratchet) -> int | float:
80
+ slug = ratchet_to_slug(ratchet)
81
+ env_res = self.env_adapter(slug)
82
+
83
+ if env_res is not None:
84
+ return env_res
85
+
86
+ return self.command_runner(ratchet)
@@ -0,0 +1,54 @@
1
+ import tomllib
2
+ from datetime import date
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from pydantic import TypeAdapter
7
+
8
+ from smallsteps.ratchet import Ratchet
9
+
10
+
11
+ class TOMLConfigAdapter:
12
+ """Manages reading, validating, and mutating the smallsteps TOML file structure."""
13
+
14
+ def load(self, path: Path) -> List[Ratchet]:
15
+ """Reads and parses the entire configuration file into a list of valid Ratchets."""
16
+ if not path.exists():
17
+ return []
18
+
19
+ try:
20
+ with open(path, "rb") as f:
21
+ toml_data = tomllib.load(f)
22
+
23
+ return TypeAdapter(List[Ratchet]).validate_python(
24
+ toml_data.get("ratchets", [])
25
+ )
26
+ except Exception as e:
27
+ raise RuntimeError(
28
+ f"Malformed or invalid configuration at {path.name}:\n{e}"
29
+ )
30
+
31
+ def append(self, path: Path, ratchet: Ratchet) -> None:
32
+ """Serializes a single valid Ratchet and appends it structurally to the file."""
33
+ # Auto-initialize the file if it doesn't exist yet
34
+ #
35
+ if not path.exists():
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ path.write_text("# Smallsteps Configuration\n", encoding="utf-8")
38
+
39
+ toml_lines = ["", "[[ratchets]]"]
40
+ for key, value in ratchet.model_dump().items():
41
+ if isinstance(value, date):
42
+ toml_lines.append(f"{key} = {value.isoformat()}")
43
+ elif isinstance(value, str):
44
+ toml_lines.append(f'{key} = "{value}"')
45
+ else:
46
+ toml_lines.append(f"{key} = {value}")
47
+
48
+ toml_block = "\n".join(toml_lines) + "\n"
49
+
50
+ try:
51
+ with open(path, "a", encoding="utf-8") as f:
52
+ f.write(toml_block)
53
+ except Exception as e:
54
+ raise IOError(f"Failed to write ratchet data to filesystem: {e}")
smallsteps/parsing.py ADDED
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+
4
+ def parse_numeric_input(value: Any) -> int | float:
5
+ if isinstance(value, (int, float)):
6
+ return value
7
+
8
+ if not isinstance(value, str):
9
+ raise TypeError(f"Expected string or number, got {type(value).__name__}")
10
+
11
+ cleaned = value.strip()
12
+
13
+ # Elegant trick: Change "99.5%" to "99.5e-2" (scientific notation for /100)
14
+ if cleaned.endswith("%"):
15
+ return float(cleaned.rstrip("%").strip() + "e-2")
16
+
17
+ # Standard fallback path (int takes priority to preserve type fidelity)
18
+ try:
19
+ return int(cleaned)
20
+ except ValueError:
21
+ try:
22
+ return float(cleaned)
23
+ except ValueError:
24
+ raise ValueError(f"Could not parse string '{value}' into a number.")
smallsteps/prober.py ADDED
@@ -0,0 +1,45 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+ from typing import Protocol
4
+
5
+ from smallsteps.command_runner import CommandRunner
6
+ from smallsteps.ratchet import (
7
+ Ratchet,
8
+ RatchetEvaluation,
9
+ RatchetEvaluationFailure,
10
+ evaluate,
11
+ )
12
+
13
+
14
+ class DateProvider(Protocol):
15
+ def __call__(self) -> date: ...
16
+
17
+
18
+ def local_system_date_provider() -> date:
19
+ return date.today()
20
+
21
+
22
+ class StaticDateProvider(DateProvider):
23
+ def __init__(self, date: date) -> None:
24
+ self.date = date
25
+
26
+ def __call__(self) -> date:
27
+ return self.date
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Prober:
32
+ command_runner: CommandRunner
33
+ date_provider: DateProvider
34
+
35
+ def probe(self, ratchet: Ratchet) -> RatchetEvaluation:
36
+
37
+ try:
38
+ current_value = self.command_runner(ratchet)
39
+ current_date = self.date_provider()
40
+
41
+ except Exception as e:
42
+ return RatchetEvaluationFailure(error_message=str(e))
43
+ return evaluate(
44
+ ratchet=ratchet, current_value=current_value, current_date=current_date
45
+ )
smallsteps/ratchet.py ADDED
@@ -0,0 +1,72 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+
4
+ from pydantic import BaseModel, ConfigDict, model_validator
5
+ from typing_extensions import Self
6
+
7
+
8
+ class Ratchet(BaseModel):
9
+ model_config = ConfigDict(frozen=True)
10
+
11
+ name: str
12
+ start: date
13
+ end: date
14
+ initial_value: int | float
15
+ goal_value: int | float
16
+ command: str
17
+
18
+ @model_validator(mode="after")
19
+ def validate_end(self) -> Self:
20
+ if self.end < self.start:
21
+ raise ValueError("End date must be >= start date of ratchet")
22
+ return self
23
+
24
+ @model_validator(mode="after")
25
+ def validate_value(self) -> Self:
26
+ if self.initial_value == self.goal_value:
27
+ raise ValueError("Initial value must be different from goal")
28
+ return self
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RatchetEvaluationSuccess:
33
+ current_date: date
34
+ current_value: int | float
35
+ expected_value: float
36
+ is_healthy: bool
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RatchetEvaluationFailure:
41
+ error_message: str
42
+ is_healthy: bool = False
43
+
44
+
45
+ RatchetEvaluation = RatchetEvaluationSuccess | RatchetEvaluationFailure
46
+
47
+
48
+ def evaluate(
49
+ ratchet: Ratchet, current_value: int | float, current_date: date
50
+ ) -> RatchetEvaluation:
51
+ if current_date <= ratchet.start:
52
+ time_ratio = 0.0
53
+ elif current_date >= ratchet.end:
54
+ time_ratio = 1.0
55
+ else:
56
+ time_ratio = (current_date - ratchet.start).days / (
57
+ ratchet.end - ratchet.start
58
+ ).days
59
+
60
+ expected_progress = time_ratio # linear progress assumed
61
+ expected_value = ratchet.initial_value + (
62
+ expected_progress * (ratchet.goal_value - ratchet.initial_value)
63
+ )
64
+ actual_progress = (current_value - ratchet.initial_value) / (
65
+ ratchet.goal_value - ratchet.initial_value
66
+ )
67
+ return RatchetEvaluationSuccess(
68
+ current_date=current_date,
69
+ current_value=current_value,
70
+ expected_value=expected_value,
71
+ is_healthy=actual_progress >= expected_progress,
72
+ )
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: smallsteps
3
+ Version: 0.1.1
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic>=2.13.4
7
+ Requires-Dist: typer>=0.26.7
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Smallsteps - improve your codebase in small steps
11
+
12
+ Smallsteps allows you to declare ratchets – metrics that must increase over time – and to enforce them via testing or CI pipelines.
13
+
14
+ A typical situation: you want to add test coverage as a health check.
15
+ Initially your coverage is at 42% so you create a CI gate that fails if it falls under 40%. Now you have your baseline secured – so far so good. You make a resolution to yourself that you will increase your coverage to bump up the percentage in your CI action frequently.
16
+ **Somehow that never happens and your coverage stays at 42%.**
17
+
18
+ With smallsteps you declare your goal of 80% coverage and that you want to reach it in 100 days. Smallsteps computes the necessary percentage that must be met for each day (e.g. after 50 days you need 60% coverage) and fails if the goal is not met.
19
+
20
+ ## Example
21
+
22
+ We are using this repository itself as an example. We want to enforce our test coverage to go up.
23
+ So we create a ratchet:
24
+
25
+ ```
26
+ uvx smallsteps add \
27
+ --name="Pytest Coverage" \
28
+ --command="uv run pytest --cov --cov-report=json > /dev/null && jq -r '.totals.percent_covered' coverage.json" \
29
+ --goal=80 \
30
+ --end="2026-10-01"
31
+ ```
32
+
33
+ This created a new file – `smallsteps.toml` – which holds the configuration of our ratchet:
34
+
35
+ ```toml
36
+ # Smallsteps Configuration
37
+
38
+ [[ratchets]]
39
+ name = "Pytest Coverage"
40
+ start = 2026-06-25
41
+ end = 2026-10-01
42
+ initial_value = 57.142857142857146
43
+ goal_value = 80
44
+ command = "uv run pytest --cov --cov-report=json > /dev/null && jq -r '.totals.percent_covered' coverage.json"
45
+
46
+ ```
47
+
48
+ **Note**
49
+
50
+ - You don't need to memorize the command parameters, `smallsteps add` walks you through them interactively.
51
+ - Ratchets can be increasing or decreasing. Percentages (56%) are parsed as floats (.56). So make sure that your goal matches the format of the command output.
52
+
53
+ You can inspect, add or modify the ratchets using the the toml file.
54
+
55
+ ### Checking your Ratchets
56
+
57
+ Run `uvx smallsteps check` to check the status of your ratchets. To simulate the future you can run with `--date`, e.g.:
58
+
59
+ ```
60
+ uv smallsteps check --date 2026-10-31
61
+ Evaluating 1 ratchet(s)...
62
+
63
+ Pytest Coverage: BEHIND (Current: 57.14, Expected Min: 80.00)
64
+
65
+ Progress check failed.
66
+
67
+ ```
68
+
69
+ shows that on the goal date the check will fail if we do not achieve 80% test coverage.
70
+
71
+ ### CI and reading values from env
72
+
73
+ When running `smallsteps check` all commands to gather your metrics are run by smallsteps. This may be not the desired behaviour in CI because you want to run the checks in different workflows / actions and do not want to use smallsteps as a central orchestrator.
74
+
75
+ By default smallsteps looks for environment variables matching the ratchets before running the command. E.g. if `SMALLSTEPS_PYTEST_COVERAGE` is present, the coverage is not re-computed. You can use this in CI to pass outputs from test workflow into the smallsteps action. To get a scaffolding github action with the required input vars for your ratchets run `uvx smallsteps ci`.
76
+
77
+ Have a look at [the action](.github/actions/smallsteps/action.yml) and the whole workflow (not provided by a command because too specific for your setup) to see how the plumbing can work.
78
+
79
+ ## Installation
80
+
81
+ This documentaion assumes you are using `uv`, hence you don't need to do anything despite using the uv tool command (`ux`) to install and run smallsteps.
82
+
83
+ # License
84
+
85
+ MIT
@@ -0,0 +1,13 @@
1
+ smallsteps/__init__.py,sha256=7AyPBtevDUNnsu-5qvjDg4u9cu9QXMWRy8x_0JHdcyA,261
2
+ smallsteps/ci_adapter.py,sha256=17qXL0wHMjzLGQjsvxwqIVZEobMln3-C-QW3dY7s-Wo,2609
3
+ smallsteps/cli.py,sha256=lMUAT9xx4P5hVVEPqmzAMlOqcNrL-l9DbWeAZmCIvxc,9196
4
+ smallsteps/cli_helpers.py,sha256=kLcLJitmjhGRqnQDTMU5NJ8560qOFr3PHkndrB1Ay8M,624
5
+ smallsteps/command_runner.py,sha256=WL1WrXTnUFc54AmSQMyrMRSbbpHqO59W9lcJbKkcEd0,2837
6
+ smallsteps/config_adapter.py,sha256=8_t8tUzQgo03XHGpw65-4FZddeaPupVtttO2Q_gyySk,1874
7
+ smallsteps/parsing.py,sha256=XE-E_b5RV2rdcF01km81gn-aoNDABWPu7DvXyOrq_ik,746
8
+ smallsteps/prober.py,sha256=azpOGapz514HHwzymNVYaazEpFMR8rJIINfPfa0SFYU,1064
9
+ smallsteps/ratchet.py,sha256=sFlJf3diMd6xsZbZGOWOjWDDgN-bBPILiP4dmv4tnzw,1993
10
+ smallsteps-0.1.1.dist-info/METADATA,sha256=8mAcHcX3qcgFpZQa3hSqyGzFFLKG3_kPMDmEPEA0Oo8,3589
11
+ smallsteps-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ smallsteps-0.1.1.dist-info/entry_points.txt,sha256=L7SjUfIYSJvCxaoEdege14YzNrMYGwcyk9rfFYIiToM,50
13
+ smallsteps-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ smallsteps = smallsteps.cli:app