refactor(services): split command policy out of Repository into a services layer - #9
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
refactor(services): split command policy out of Repository into a services layer, enforced by import-linter
Breaks up the
Repositorygod object. Every command verb (install, uninstall, upgrade, cleanup, pin/unpin, link/unlink) moves out ofcore/repo.pyinto a newbrewery.servicespackage as a free function taking the repo as its first argument, leavingRepositoryas a read-only data facade over the catalog and the filesystem cache. Underneath it, the native pipeline stops taking a wholerepoobject and takes the three ports it actually uses, soprovidersno longer importscore.repoat all. The resulting one-way layering (cli>daemon>services>providers>core) is now checked by import-linter injust check, the pre-commit hooks, and CI rather than by convention. No behaviour changes.What changed
New
serviceslayerservices/{install,uninstall,upgrade,cleanup,pin,link,resolve,cask}.py— each formerRepositorymethod becomes a module-level function takingrepoas 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, andformula/caskbecome injectable parameters defaulting tobrew.formula_backend/brew.cask_backendinstead of being read off the repo.services/cask.py—install_casks/uninstall_casks/upgrade_casks, one named seam for the "hand to brew" path the three services share, typed against the narrowInstallBackend/UninstallBackend/UpgradeBackendprotocols.services/resolve.py—installed_formulae(), the alias-resolving lookup that pin and link both need.services/link.py— the oldproviders/link_service.py(run_link/run_unlink) plus thelink_packages/unlink_packagespolicy wrappers that used to live in the repo.services/cleanup.py—cleanup_packages()with its previously-nestedremove_rack()/sweep_rack()helpers hoisted to module level, and_CLEANUP_CONCURRENCYmoved 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 theUpgradeOutcomealias.core/models.py—Notes = list[tuple[str, str]]promoted out oflink_serviceand shared by every service for advisories and failures.Repositoryreduced to a data facadecore/repo.pykeeps onlyclose,get_all_installed,get_details,search, andget_outdated.cli/commands/{install,uninstall,upgrade,cleanup,link,pin}.pyanddaemon/catalog_refresh._maybe_cleanup()call the services directly instead of the repo methods.Pipeline decoupled from
Repositoryproviders/install_service.pyandproviders/upgrade_service.pymerge intoproviders/pipeline.py(carryingPIPELINE_TIMEOUT).build_orchestrator(),run_install(), andrun_upgrade()takecatalog/cache_mgr/formulaas explicit keyword ports instead of a wholerepo.install_adapters.py—RepositoryCatalogAdapterbecomesCatalogAdapter, taking the catalog and cache manager directly. NewCatalogSource,InstalledSource, andFallbackBackendprotocols 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 fullCatalogsurface across it.providers/base.py—PackageBackendsplits intoInstallBackend/UninstallBackend/UpgradeBackend, withPackageBackendcomposing all three, so each service can ask for exactly the verbs it uses.providers/brew.py—_make_backend()'sSimpleNamespaceclosure becomes a realBrewBackendclass with a static assertion againstPackageBackend.providers/orchestrator.py—FormulaRowPre-declared as read-only properties: a protocol's mutable attributes are invariant and need a writable target, which the frozenFormulaRowis not.bottle_url/bottle_sha256are now correctly typed as optional, with the narrowed sha bound to a local before use.providers/cellar.py—remove_rack()(formerlyuninstall_service._remove_formula()) joins the rest of the keg-removal primitives, and uses cellar's ownrmtree()wrapper rather thanshutil.rmtreedirectly.run_uninstall()moves up intoservices/uninstall.pyalongside its caller, leavingproviderswith the primitives and none of the policy.providers/pin_service.pyrenamed toproviders/pinning.py.Moves within
corecore/deps.py(new) —blocking_dependents(), formerlyRepository._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 thekind=Nonescan when there was one, so a cask-only batch never scans the cellar at all.analysis/status.derive_local_status()moves intocore/merge.py, its only caller; the now-emptyanalysispackage is removed.Layering contracts
pyproject.toml—import-linteradded as a dev dependency with two contracts: an exhaustivelayerscontract pinningcli>daemon>services>providers>core, and aforbiddencontract keepingprovidersoffcore.repo.lint-importswired into a newjust layersrecipe,just check, the pre-commit hooks, and the CI check job.Behaviour notes
Repository(formula_backend=..., cask_backend=...)is gone — inject backends per call via each service'sformula=/cask=keywords instead.repo.install_packages(...)and its siblings no longer exist; callbrewery.services.<verb>.<verb>_packages(repo, ...).just check, the pre-commit run, and CI.exhaustive = truemeans a new top-level package underbrewerymust be added to the contract before it can be imported at all.providersimportingcore.repois a hard error, so anything inprovidersneeding repo state must take the narrow port instead.Tests
tests/unit/test_repo.py(new) — assertsRepository'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.py→test_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 totest_installing.py,test_uninstalling.py,test_upgrading.py,test_cleanup_service.py, andtest_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-leveltest_repo.py, which pytest's rootdir-relative module naming requires.tests/unit/test_install_service.pyandtest_upgrade_service.pymerge intotest_pipeline.py, driving the explicitcatalog/cache_mgr/formulaports;_stubs.MockRepois replaced by aRecordingBackend.test_install_adapters.pyrebuilt againstCatalogAdapter's two constructor arguments rather than a whole repo; the_remove_formula()cases move totest_cellar.pyalongsideremove_rack().test_link_service.py→test_link.py,test_pin_service.py→test_pinning.py, andtest_status.py/test_uninstall_service.pyre-pointed at their new import paths.