Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ performance.
<https://mamba.readthedocs.io>`_

- `uv <https://github.com/astral-sh/uv>`_, `pdm
<https://pdm-project.org>`_
<https://pdm-project.org>`_, `hatch <https://hatch.pypa.io>`_

- `venv <https://docs.python.org/3/library/venv.html>`_, `virtualenv
<https://virtualenv.pypa.io>`_
Expand Down Expand Up @@ -204,12 +204,13 @@ This automatically configures all supported packages for both
Environment Switching
=====================

For projects using conda, mamba, or pixi, you can now switch environments
For projects using conda, mamba, pixi, or hatch, you can now switch environments
interactively::

M-x pet-conda-switch-environment
M-x pet-mamba-switch-environment
M-x pet-pixi-switch-environment
M-x pet-hatch-switch-environment

When you enable ``pet-mode`` on a fresh project using these tools,
``pet`` will automatically prompt you to select an environment if none
Expand Down
2 changes: 2 additions & 0 deletions doc/SUPPORTED.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ environment tools and Emacs packages that pet supports.

- `pdm <https://pdm-project.org>`_

- `hatch <https://hatch.pypa.io>`_

- `pipx <https://pipx.pypa.io>`_

- `pyenv <https://github.com/pyenv/pyenv>`_ (very poorly maintained;
Expand Down
35 changes: 32 additions & 3 deletions pet.el
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,10 @@ Return nil if the file is not found." file-name)))
:file-name "pixi.toml"
:parser pet-parse-config-file)

(pet-def-config-accessor hatch
:file-name "hatch.toml"
:parser pet-parse-config-file)

(defun pet-use-pre-commit-p ()
"Whether the current project is using `pre-commit'.
Expand Down Expand Up @@ -844,6 +848,15 @@ Returns the path to the `pipenv' executable."
(and (pet-pipfile)
(pet--executable-find "pipenv" t)))

(defun pet-use-hatch-p ()
"Whether the current project is using `hatch'.
Returns the path to the `hatch' executable."
(and (or (pet-hatch)
(let-alist (pet-pyproject)
.tool.hatch.envs))
(pet--executable-find "hatch" t)))

(defun pet-pre-commit-config-has-hook-p (id)
"Determine if the `pre-commit' configuration has a hook.
Expand Down Expand Up @@ -1038,9 +1051,10 @@ Selects a virtualenv in the following order:
1. Cached virtualenv path (from previous detection or manual switching).
2. The value of the environment variable `VIRTUAL_ENV' if defined.
3. Poetry virtualenv from `pyproject.toml'.
4. Pipenv virtualenv from `Pipfile'.
5. A directory in `pet-venv-dir-names' in the project root if found.
6. Pyenv virtualenv from `.python-version'."
4. Hatch virtualenv from `hatch.toml' or `pyproject.toml'.
5. Pipenv virtualenv from `Pipfile'.
6. A directory in `pet-venv-dir-names' in the project root if found.
7. Pyenv virtualenv from `.python-version'."
(let ((root (pet-project-root)))
(or (pet-cache-get (list root :virtualenv))
(let ((venv-path
Expand All @@ -1049,6 +1063,10 @@ Selects a virtualenv in the following order:
((when-let* ((program (pet-use-poetry-p))
(default-directory (file-name-directory (pet-pyproject-path))))
(pet-run-process-get-output program "env" "info" "--no-ansi" "--path")))
((when-let* ((program (pet-use-hatch-p))
(config-file (or (pet-hatch-path) (pet-pyproject-path)))
(default-directory (file-name-directory config-file)))
(pet-run-process-get-output program "env" "find" "default")))
((when-let* ((program (pet-use-pipenv-p))
(default-directory (file-name-directory (pet-pipfile-path))))
(pet-run-process-get-output program "--quiet" "--venv")))
Expand Down Expand Up @@ -1109,6 +1127,13 @@ environments. This expression must return a list of strings."
:parse-output
(let-alist (pet-parse-json output) .envs))

(pet-def-env-list hatch
:args ("env" "show" "--json")
:parse-output
(mapcar (lambda (env-name)
(pet-run-process-get-output program "env" "find" env-name))
(mapcar #'symbol-name (mapcar #'car (pet-parse-json output)))))

(cl-defmacro pet-def-env-switch (name &key env-list-fn prompt-text)
"Define a environment switching function.
Expand Down Expand Up @@ -1159,6 +1184,10 @@ returned by `%s'." name name env-list-fn)))
:env-list-fn pet-mamba-environments
:prompt-text "Please select a mamba environment: ")

