Skip to content

refactor(services): split command policy out of Repository into a services layer - #9

Merged
rdawebb merged 8 commits into
mainfrom
repo-refactor
Aug 20, 2026
Merged

refactor(services): split command policy out of Repository into a services layer#9
rdawebb merged 8 commits into
mainfrom
repo-refactor

Conversation

@rdawebb

@rdawebb rdawebb commented Aug 20, 2026

Copy link
Copy Markdown
Owner

refactor(services): split command policy out of Repository into a services layer, enforced by import-linter

Breaks up the Repository god object. Every command verb (install, uninstall, upgrade, cleanup, pin/unpin, link/unlink) moves out of core/repo.py into a new brewery.services package as a free function taking the repo as its first argument, leaving Repository as a read-only data facade over the catalog and the filesystem cache. Underneath it, the native pipeline stops taking a whole repo object and takes the three ports it actually uses, so providers no longer imports core.repo at all. The resulting one-way layering (cli > daemon > services > providers > core) is now checked by import-linter in just check, the pre-commit hooks, and CI rather than by convention. No behaviour changes.

What changed

New services layer

  • services/{install,uninstall,upgrade,cleanup,pin,link,resolve,cask}.py — each former Repository method becomes a module-level function taking repo as its first argument: install_packages(repo, names, kind, ...), uninstall_packages, upgrade_packages, cleanup_packages, pin_packages/unpin_packages, link_packages/unlink_packages.
  • env= is an overridable keyword on every service rather than always being resolved internally, and formula/cask become injectable parameters defaulting to brew.formula_backend/brew.cask_backend instead of being read off the repo.
  • services/cask.pyinstall_casks/uninstall_casks/upgrade_casks, one named seam for the "hand to brew" path the three services share, typed against the narrow InstallBackend/UninstallBackend/UpgradeBackend protocols.
  • services/resolve.pyinstalled_formulae(), the alias-resolving lookup that pin and link both need.
  • services/link.py — the old providers/link_service.py (run_link/run_unlink) plus the link_packages/unlink_packages policy wrappers that used to live in the repo.
  • services/cleanup.pycleanup_packages() with its previously-nested remove_rack()/sweep_rack() helpers hoisted to module level, and _CLEANUP_CONCURRENCY moved with them.
  • services/upgrade.py — target selection splits into _select_targets(), so the entry point reads select → dispatch → re-scan rather than one long branch; the result tuple gets the UpgradeOutcome alias.
  • core/models.pyNotes = list[tuple[str, str]] promoted out of link_service and shared by every service for advisories and failures.

Repository reduced to a data facade

  • core/repo.py keeps only close, get_all_installed, get_details, search, and get_outdated.
  • cli/commands/{install,uninstall,upgrade,cleanup,link,pin}.py and daemon/catalog_refresh._maybe_cleanup() call the services directly instead of the repo methods.

Pipeline decoupled from Repository

  • providers/install_service.py and providers/upgrade_service.py merge into providers/pipeline.py (carrying PIPELINE_TIMEOUT). build_orchestrator(), run_install(), and run_upgrade() take catalog/cache_mgr/formula as explicit keyword ports instead of a whole repo.
  • install_adapters.pyRepositoryCatalogAdapter becomes CatalogAdapter, taking the catalog and cache manager directly. New CatalogSource, InstalledSource, and FallbackBackend protocols name only the four catalog reads, the one installed-state read, and the two verbs the adapter forwards, keeping the boundary checkable without dragging the full Catalog surface across it.
  • providers/base.pyPackageBackend splits into InstallBackend/UninstallBackend/UpgradeBackend, with PackageBackend composing all three, so each service can ask for exactly the verbs it uses.
  • providers/brew.py_make_backend()'s SimpleNamespace closure becomes a real BrewBackend class with a static assertion against PackageBackend.
  • providers/orchestrator.pyFormulaRowP re-declared as read-only properties: a protocol's mutable attributes are invariant and need a writable target, which the frozen FormulaRow is not. bottle_url/bottle_sha256 are now correctly typed as optional, with the narrowed sha bound to a local before use.
  • providers/cellar.pyremove_rack() (formerly uninstall_service._remove_formula()) joins the rest of the keg-removal primitives, and uses cellar's own rmtree() wrapper rather than shutil.rmtree directly. run_uninstall() moves up into services/uninstall.py alongside its caller, leaving providers with the primitives and none of the policy.
  • providers/pin_service.py renamed to providers/pinning.py.

