diff --git a/.git_archival.txt b/.git_archival.txt
new file mode 100644
index 0000000..8d015c8
--- /dev/null
+++ b/.git_archival.txt
@@ -0,0 +1,4 @@
+node: $Format:%H$
+node-date: $Format:%cI$
+describe-name: $Format:%(describe:tags=true,match=v[0-9]*)$
+ref-names: $Format:%D$
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a94cb2f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+.git_archival.txt export-subst
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
new file mode 100644
index 0000000..4627bbf
--- /dev/null
+++ b/.github/workflows/package.yml
@@ -0,0 +1,37 @@
+name: package
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install build frontend
+ run: python -m pip install --upgrade build
+
+ - name: Build sdist and wheel
+ run: python -m build
+
+ - name: Show checksums
+ run: sha256sum dist/*
+
+ - name: Upload distribution artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: roll-dist-${{ github.ref_name }}
+ path: dist/*
diff --git a/.gitignore b/.gitignore
index 042acc4..9b094ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ dist/
downloads/
eggs/
.eggs/
+src/roll/_version.py
lib/
lib64/
parts/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2691b0a..fd12ac9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,7 @@
## Before you start
-- Start here — [docs/index.md](docs/index.md)
+- Start here — [docs/index.md](docs/_index.md)
- Project scope — [docs/getting-started.md](docs/getting-started.md): if a feature doesn't help find a roll from memory faster, let's discuss it separately before a PR
- Architecture and command effects — [docs/architecture.md](docs/architecture.md)
- Terms and exact rules — [docs/reference.md](docs/reference.md)
diff --git a/README.md b/README.md
index f3ba1da..16ebebe 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
## `roll`
-
-

-
+[//]: # ()
+[//]: # (

)
+[//]: # (
)
A small CLI for managing a film photography archive.
@@ -25,6 +25,21 @@ The goal is to keep this information next to the archive instead of in my head.
---
+## What it does
+
+Use `roll` to:
+
+- keep film stock in one place;
+- record when a film was loaded and into which camera;
+- mark a roll as processed or failed;
+- add features and tags later, when the memory is still fresh;
+- search the archive by film, camera, status, year, or a short text query;
+- normalize archive metadata without moving the archive into a separate system.
+
+It is a CLI for small, durable records around the archive, not for managing the photos themselves.
+
+---
+
## Principles
`roll` doesn't try to replace your photo archive.
@@ -43,19 +58,15 @@ Some principles behind the project:
## Installation
-```text
-pip install git+https://github.com/katrinio/roll.git
-```
+`brew install katrinio/tap/roll`
-or
+Alternative install methods: `uv tool install git+https://github.com/katrinio/roll.git`, `pip install git+https://github.com/katrinio/roll.git`.
-```text
-uv tool install git+https://github.com/katrinio/roll.git
-```
+Tap details: [docs/homebrew.md](docs/homebrew.md).
---
-## Getting started
+## Main flow
| Status | Command | Description |
|--------|----------|-------------|
@@ -68,6 +79,16 @@ uv tool install git+https://github.com/katrinio/roll.git
| `processed` | `rl search` | Search the archive |
| `processed` | `rl normalize` | Normalize folder names |
+Typical use:
+
+```bash
+rl init ~/Pictures/plenka
+rl stock add
+rl load
+rl stock process
+rl search balcony
+```
+
---
## Storage
diff --git a/docs/index.md b/docs/_index.md
similarity index 88%
rename from docs/index.md
rename to docs/_index.md
index f78a968..05c0a1b 100644
--- a/docs/index.md
+++ b/docs/_index.md
@@ -5,7 +5,7 @@ Start here:
| Doc | What it answers |
|---|---|
| [Getting Started](getting-started.md) | What the tool does and how to begin |
-| [Editing](editing.md) | How to use `stock edit` and `batch` |
+| [Editing](editing.md) | How to update metadata and run batch changes |
| [Architecture](architecture.md) | What files exist, what commands read and write, how the archive is structured |
| [Reference](reference.md) | Exact terms, behavior, and rules |
| [Development](development.md) | Setup, checks, and CI |
diff --git a/docs/architecture.md b/docs/architecture.md
index 1bb169f..67ee4e2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -41,24 +41,26 @@ archives = ["/path/to/archive"]
| Command | Reads | Writes | Auto-fix | Scope |
|---|---|---|---|---|
-| `rl version` | package metadata, git tag | no | no | meta |
+| `rl --version` | package metadata, git tag | no | no | meta |
| `rl init` | filesystem | global config, workspace | no | setup |
+| `rl config` | global config | no | no | config |
| `rl config lang` | global config | global config | yes, with `rl doctor --fix` | config |
+| `rl update` | no | no | no | meta |
| `rl stock add` | global config, workspace, vocabulary, stock | stock | no | workspace |
| `rl load` | global config, stock, vocabulary | stock, roll | no | roll creation |
| `rl load --manual` | global config, vocabulary | roll | no | roll creation |
| `rl stock process` / `rl stock failed` | global config, workspace, roll | roll | no | roll status |
+| `rl batch process` | global config, workspace, roll | roll | no | roll status |
| `rl features add` / `rl tags add` | global config, workspace, vocabulary, roll | roll, vocabulary | no | roll editing |
| `rl search` / `rl scan` / `rl status` / `rl stats` / `rl vocab` | global config, workspace, roll, vocabulary | no | no | read-only |
| `rl doctor` | global config, workspace, stock, roll, vocabulary | no | yes with `--fix` | integrity |
| `rl normalize` | current archive workspace, roll, vocabulary | roll, vocabulary | yes | normalization |
| `rl normalize --photos` | photo folders in current archive workspace | archive folders | yes | photo import |
-| `rl stock edit` | global config, workspace, roll, vocabulary | roll, vocabulary | no | single-roll editing |
| `rl batch` | global config, workspace, roll | roll | no | batch update |
These scopes are deliberate:
-- `rl stock edit` is the manual, one-roll editor;
+- `rl tags add` and `rl features add` update one roll at a time;
- `rl batch` is the bulk editor;
- `rl search` shares the same filter language as `rl batch`, but never writes;
- `rl normalize` is for structural cleanup and normalization;
@@ -104,7 +106,7 @@ rl stock process
```bash
rl search kir balcony
rl status
-rl stats -v
+rl stats --verbose
```
### Integrity pass
diff --git a/docs/development.md b/docs/development.md
index 7154eff..377e6e3 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -29,7 +29,7 @@ pre-commit run --all-files
```bash
ruff check .
-python -m unittest discover -s tests
+PYTHONPATH=src python -m unittest discover -s tests
```
## CLI Map
@@ -37,22 +37,21 @@ python -m unittest discover -s tests
| Flow | Command | Reads | Writes | Auto-fix |
|---|---|---|---|---|
| Setup | `rl init /path/to/archive` | filesystem | global config, workspace | no |
-| Language | `rl config lang`, `rl config lang EN`, `rl config lang RU` | global config | global config | yes, via `rl doctor --fix` |
+| Config | `rl config`, `rl config lang`, `rl config lang EN`, `rl config lang RU` | global config | global config | yes, via `rl doctor --fix` |
| Stock | `rl stock add`, `rl stock list` | config, vocab, stock | stock | no |
| Roll creation | `rl load`, `rl load --manual` | stock, vocab | roll, stock | no |
-| Roll status | `rl stock process`, `rl stock failed` | roll | roll | no |
-| Editing | `rl features add`, `rl tags add` | roll, vocab | roll, vocab | no |
+| Roll status | `rl stock process`, `rl stock failed`, `rl batch process` | roll | roll | no |
+| Metadata | `rl features add`, `rl tags add`, `rl batch` | roll, vocab | roll, vocab | no |
| Read-only | `rl search`, `rl scan`, `rl status`, `rl stats`, `rl vocab` | global config, workspace, roll, vocab | no | no |
| Integrity | `rl doctor`, `rl doctor --fix` | global config, workspace, stock, roll, vocab | no | yes |
| Normalization | `rl normalize --tags` | workspace, roll, vocab | roll, vocab | yes |
-| Batch | `rl batch` | workspace, roll | roll | no |
`rl load --manual` creates a roll from the film dictionary without changing stock.
For `rl features add` and `rl tags add` you can enter several values separated by commas. Autocomplete works per value, duplicates aren't written, `_` is allowed inside a value.
`rl config lang` applies immediately in the current process because user-facing messages resolve the language at render time.
`rl doctor` checks the global config, workspace config, stock, roll metadata, and vocabularies. Its diagnostics stay in English; only the user-facing UI is localized.
Package version comes from git tags at build time. In a source checkout, `rl --version` falls back to the latest git tag if package metadata is not installed.
-`rl update` is a runtime convenience command for local installs; it runs `pip install --no-input --upgrade --force-reinstall git+https://github.com/katrinio/roll.git@main` in the current interpreter.
+`rl update` prints package manager update guidance. It does not reinstall the package.
## CI
diff --git a/docs/editing.md b/docs/editing.md
index 63358f0..1d85239 100644
--- a/docs/editing.md
+++ b/docs/editing.md
@@ -1,17 +1,17 @@
# Editing
-`roll` has two editing surfaces:
+`roll` has two write paths:
-- `rl stock edit` for one roll at a time;
-- `rl batch` for many rolls at once.
+- `rl tags add` and `rl features add` update one roll at a time;
+- `rl batch` applies one change to many rolls.
-They solve different problems and should stay separate.
+Use `rl search` to preview a set before `rl batch`.
## Boundaries
| Command | Scope | Typical use |
|---|---|---|
-| `rl stock edit` | one roll | manual correction, field-by-field judgment |
+| `rl tags add` / `rl features add` | one roll | add or correct roll metadata |
| `rl batch` | many rolls | repeated update across a filtered set |
| `rl search` | many rolls | structured lookup, optionally with free text |
| `rl normalize` | archive structure | folder shape, keywords normalization, photo import |
@@ -19,29 +19,28 @@ They solve different problems and should stay separate.
---
-## `rl stock edit`
+## Single-roll metadata updates
-Use this when you want to inspect and adjust one roll by hand.
+Use `rl tags add` or `rl features add` to update one roll.
-What it does:
+What they do:
- selects a single roll;
-- edits its metadata fields directly;
-- keeps the current values available in the prompts;
-- applies changes only after you confirm each value in the interactive flow.
+- updates either `keywords` or `features`;
+- writes new values to the roll and the matching vocabulary;
+- skips duplicates.
Best for:
- correcting one record;
-- changing a camera on one roll;
- refining features or keywords on a single roll;
-- adjusting the origin fields on one roll.
+- adding new vocabulary values during normal use.
Not for:
- mass changes across many rolls;
-- filtering by year or film name;
-- bulk status updates.
+- camera changes across many rolls;
+- repeated status updates.
---
@@ -61,8 +60,7 @@ Selection is based on filters such as:
- year;
- film name;
- camera;
-- status;
-- tags or keywords when needed.
+- status.
Within one filter, comma-separated values mean "match any of these".
Across filters, the selection is cumulative.
@@ -86,7 +84,7 @@ Not for:
Use this rule:
-- one roll, manual judgment, many fields -> `rl stock edit`;
+- one roll, tags or features only -> `rl tags add` or `rl features add`;
- many rolls, one repeated operation -> `rl batch`.
## `rl search`
@@ -104,8 +102,7 @@ Selection is based on the same filters:
- year;
- film name;
- camera;
-- status;
-- tags or keywords.
+- status.
Within one filter, comma-separated values mean "match any of these".
Across filters, the selection is cumulative.
@@ -123,7 +120,7 @@ Not for:
- structural normalization;
- integrity repair.
-If you need both at different times, start with `stock edit` for the outlier and use `batch` for the rest.
+If you need both at different times, start with `rl search` to preview the set, then run `rl batch`.
---
@@ -134,19 +131,23 @@ rl search --year 2025 --status loaded
```
```bash
-rl search --film "Kodak Gold 200, Ilford HP5 Plus" --query balcony
+rl search --film "Kodak Gold 200, Ilford HP5 Plus" balcony
+```
+
+```bash
+rl tags add
```
```bash
-rl stock edit
+rl features add
```
```bash
-rl batch --year 2025 --film "Kodak Gold 200, Ilford HP5 Plus" --set camera="Pentax K1000"
+rl batch --year 2025 --film "Kodak Gold 200, Ilford HP5 Plus" --set-camera "Pentax K1000"
```
```bash
-rl batch --year 2025 --set status=processed
+rl batch --year 2025 --set-status processed
```
```bash
diff --git a/docs/getting-started.md b/docs/getting-started.md
index c52ab78..7e388af 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,6 +1,6 @@
# Getting Started
-> Find the right roll from memory: a person, a place, an event, a mood - months later.
+Track film stock, loaded rolls, and archive metadata.
## Main Flow
@@ -12,28 +12,29 @@ rl stock process # or: rl stock failed
rl search kir balcony # half a year later — found it
```
-## What This Covers
+## Command Groups
-| Flow | Command |
+| Area | Commands |
|---|---|
-| Start | `rl init`, `rl config lang` |
+| Setup | `rl init`, `rl config`, `rl config lang` |
| Film stock | `rl stock add`, `rl stock list` |
| Roll creation | `rl load`, `rl load --manual` |
-| Status update | `rl stock process`, `rl stock failed` |
-| Fill in a roll | `rl features add`, `rl tags add` |
+| Status update | `rl stock process`, `rl stock failed`, `rl batch process` |
+| Metadata | `rl features add`, `rl tags add`, `rl batch` |
| Find / inspect | `rl search`, `rl scan`, `rl status`, `rl stats [-v]`, `rl vocab` |
| Integrity | `rl doctor`, `rl doctor --fix`, `rl normalize --tags` |
| Photo import | `rl normalize --photos` |
-| Editing | `rl stock edit`, `rl batch` |
## Out of Scope
sync between machines · cloud · web UI · migrating old formats · image processing
-The CLI defaults to English in the global config and `rl config lang` changes it.
-`rl --version` prints the current version. If a newer git tag is available in the current checkout, it also prints a short update hint and points to `rl update`.
-`rl update` reinstalls the app from the GitHub repository.
-`rl normalize --photos` works in the current archive workspace and can turn a raw photo folder into an archive month based on the dominant photo date.
+## Notes
+
+- Run `rl config lang EN` or `rl config lang RU` to set the UI language.
+- Run `rl --version` to print the current version.
+- Run `rl update` to print package manager update guidance.
+- Run `rl normalize --photos` in the current archive to sort raw photo folders by dominant photo date.
## Rule
diff --git a/docs/homebrew.md b/docs/homebrew.md
new file mode 100644
index 0000000..b3a72ca
--- /dev/null
+++ b/docs/homebrew.md
@@ -0,0 +1,64 @@
+# Homebrew tap
+
+`roll` is a good fit for a personal Homebrew tap before aiming at `homebrew/core`.
+
+## Why a tap first
+
+- no central review queue;
+- easier iteration on formula changes;
+- suitable for niche CLI tools;
+- users can still install with a single `brew install owner/tap/roll`.
+
+## Expected repository layout
+
+Create a separate repository named `homebrew-tap`:
+
+```text
+homebrew-tap/
+└── Formula/
+ └── roll.rb
+```
+
+Use [`packaging/homebrew/roll.rb`](../packaging/homebrew/roll.rb) as the starting point.
+
+## Install flow
+
+```bash
+brew tap katrinio/tap
+brew install roll
+```
+
+Or in one command:
+
+```bash
+brew install katrinio/tap/roll
+```
+
+## Release checklist
+
+1. Push a tagged release such as `v0.8.0`.
+2. Wait for the `package` GitHub Actions workflow to build `sdist` and wheel artifacts for that tag.
+3. Compute the GitHub tag tarball checksum for that exact release:
+
+```bash
+curl -L -o /tmp/roll-v0.8.0.tar.gz \
+ https://github.com/katrinio/roll/archive/refs/tags/v0.8.0.tar.gz
+shasum -a 256 /tmp/roll-v0.8.0.tar.gz
+```
+
+4. Copy [`packaging/homebrew/roll.rb`](../packaging/homebrew/roll.rb) into the tap as `Formula/roll.rb`.
+5. Update `url` and top-level `sha256` in the formula.
+6. Fill Python dependency resource checksums. If Homebrew developer commands are available, run:
+
+```bash
+brew update-python-resources Formula/roll.rb
+```
+
+7. Run `brew install --build-from-source ./Formula/roll.rb`.
+8. Run `brew test roll`.
+
+## Notes for `roll`
+
+- `rl update` intentionally does not self-update. Homebrew users should use `brew upgrade roll`.
+- package versioning is configured to work from git archives used by tagged source tarballs.
+- a meaningful `brew test` should initialize a temporary archive and verify that `.roll` was created.
diff --git a/docs/reference.md b/docs/reference.md
index f7fa94e..db204a3 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -12,13 +12,13 @@ A folder with a film archive: years of rolls plus `.roll/` (workspace). `roll` d
## Workspace
-`.roll/` inside the archive: workspace config, [[Stock]], and vocabularies. It should stay portable together with the archive.
+`.roll/` inside the archive. It stores workspace config, stock, and vocabularies. Keep it together with the archive.
---
## Stock
-Film that has not been loaded into a camera yet. Stored in `stock.toml`, separate from `roll.toml`: while a film is in stock, it is not a [[Roll]] yet and does not take up a folder in the archive.
+Film that has not been loaded into a camera yet. It lives in `stock.toml`. It is not a roll yet and does not create an archive folder.
---
@@ -84,7 +84,7 @@ Brings folder names to a consistent shape: builds a plan, asks for confirmation,
## Editing
-`rl stock edit` is a single-roll manual editor. `rl batch` is a bulk editor that applies one change to many rolls selected by filters. `rl search` uses the same filter language for read-only lookup and can also keep free-text search.
+`rl tags add` and `rl features add` update one roll at a time. `rl batch` applies one change to many rolls selected by filters. `rl search` uses the same filters for read-only lookup and also accepts a positional free-text query.
See [docs/editing.md](editing.md) for the shared editing model and the boundary between the two commands.
diff --git a/packaging/homebrew/roll.rb b/packaging/homebrew/roll.rb
new file mode 100644
index 0000000..919050e
--- /dev/null
+++ b/packaging/homebrew/roll.rb
@@ -0,0 +1,32 @@
+class Roll < Formula
+ include Language::Python::Virtualenv
+
+ desc "Personal film roll index"
+ homepage "https://github.com/katrinio/roll"
+ url "https://github.com/katrinio/roll/archive/refs/tags/v0.7.0.tar.gz"
+ sha256 "__REPLACE_WITH_RELEASE_SHA256__"
+ license "MIT"
+
+ depends_on "python@3.12"
+
+ resource "typer" do
+ url "https://files.pythonhosted.org/packages/source/t/typer/typer-0.16.0.tar.gz"
+ sha256 "__REPLACE_WITH_TYPER_SHA256__"
+ end
+
+ resource "prompt-toolkit" do
+ url "https://files.pythonhosted.org/packages/source/p/prompt_toolkit/prompt_toolkit-3.0.51.tar.gz"
+ sha256 "__REPLACE_WITH_PROMPT_TOOLKIT_SHA256__"
+ end
+
+ def install
+ virtualenv_install_with_resources
+ end
+
+ test do
+ archive = testpath/"archive"
+ system bin/"rl", "init", archive
+ assert_predicate archive/".roll", :directory?
+ assert_predicate archive/".roll"/"stock.toml", :exist?
+ end
+end
diff --git a/pyproject.toml b/pyproject.toml
index 982af57..2b22477 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,13 @@ rl = "roll.cli:app"
requires = ["setuptools>=69", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
[tool.setuptools_scm]
version_scheme = "no-guess-dev"
local_scheme = "no-local-version"
+version_file = "src/roll/_version.py"
diff --git a/src/roll/app/root_commands.py b/src/roll/app/root_commands.py
index 4663900..f3e7e52 100644
--- a/src/roll/app/root_commands.py
+++ b/src/roll/app/root_commands.py
@@ -1,8 +1,6 @@
from __future__ import annotations
from pathlib import Path
-import subprocess
-import sys
import typer
@@ -18,7 +16,7 @@
from roll.helpers.guards import require_config, require_directory
from roll.helpers.output import echo_lines, echo_section
from roll.messages import Msg
-from roll.version import get_latest_version, get_version, is_outdated
+from roll.version import get_latest_version, get_update_hint, get_version, is_outdated
def version() -> None:
@@ -26,7 +24,7 @@ def version() -> None:
typer.echo(current)
latest = get_latest_version()
if latest and is_outdated(current=current, latest=latest):
- typer.echo(f"New version available: {latest}. Run `rl update`.")
+ typer.echo(f"New version available: {latest}. {get_update_hint()}")
raise typer.Exit()
@@ -50,20 +48,8 @@ def init(archive: Path) -> None:
def update() -> None:
- result = subprocess.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--no-input",
- "--upgrade",
- "--force-reinstall",
- "git+https://github.com/katrinio/roll.git@main",
- ],
- check=False,
- )
- raise typer.Exit(code=result.returncode)
+ typer.echo(str(Msg.UPDATE_USE_PACKAGE_MANAGER))
+ raise typer.Exit(code=1)
def config() -> None:
diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py
index e896f0e..203e208 100644
--- a/src/roll/messages/cli.py
+++ b/src/roll/messages/cli.py
@@ -163,6 +163,11 @@ class Msg(Headers):
"Нужен запрос или фильтр.",
"Query or filter required.",
)
+ UPDATE_USE_PACKAGE_MANAGER = Message(
+ "cli.update_use_package_manager",
+ "roll не обновляет себя сам. Используйте пакетный менеджер, например `brew upgrade roll`, или переустановите пакет из исходников.",
+ "roll does not self-update. Use your package manager, for example `brew upgrade roll`, or reinstall the package from source.",
+ )
TAGS_NORMALIZED = Message(
"cli.tags_normalized", "Теги нормализованы.", "Tags normalized."
)
diff --git a/src/roll/version.py b/src/roll/version.py
index fba89af..d48783a 100644
--- a/src/roll/version.py
+++ b/src/roll/version.py
@@ -5,6 +5,7 @@
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
import subprocess
+import sys
@lru_cache(maxsize=1)
@@ -27,6 +28,12 @@ def get_latest_version() -> str:
return _git_tag()
+def get_update_hint() -> str:
+ if _is_homebrew_install():
+ return "Update with `brew upgrade roll`."
+ return "Update using your package manager or reinstall from source."
+
+
def is_outdated(current: str | None = None, latest: str | None = None) -> bool:
current_version = _version_tuple(current or get_version())
latest_version = _version_tuple(latest or get_latest_version())
@@ -95,3 +102,8 @@ def _max_version(values: list[str]) -> str:
if not parsed:
return ""
return max(parsed, key=lambda item: item[0])[1]
+
+
+def _is_homebrew_install() -> bool:
+ executable = Path(sys.executable).resolve()
+ return any(part in {"Cellar", "Homebrew"} for part in executable.parts)
diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py
index 792a55b..f52af06 100644
--- a/tests/test_cli_smoke.py
+++ b/tests/test_cli_smoke.py
@@ -36,6 +36,26 @@ def test_batch_help(self) -> None:
self.assertEqual(result.returncode, 0)
self.assertIn("process", self._output(result))
+ def test_init_creates_workspace(self) -> None:
+ with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as tmp:
+ env = os.environ.copy()
+ env["HOME"] = home
+ archive = Path(tmp) / "archive"
+ archive.mkdir()
+
+ result = subprocess.run(
+ [PYTHON, "-c", CLI_ENTRY, "init", str(archive)],
+ cwd=ROOT,
+ env=env,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ self.assertEqual(result.returncode, 0)
+ self.assertTrue((archive / ".roll").is_dir())
+ self.assertTrue((archive / ".roll" / "stock.toml").exists())
+
def test_stats_help(self) -> None:
result = self._run("stats", "--help")
self.assertEqual(result.returncode, 0)
diff --git a/tests/test_root_commands.py b/tests/test_root_commands.py
new file mode 100644
index 0000000..d69f864
--- /dev/null
+++ b/tests/test_root_commands.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from io import StringIO
+import unittest
+from contextlib import redirect_stdout
+
+import typer
+
+from roll.app import root_commands
+
+
+class RootCommandTests(unittest.TestCase):
+ def test_update_prints_package_manager_guidance(self) -> None:
+ output = StringIO()
+
+ with redirect_stdout(output):
+ with self.assertRaises(typer.Exit) as exc:
+ root_commands.update()
+
+ self.assertEqual(exc.exception.exit_code, 1)
+ self.assertIn("does not self-update", output.getvalue())
diff --git a/tests/test_version.py b/tests/test_version.py
index 8b03df4..82924c2 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -5,7 +5,7 @@
from pathlib import Path
from unittest.mock import patch
-from roll.version import get_version, is_outdated
+from roll.version import get_update_hint, get_version, is_outdated
class VersionTests(unittest.TestCase):
@@ -33,3 +33,17 @@ def test_is_outdated_compares_versions(self) -> None:
self.assertTrue(is_outdated(current="0.4.0", latest="0.4.1"))
self.assertFalse(is_outdated(current="0.4.1", latest="0.4.1"))
self.assertFalse(is_outdated(current="0.4.2", latest="0.4.1"))
+
+ def test_update_hint_prefers_brew_for_homebrew_installs(self) -> None:
+ with patch(
+ "roll.version.sys.executable",
+ "/opt/homebrew/Cellar/python@3.12/bin/python3.12",
+ ):
+ self.assertEqual(get_update_hint(), "Update with `brew upgrade roll`.")
+
+ def test_update_hint_is_generic_outside_homebrew(self) -> None:
+ with patch("roll.version.sys.executable", "/usr/bin/python3"):
+ self.assertEqual(
+ get_update_hint(),
+ "Update using your package manager or reinstall from source.",
+ )