(pet-def-env-switch hatch
:env-list-fn pet-hatch-environments
:prompt-text "Please select a hatch environment: ")



(defvar flycheck-mode)
Expand Down
61 changes: 61 additions & 0 deletions test/pet-hatch-environments-test.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
;; -*- lexical-binding: t; -*-

(require 'pet)

(describe "pet-hatch-environments"
(describe "when hatch is available"
(before-each
(spy-on 'pet-use-hatch-p :and-return-value "/usr/bin/hatch"))

(it "should return list of environment paths on success"
(spy-on 'process-file :and-call-fake
(lambda (program &optional infile buffer display &rest args)
(cond
;; Mock hatch env show --json
((and (equal program "/usr/bin/hatch")
(equal args '("env" "show" "--json")))
(insert "{\"default\":{\"type\":\"virtual\"},\"test\":{\"type\":\"virtual\"}}")
0)
;; Mock hatch env find for each environment
((and (equal program "/usr/bin/hatch")
(equal (car args) "env")
(equal (cadr args) "find"))
(let ((env-name (caddr args)))
(cond
((equal env-name "default")
(insert "/home/user/.hatch/envs/project/default"))
((equal env-name "test")
(insert "/home/user/.hatch/envs/project/test")))
0))
(t 1))))
(expect (pet-hatch-environments) :to-equal '("/home/user/.hatch/envs/project/default" "/home/user/.hatch/envs/project/test")))

(it "should return empty list when no environments exist"
(spy-on 'process-file :and-call-fake
(lambda (program &optional infile buffer display &rest args)
(when (and (equal program "/usr/bin/hatch")
(equal args '("env" "show" "--json")))
(insert "{}")
0)))
(expect (pet-hatch-environments) :to-equal '()))

(it "should return nil on command failure"
(spy-on 'process-file :and-call-fake
(lambda (&rest _)
(insert "hatch: command not found")
1))
(spy-on 'pet-report-error)
(expect (pet-hatch-environments) :to-equal nil)
(expect 'pet-report-error :to-have-been-called)))

(describe "when hatch is not available"
(before-each
(spy-on 'pet-use-hatch-p))

(it "should return nil"
(expect (pet-hatch-environments) :to-equal nil))))


;; Local Variables:
;; eval: (buttercup-minor-mode 1)
;; End:
126 changes: 126 additions & 0 deletions test/pet-hatch-switch-environment-test.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
;; -*- lexical-binding: t; -*-

(require 'pet)

(setq python-indent-guess-indent-offset nil)

(describe "pet-hatch-switch-environment"
:var ((project-root "/home/user/project/")
(env-path "/home/user/.hatch/envs/project/test")
(other-env-path "/home/user/.hatch/envs/project/default")
buffer-a
buffer-b
other-project-buffer)

(before-each
(setq-local pet-cache nil)
(spy-on 'pet-project-root :and-return-value project-root)
(spy-on 'pet-hatch-environments :and-return-value
(list other-env-path env-path))
(spy-on 'pet-buffer-local-vars-teardown)
(spy-on 'pet-buffer-local-vars-setup)

(setq buffer-a (get-buffer-create "test-a.py"))
(setq buffer-b (get-buffer-create "test-b.py"))
(with-current-buffer buffer-a
(setq buffer-file-name "/home/user/project/main.py")
(python-mode)
(setq-local process-environment '("PATH=/usr/bin" "VIRTUAL_ENV=/old/env" "HOME=/home/user")))
(with-current-buffer buffer-b
(setq buffer-file-name "/home/user/project/utils.py")
(python-mode)
(setq-local process-environment '("PATH=/usr/bin" "VIRTUAL_ENV=/old/env" "HOME=/home/user")))

(setq other-project-buffer (get-buffer-create "other.py"))
(with-current-buffer other-project-buffer
(setq buffer-file-name "/home/user/other-project/main.py")
(python-mode)
(setq-local process-environment '("VIRTUAL_ENV=/other/old/env"))))

(after-each
(when buffer-a (kill-buffer buffer-a))
(when buffer-b (kill-buffer buffer-b))
(when other-project-buffer (kill-buffer other-project-buffer))
(kill-local-variable 'pet-cache))

(describe "basic functionality"
(it "should update virtualenv cache with selected environment"

(pet-hatch-switch-environment env-path)

(expect (pet-cache-get (list project-root :virtualenv))
:to-equal env-path))

(it "should call teardown and setup for all project Python buffers"
(spy-on 'buffer-list :and-return-value (list buffer-a buffer-b other-project-buffer))

(pet-hatch-switch-environment env-path)

(expect 'pet-buffer-local-vars-teardown :to-have-been-called-times 2)
(expect 'pet-buffer-local-vars-setup :to-have-been-called-times 2))

(it "should display success message"
(spy-on 'message)

(pet-hatch-switch-environment env-path)

(expect 'message :to-have-been-called-with
"Switched to %s environment: %s" "hatch" env-path)))

(describe "cache handling"
(it "should cache the selected environment for the project"
(spy-on 'buffer-list :and-return-value (list buffer-a buffer-b))

(pet-hatch-switch-environment env-path)

(expect (pet-cache-get (list project-root :virtualenv)) :to-equal env-path)))

(describe "buffer isolation"
(it "should only refresh variables for buffers from the same project"
(spy-on 'buffer-list :and-return-value (list buffer-a other-project-buffer))

(pet-hatch-switch-environment env-path)

(expect 'pet-buffer-local-vars-teardown :to-have-been-called-times 1)
(expect 'pet-buffer-local-vars-setup :to-have-been-called-times 1)))

(describe "interactive completion"
(it "should prompt with available environments"
(spy-on 'completing-read :and-return-value env-path)

(call-interactively #'pet-hatch-switch-environment)

(expect 'completing-read :to-have-been-called-with
"Please select a hatch environment: "
(list other-env-path env-path)
nil t))

(it "should use the selected environment"
(spy-on 'completing-read :and-return-value other-env-path)

(call-interactively #'pet-hatch-switch-environment)

(expect (pet-cache-get (list project-root :virtualenv))
:to-equal other-env-path)))

(describe "error handling"
(it "should handle when no project root is found"
(spy-on 'pet-project-root)

(pet-hatch-switch-environment env-path)

(expect 'pet-buffer-local-vars-teardown :not :to-have-been-called)
(expect 'pet-buffer-local-vars-setup :not :to-have-been-called))

(it "should handle when no Python buffers exist in project"
(spy-on 'buffer-list :and-return-value '())

(pet-hatch-switch-environment env-path)

(expect (pet-cache-get (list project-root :virtualenv)) :to-equal env-path)
(expect 'pet-buffer-local-vars-teardown :not :to-have-been-called))))


;; Local Variables:
;; eval: (buttercup-minor-mode 1)
;; End:
57 changes: 57 additions & 0 deletions test/pet-use-hatch-p-test.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
;; -*- lexical-binding: t; -*-

(require 'pet)

(describe "pet-use-hatch-p"
(describe "when the project has a `hatch.toml' file"
(before-each
(spy-on 'pet-pyproject)
(spy-on 'pet-hatch :and-return-value '((envs (default (dependencies ("pytest")))))))

(it "should return `hatch' path if `hatch' is found"
(spy-on 'pet--executable-find :and-return-value "/usr/bin/hatch")
(expect (pet-use-hatch-p) :to-equal "/usr/bin/hatch"))

(it "should return nil if `hatch' is not found"
(spy-on 'pet--executable-find)
(expect (pet-use-hatch-p) :to-be nil)))

(describe "when the `pyproject.toml' file has `[tool.hatch.envs]` section"
(before-each
(spy-on 'pet-hatch)
(spy-on 'pet-pyproject :and-return-value '((tool (hatch (envs (default (dependencies ("pytest")))))))))

(it "should return `hatch' path if `hatch' is found"
(spy-on 'pet--executable-find :and-return-value "/usr/bin/hatch")
(expect (pet-use-hatch-p) :to-equal "/usr/bin/hatch"))

(it "should return nil if `hatch' is not found"
(spy-on 'pet--executable-find)
(expect (pet-use-hatch-p) :to-be nil)))

(describe "when the project has neither `hatch.toml' nor `[tool.hatch.envs]` in `pyproject.toml'"
(before-each
(spy-on 'pet-hatch)
(spy-on 'pet-pyproject :and-return-value '((build-system (build-backend . "setuptools")))))

(it "should return nil if `hatch' is found"
(spy-on 'pet--executable-find :and-return-value "/usr/bin/hatch")
(expect (pet-use-hatch-p) :to-be nil))

(it "should return nil if `hatch' is not found"
(spy-on 'pet--executable-find)
(expect (pet-use-hatch-p) :to-be nil)))

(describe "when the project has no configuration files"
(before-each
(spy-on 'pet-hatch)
(spy-on 'pet-pyproject))

(it "should return nil regardless of `hatch' availability"
(spy-on 'pet--executable-find :and-return-value "/usr/bin/hatch")
(expect (pet-use-hatch-p) :to-be nil))))


;; Local Variables:
;; eval: (buttercup-minor-mode 1)
;; End:
Loading