Moves within core

  • core/deps.py (new) — blocking_dependents(), formerly Repository._blocking_dependents(), now a pure function over an already-merged package list with no repo access. The caller decides where the list comes from and reuses the kind=None scan when there was one, so a cask-only batch never scans the cellar at all.
  • analysis/status.derive_local_status() moves into core/merge.py, its only caller; the now-empty analysis package is removed.

Layering contracts

  • pyproject.tomlimport-linter added as a dev dependency with two contracts: an exhaustive layers contract pinning cli > daemon > services > providers > core, and a forbidden contract keeping providers off core.repo.
  • lint-imports wired into a new just layers recipe, just check, the pre-commit hooks, and the CI check job.

Behaviour notes

  • No user-visible change: every command runs the same code, reached through a function rather than a method.
  • Repository(formula_backend=..., cask_backend=...) is gone — inject backends per call via each service's formula=/cask= keywords instead.
  • repo.install_packages(...) and its siblings no longer exist; call brewery.services.<verb>.<verb>_packages(repo, ...).
  • Adding an import that points up a layer now fails just check, the pre-commit run, and CI. exhaustive = true means a new top-level package under brewery must be added to the contract before it can be imported at all.
  • providers importing core.repo is a hard error, so anything in providers needing repo state must take the narrow port instead.

Tests

  • tests/unit/test_repo.py (new) — asserts Repository's public surface is still the frozen read-only set, so a mutating verb landing back on the facade is a caught regression.
  • tests/integration/test_repo.pytest_querying.py, keeping only the facade's read queries (get_all_installed, outdated derivation, get_details, search). The ~930 lines of command tests move out to test_installing.py, test_uninstalling.py, test_upgrading.py, test_cleanup_service.py, and test_pin_link_services.py, with shared fixtures and helpers extracted into _repo_helpers.py. The rename also keeps the basename unique against the new unit-level test_repo.py, which pytest's rootdir-relative module naming requires.
  • tests/unit/test_install_service.py and test_upgrade_service.py merge into test_pipeline.py, driving the explicit catalog/cache_mgr/formula ports; _stubs.MockRepo is replaced by a RecordingBackend.
  • test_install_adapters.py rebuilt against CatalogAdapter's two constructor arguments rather than a whole repo; the _remove_formula() cases move to test_cellar.py alongside remove_rack().
  • test_link_service.pytest_link.py, test_pin_service.pytest_pinning.py, and test_status.py/test_uninstall_service.py re-pointed at their new import paths.

…orts explicitly

- install_service.py and upgrade_service.py merge into pipeline.py, with 'PIPELINE_TIMEOUT'
- build_orchestrator(), run_install(), and run_upgrade() now take 'catalog'/'cache_mgr'/'formula' as explicit keyword ports instead of a whole 'repo' object
- 'RepositoryCatalogAdapter' becomes 'CatalogAdapter', taking the catalog and cache manager directly
- New 'CatalogSource', 'InstalledSource', and 'FallbackBackend' protocols name only the four catalog and installed-state reads, and the two required verbs
- Keeps the boundary checkable without dragging the full 'Catalog' surface across it
- 'PackageBackend' splits into 'InstallBackend'/'UninstallBackend'/'UpgradeBackend', with 'PackageBackend' composing all three
- _make_backend()'s 'SimpleNamespace' closure becomes a real 'BrewBackend' class with a static assertion against 'PackageBackend'
- _remove_formula() moves from uninstall_service.py to cellar.remove_rack(), where the rest of the keg-removal primitives live
- run_uninstall() now takes an 'UninstallBackend' rather than a repo
- 'FormulaRowP' re-declared as read-only properties, as a protocol's mutable attributes are invariant and need a writable target, which the frozen 'FormulaRow' isn't
- 'bottle_url'/ 'bottle_sha256' are now correctly typed as optional, with the narrowed sha bound to a local before use
- pin_service.py renamed to pinning.py, and its test file to match
- test_install_service.py/test_upgrade_service.py merge into test_pipeline.py
- '_stubs.MockRepo' replaced by a 'RecordingBackend'
- _remove_formula() cellar tests move alongside remove_rack()
…to a services layer

- New 'services/' package establishes a one-way layering — 'cli' -> 'services' -> {'core', 'providers'}
- pin_packages()/unpin_packages()/'link_packages()/unlink_packages()/_resolve_installed_formulae() move out of repo.py into services/{pin/link/resolve}.py
- Each take 'repo' as an argument and 'env=' becomes an overridable keyword rather than always being resolved internally
- link_service.py moves to services/link.py with the new 'link_packages'/'unlink_packages' policy wrappers
- The '(name, reason)' pair type 'Notes' is promoted out of 'link_service' to models.py and shared by every service
- cli/commands/{link/pin}.py call the services directly instead of the repo methods
- Pin/link test cases move from test_repo.py into a new test_pin_link_services.py
- test_link_service.py is renamed to test_link.py
…layer

