smallsteps 0.1.1__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.
- smallsteps-0.1.1/.github/actions/babysteps/action.yml +20 -0
- smallsteps-0.1.1/.github/workflows/ci.yaml +83 -0
- smallsteps-0.1.1/.gitignore +14 -0
- smallsteps-0.1.1/.python-version +1 -0
- smallsteps-0.1.1/PKG-INFO +85 -0
- smallsteps-0.1.1/README.md +76 -0
- smallsteps-0.1.1/pyproject.toml +42 -0
- smallsteps-0.1.1/smallsteps.toml +9 -0
- smallsteps-0.1.1/src/smallsteps/__init__.py +12 -0
- smallsteps-0.1.1/src/smallsteps/ci_adapter.py +69 -0
- smallsteps-0.1.1/src/smallsteps/cli.py +275 -0
- smallsteps-0.1.1/src/smallsteps/cli_helpers.py +18 -0
- smallsteps-0.1.1/src/smallsteps/command_runner.py +86 -0
- smallsteps-0.1.1/src/smallsteps/config_adapter.py +54 -0
- smallsteps-0.1.1/src/smallsteps/parsing.py +24 -0
- smallsteps-0.1.1/src/smallsteps/prober.py +45 -0
- smallsteps-0.1.1/src/smallsteps/ratchet.py +72 -0
- smallsteps-0.1.1/tests/__init__.py +0 -0
- smallsteps-0.1.1/tests/helpers.py +23 -0
- smallsteps-0.1.1/tests/test_cli.py +147 -0
- smallsteps-0.1.1/tests/test_command_runner.py +32 -0
- smallsteps-0.1.1/tests/test_prober.py +36 -0
- smallsteps-0.1.1/tests/test_ratchet.py +78 -0
- smallsteps-0.1.1/uv.lock +595 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: "Smallsteps Ratchet Evaluation"
|
|
2
|
+
description: "Evaluates your quality goals"
|
|
3
|
+
inputs:
|
|
4
|
+
SMALLSTEPS_PYTEST_COVERAGE:
|
|
5
|
+
description: "Current live performance value for 'Pytest Coverage'"
|
|
6
|
+
required: true
|
|
7
|
+
|
|
8
|
+
runs:
|
|
9
|
+
using: "composite"
|
|
10
|
+
steps:
|
|
11
|
+
- name: Install uv
|
|
12
|
+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
13
|
+
with:
|
|
14
|
+
enable-cache: true
|
|
15
|
+
|
|
16
|
+
- name: Run Smallsteps Guardrail Validation
|
|
17
|
+
shell: bash
|
|
18
|
+
run: uvx smallsteps-ratchet check
|
|
19
|
+
env:
|
|
20
|
+
SMALLSTEPS_PYTEST_COVERAGE: ${{ inputs.SMALLSTEPS_PYTEST_COVERAGE }}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
name: CI and Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v6
|
|
14
|
+
|
|
15
|
+
- name: Install uv
|
|
16
|
+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
17
|
+
with:
|
|
18
|
+
enable-cache: true
|
|
19
|
+
|
|
20
|
+
- name: Install dependencies
|
|
21
|
+
run: uv sync --all-groups --frozen
|
|
22
|
+
|
|
23
|
+
- name: Lint with ruff
|
|
24
|
+
run: uv run ruff check .
|
|
25
|
+
|
|
26
|
+
- name: Format check with ruff
|
|
27
|
+
run: uv run ruff format --check .
|
|
28
|
+
|
|
29
|
+
- name: Execute Test Suite with JSON Report
|
|
30
|
+
run: uv run pytest --cov --cov-report=json
|
|
31
|
+
|
|
32
|
+
- name: Parse Metrics
|
|
33
|
+
id: extract_metrics
|
|
34
|
+
run: |
|
|
35
|
+
COVERAGE_RAW=$(jq -r '.totals.percent_covered' coverage.json)
|
|
36
|
+
echo "coverage=$COVERAGE_RAW" >> $GITHUB_OUTPUT
|
|
37
|
+
|
|
38
|
+
# - name: Enforce Monotonic Progress Guardrails
|
|
39
|
+
# uses: ./.github/actions/smallsteps
|
|
40
|
+
# with:
|
|
41
|
+
# SMALLSTEPS_PYTEST_COVERAGE: ${{ steps.extract_metrics.outputs.coverage }}
|
|
42
|
+
|
|
43
|
+
publish:
|
|
44
|
+
needs: test
|
|
45
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
environment:
|
|
48
|
+
name: pypi
|
|
49
|
+
url: https://pypi.org/p/nasa-lsp
|
|
50
|
+
permissions:
|
|
51
|
+
id-token: write
|
|
52
|
+
contents: write
|
|
53
|
+
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@v6
|
|
56
|
+
with:
|
|
57
|
+
fetch-depth: 0
|
|
58
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
59
|
+
|
|
60
|
+
- name: Install uv
|
|
61
|
+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
62
|
+
with:
|
|
63
|
+
enable-cache: true
|
|
64
|
+
|
|
65
|
+
- name: Install dependencies
|
|
66
|
+
run: uv sync --all-groups --frozen
|
|
67
|
+
|
|
68
|
+
- name: Configure git
|
|
69
|
+
run: |
|
|
70
|
+
git config user.name "github-actions[bot]"
|
|
71
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
72
|
+
|
|
73
|
+
- name: Bump version
|
|
74
|
+
run: uv run bump-my-version bump patch
|
|
75
|
+
|
|
76
|
+
- name: Push version bump
|
|
77
|
+
run: git push --follow-tags
|
|
78
|
+
|
|
79
|
+
- name: Build package
|
|
80
|
+
run: uv build
|
|
81
|
+
|
|
82
|
+
- name: Publish to PyPI
|
|
83
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -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,76 @@
|
|
|
1
|
+
# Smallsteps - improve your codebase in small steps
|
|
2
|
+
|
|
3
|
+
Smallsteps allows you to declare ratchets – metrics that must increase over time – and to enforce them via testing or CI pipelines.
|
|
4
|
+
|
|
5
|
+
A typical situation: you want to add test coverage as a health check.
|
|
6
|
+
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.
|
|
7
|
+
**Somehow that never happens and your coverage stays at 42%.**
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
We are using this repository itself as an example. We want to enforce our test coverage to go up.
|
|
14
|
+
So we create a ratchet:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
uvx smallsteps add \
|
|
18
|
+
--name="Pytest Coverage" \
|
|
19
|
+
--command="uv run pytest --cov --cov-report=json > /dev/null && jq -r '.totals.percent_covered' coverage.json" \
|
|
20
|
+
--goal=80 \
|
|
21
|
+
--end="2026-10-01"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This created a new file – `smallsteps.toml` – which holds the configuration of our ratchet:
|
|
25
|
+
|
|
26
|
+
```toml
|
|
27
|
+
# Smallsteps Configuration
|
|
28
|
+
|
|
29
|
+
[[ratchets]]
|
|
30
|
+
name = "Pytest Coverage"
|
|
31
|
+
start = 2026-06-25
|
|
32
|
+
end = 2026-10-01
|
|
33
|
+
initial_value = 57.142857142857146
|
|
34
|
+
goal_value = 80
|
|
35
|
+
command = "uv run pytest --cov --cov-report=json > /dev/null && jq -r '.totals.percent_covered' coverage.json"
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Note**
|
|
40
|
+
|
|
41
|
+
- You don't need to memorize the command parameters, `smallsteps add` walks you through them interactively.
|
|
42
|
+
- 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.
|
|
43
|
+
|
|
44
|
+
You can inspect, add or modify the ratchets using the the toml file.
|
|
45
|
+
|
|
46
|
+
### Checking your Ratchets
|
|
47
|
+
|
|
48
|
+
Run `uvx smallsteps check` to check the status of your ratchets. To simulate the future you can run with `--date`, e.g.:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
uv smallsteps check --date 2026-10-31
|
|
52
|
+
Evaluating 1 ratchet(s)...
|
|
53
|
+
|
|
54
|
+
Pytest Coverage: BEHIND (Current: 57.14, Expected Min: 80.00)
|
|
55
|
+
|
|
56
|
+
Progress check failed.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
shows that on the goal date the check will fail if we do not achieve 80% test coverage.
|
|
61
|
+
|
|
62
|
+
### CI and reading values from env
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
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`.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
# License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smallsteps"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.13.4",
|
|
9
|
+
"typer>=0.26.7",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = [
|
|
14
|
+
"bump-my-version>=1.4.1",
|
|
15
|
+
"ruff>=0.15.19",
|
|
16
|
+
]
|
|
17
|
+
test = [
|
|
18
|
+
"pytest>=9.0.3",
|
|
19
|
+
"pytest-cov>=7.1.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv]
|
|
23
|
+
package = true
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
smallsteps = "smallsteps.cli:app"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.bumpversion]
|
|
33
|
+
current_version = "0.1.1"
|
|
34
|
+
commit = true
|
|
35
|
+
tag = true
|
|
36
|
+
tag_name = "v{new_version}"
|
|
37
|
+
message = "Bump version: {current_version} → {new_version} [skip ci]"
|
|
38
|
+
|
|
39
|
+
[[tool.bumpversion.files]]
|
|
40
|
+
filename = "pyproject.toml"
|
|
41
|
+
search = 'version = "{current_version}"'
|
|
42
|
+
replace = 'version = "{new_version}"'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Smallsteps Configuration
|
|
2
|
+
|
|
3
|
+
[[ratchets]]
|
|
4
|
+
name = "Pytest Coverage"
|
|
5
|
+
start = 2026-06-25
|
|
6
|
+
end = 2026-10-01
|
|
7
|
+
initial_value = 57.142857142857146
|
|
8
|
+
goal_value = 80
|
|
9
|
+
command = "uv run pytest --cov --cov-report=json > /dev/null && jq -r '.totals.percent_covered' coverage.json"
|
|
@@ -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}")
|
|
@@ -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)
|