- cleanup_packages() moves to services/cleanup.py, alongside its previously-nested remove_rack()/sweep_rack() helpers, now module-level
- '_CLEANUP_CONCURRENCY' moves with them
- The function-local imports the old method needed to avoid a cycle become ordinary module-level imports now that the code sits above 'core'
- 'env=' becomes an overridable keyword rather than always resolved internally, and the failure lists use the shared 'Notes' alias
- cli/commands/cleanup.py and daemon/catalog_refresh._maybe_cleanup() call the service directly instead of the repo method
- 'TestCleanup' moves from test_repo.py into a new test_cleanup_service.py, with the per-test local imports hoisted to module level
…s layer

- uninstall_packages() and _verify_removed() move to services/uninstall.py as free functions taking the repo as an argument
- The function-local imports the old method needed become ordinary module-level ones now that the code sits above 'core'
- 'env=' becomes an overridable keyword
- The 'formula'/'cask' backends become injectable parameters defaulting to 'brew.formula_backend'/'brew.cask_backend' instead of being read off the repo
- _blocking_dependents() moves to deps.py, now a pure function over an already-merged package list with no repo access
- The caller decides where the list comes from, reusing the 'kind=None' scan when there was one so a cask-only batch never scans the cellar at all
- The failure lists use the shared 'Notes' alias
- cli/commands/uninstall.py calls the service directly instead of the repo method
- Uninstall and _verify_removed() test cases move from test_repo.py into a new test_uninstalling.py
- Shared fixtures and helpers extracted to _repo_helpers.py
…layer

- upgrade_packages() moves to services/upgrade.py as a free function taking the repo as an argument
- Target selection splits out into _select_targets(), so the entry point reads as select -> dispatch -> re-scan rather than one long branch
- 'env=' becomes an overridable keyword
- 'formula'/'cask' become injectable backends defaulting to 'brew.formula_backend'/'brew.cask_backend' instead of being read off the repo
- The result tuple gets the 'UpgradeOutcome' alias and uses the shared 'Notes' alias for advisories/failures
- cli/commands/upgrade.py calls the service directly instead of the repo method
- pipeline.py drops the module docstring paragraph describing the layering, now stated once in services/__init__.py
- 'TestUpgrade' moves from test_repo.py into a new test_upgrading.py
…e seam

- install_packages() moves to services/install.py as a free function taking the repo as an argument
- 'Repository' is now purely the data facade, with every command-policy method relocated
- 'env=' becomes an overridable keyword
- 'formula'/'cask' become injectable backends defaulting to 'brew.formula_backend'/'brew.cask_backend', matching the uninstall/upgrade services
- The failure list uses the shared 'Notes' alias
- New 'services/cask.py' gives the three services one named seam for the 'pass to brew' path
- cli/commands/install.py calls the service directly instead of the repo method
- 'TestInstall' moves from test_repo.py into a new test_installing.py
- The now-unused _repo_with_providers() helper is dropped, since services take their backends as arguments
- Add 'import-linter' as a dev dependency with two contracts in pyproject.toml
- An exhaustive 'layers' contract pinning 'cli' > 'daemon' > 'services' > 'providers' > 'core', and a 'forbidden' contract keeping 'providers' off 'repo'
- Wire 'lint-imports' into the 'just layers' recipe, 'just check', the pre-commit hooks, and the CI check job
- 'Repository' drops its 'formula_backend'/'cask_backend' constructor arguments and the 'brewery.providers' import they needed
- derive_local_status() moves into merge.py, its only caller, and the now-empty 'analysis' package is removed
- run_uninstall() moves into services/uninstall.py alongside its caller, leaving 'providers' with the primitives and none of the policy
- remove_rack() now calls the cellar.py's own rmtree() wrapper rather than 'shutil.rmtree' directly
- 'log_args' on the install/uninstall services corrected from 'name' to 'names'
- test_status.py merges into test_merge.py, and test_uninstall_service.py becomes test_uninstalling.py
- unit/test_repo.py and integration/test_repo.py shared a basename, which pytest can't import as two distinct modules
- The integration file only covers the facade's read paths, so 'test_querying.py' matches the naming of its siblings
@rdawebb
rdawebb merged commit 1be03b5 into main Aug 20, 2026
20 checks passed
@rdawebb
rdawebb deleted the repo-refactor branch August 20, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant