Repository: pypa/pipx
Pull request #1281 (2024-03-07 03:36:42)

Updated PR: Added --global option to perform actions for all users (#754)
- [x] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

## Summary of changes

Adds a `--global` option to perform actions for all users. This is a resubmission of PR  https://github.com/pypa/pipx/pull/1088 by https://github.com/haxwithaxe which I rebased on the latest `main`.

## Test plan

Passes your CI process on github (via your unaltered actions in my fork) apart from MacOS test which needs to be investigated. Help from your side is highly appreciated. Test run from my fork: https://github.com/Jendker/pipx/actions/runs/8182365101

To verify the changes do what they are supposed to

```
python3 src/pipx --global environment
```

To test all the relevant commands for regressions.

```
python3 src/pipx --global ensurepath
sudo python3 src/pipx --global install pycowsay
sudo python3 src/pipx --global reinstall pycowsay
sudo python3 src/pipx --global inject pycowsay black
sudo python3 src/pipx --global uninject pycowsay black
sudo python3 src/pipx --global runpip pycowsay freeze
sudo python3 src/pipx --global list
sudo python3 src/pipx --global uninstall pycowsay
python3 src/pipx environment
python3 src/pipx ensurepath
python3 src/pipx install pycowsay
python3 src/pipx reinstall pycowsay
python3 src/pipx inject pycowsay black
python3 src/pipx uninject pycowsay black
python3 src/pipx runpip pycowsay freeze
python3 src/pipx list
python3 src/pipx uninstall pycowsay
```

Jendker: Pinging @Gitznik who offered help in the original PR.
Jendker: Thank you for the review and all helpful comments! I had a look into https://pipx.pypa.io/stable/how-pipx-works/ but I am not sure if I will be able to adjust the doc without blurring everything given my limited knowledge of pipx codebase. Some help here from your side would be highly appreciated!

For now, I have updated the docs in docs/installation.md which already mention the env variables so I could add concise information regarding `--global` flag there.
chrysle: > I had a look into https://pipx.pypa.io/stable/how-pipx-works/ but I am not sure if I will be able to adjust the doc without blurring everything given my limited knowledge of pipx codebase. 

Probably adding a bullet point mentioning the `--global` flag and linking to the new paragraph under _Installation_ (which I think looks good) will be enough. This doc is also missing information on how to override the common paths, so providing the link should serve a dual purpose.
Jendker: Is there anything I can do to get the tests to pass on MacOS? I am not sure why I get permission denied accessing `/opt/pipx` on MacOS but it works fine on Ubuntu.
Gitznik: > Is there anything I can do to get the tests to pass on MacOS? I am not sure why I get permission denied accessing `/opt/pipx` on MacOS but it works fine on Ubuntu.

`/opt` does not seem to be a thing on MacOS. You can use [platformdirs](https://github.com/platformdirs/platformdirs) `site_data_dir` to generate the appropriate shared path instead I think. You can use it for both Linux and Mac, staying consistent with the current way of doing things (even though it may have to be changed during https://github.com/pypa/pipx/issues/1257).
chrysle: > You can use [platformdirs](https://github.com/platformdirs/platformdirs) `site_data_dir` to generate the appropriate shared path instead I think. You can use it for both Linux and Mac, staying consistent with the current way of doing things (even though it may have to be changed during #1257).

That sounds good for MacOS, but I think we should stick to `/opt/` on Linux, as I am fairly sure `/usr/local/share/` is mostly a location for system administrators to install their own software.


Gitznik: > That sounds good for MacOS, but I think we should stick to `/opt/` on Linux, as I am fairly sure `/usr/local/share/` is mostly a location for system administrators to install their own software.

There's a lot of discourse around it online (spend about 20 minutes yesterday reading conflicting opinions on that topic). From what I gathered either would be fine, really. BSD derivatives favor `usr/local/share` (as with MacOS, `/opt` is not really a thing there) while Linux itself slightly favors `/opt`. 

IMO - stick to the decision `platformdirs` has made, as we're not just supporting Linux - we're also supporting BSD derivatives. 

chrysle: > IMO - stick to the decision `platformdirs` has made, as we're not just supporting Linux - we're also supporting BSD derivatives.

Good, but if downstream packagers complain because they feel the need to patch this behaviour I'll direct them to you ;-)

Gitznik: > Good, but if downstream packagers complain because they feel the need to patch this behaviour I'll direct them to you ;-)

If you think that's a realistic scenario, let's go for `/opt/` on linux, and `/usr/local/share/` for Mac & BSD? We currently don't differentiate between those anywhere AFAIK, so some new logic will be required there.
Jendker: I tested it locally on my Mac machine and writing to /opt works both on Intel and ARM Mac but I have to run commands as a root user with `sudo`. I’m surprised that the tests are passing on Ubuntu without any `sudo` calls.
Jendker: To follow up on my last comment I just run tests in with python docker image (debian based) with a non-root user and I got very similar output like in MacOS tests:
```
________________________________________________________________________________________ test_fetch_missing_python _________________________________________________________________________________________

src = PosixPath('/tmp/tmp9nfdvti7/download/python'), dst = PosixPath('/opt/pipx/py/3.12'), copy_function = <function copy2 at 0x7f454efcdc60>

    def move(src, dst, copy_function=copy2):
        """Recursively move a file or directory to another location. This is
        similar to the Unix "mv" command. Return the file or directory's
        destination.

        If dst is an existing directory or a symlink to a directory, then src is
        moved inside that directory. The destination path in that directory must
        not already exist.

        If dst already exists but is not a directory, it may be overwritten
        depending on os.rename() semantics.

        If the destination is on our current filesystem, then rename() is used.
        Otherwise, src is copied to the destination and then removed. Symlinks are
        recreated under the new name if os.rename() fails because of cross
        filesystem renames.

        The optional `copy_function` argument is a callable that will be used
        to copy the source or it will be delegated to `copytree`.
        By default, copy2() is used, but any function that supports the same
        signature (like copy()) can be used.

        A lot more could be done here...  A look at a mv.c shows a lot of
        the issues this implementation glosses over.

        """
        sys.audit("shutil.move", src, dst)
        real_dst = dst
        if os.path.isdir(dst):
            if _samefile(src, dst) and not os.path.islink(src):
                # We might be on a case insensitive filesystem,
                # perform the rename anyway.
                os.rename(src, dst)
                return

            # Using _basename instead of os.path.basename is important, as we must
            # ignore any trailing slash to avoid the basename returning ''
            real_dst = os.path.join(dst, _basename(src))

            if os.path.exists(real_dst):
                raise Error("Destination path '%s' already exists" % real_dst)
        try:
>           os.rename(src, real_dst)
E           FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmp9nfdvti7/download/python' -> '/opt/pipx/py/3.12'

/usr/local/lib/python3.12/shutil.py:886: FileNotFoundError

During handling of the above exception, another exception occurred:

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f454caa10d0>, mocked_github_api = None

    def test_fetch_missing_python(monkeypatch, mocked_github_api):
        def which(name):
            return None

        monkeypatch.setattr(shutil, "which", which)

        major = sys.version_info.major
        minor = sys.version_info.minor
        target_python = f"{major}.{minor}"

        if target_python == "3.8":
            # 3.8 is not available in the standalone python project
            with pytest.raises(InterpreterResolutionError) as e:
                find_python_interpreter(target_python, fetch_missing_python=True)
                assert "not found" in str(e)
        else:
>           python_path = find_python_interpreter(target_python, fetch_missing_python=True)

tests/test_interpreter.py:191:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
src/pipx/interpreter.py:67: in find_python_interpreter
    standalone_executable = download_python_build_standalone(python_version)
src/pipx/standalone_python.py:90: in download_python_build_standalone
    shutil.move(extracted_dir, install_dir)
/usr/local/lib/python3.12/shutil.py:902: in move
    copytree(src, real_dst, copy_function=copy_function,
/usr/local/lib/python3.12/shutil.py:600: in copytree
    return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
/usr/local/lib/python3.12/shutil.py:498: in _copytree
    os.makedirs(dst, exist_ok=dirs_exist_ok)
<frozen os>:215: in makedirs
    ???
<frozen os>:215: in makedirs
    ???
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

name = '/opt/pipx', mode = 511, exist_ok = False

>   ???
E   PermissionError: [Errno 13] Permission denied: '/opt/pipx'
```
The same tests passes with a root user.

I'm checking for the core issue but from what it looks like it's not the problem of `/opt` path existance on MacOS as many global tests are passing apart from this single test.
Jendker: On a separate branch just to test what I said above I have added a call in the failing MacOS test to set paths to local (which resets what previous tests did) and now MacOS tests are passing.

Commit: https://github.com/Jendker/pipx/commit/969f0390df81197a99a871c32153a6355e79c65a
Test run: https://github.com/Jendker/pipx/actions/runs/8224676966

It doesn't solve the core issue with pytest, is only a workaround. I don't have enough expertise in pytest to understand why that's needed in the first place but if you think that this test workaround is good enough for now I'll add this commit to PR branch and I think we can merge.
Jendker: I rebased this branch on the latest origin/main.
Jendker: I just included the workaround commit for pytest issue on PR branch. The tests on MacOS will pass now. I think it would make sense to fix the pytest issue in another PR since the failing test shouldn't even be affected by the `--global` argument changes.
Gitznik: > I just included the workaround commit for pytest issue on PR branch. The tests on MacOS will pass now. I think it would make sense to fix the pytest core issue in another PR since the failing test shouldn't even be affected by the `--global` argument changes.

I reckon the tests leak into the next running test. The way the test suite is written, globals in the application persist across runs. Maybe you can fix it by calling `pipx.paths.ctx.make_local()` at the end of all the tests that use the `--global` flag, instead of the tests where it fails? Just to make sure you always leave the globals in a clean state?

Jendker: That's definitely better than what I had :) Done! Tests are passing: https://github.com/Jendker/pipx/actions/runs/8257157064
Jendker: I have tried moving the `make_local` into `conftest` but that isn't enough. Failing test: https://github.com/Jendker/pipx/actions/runs/8267713069/job/22618840110
On `rebase_main` (PR branch) I have added the [make_local on fixture cleanup](https://github.com/pypa/pipx/pull/1281/commits/a918febccd99053b3ba73287b4301b38295ee211) commit while still keeping the `make_local` at the end of all `--global` tests and that obviously still works.

Do you have any suggestions how to improve the situation?
Gitznik: > I have tried moving the `make_local` into `conftest` but that isn't enough. Failing test: https://github.com/Jendker/pipx/actions/runs/8267713069/job/22618840110 On `rebase_main` (PR branch) I have added the [make_local on fixture cleanup](https://github.com/pypa/pipx/pull/1281/commits/a918febccd99053b3ba73287b4301b38295ee211) commit while still keeping the `make_local` at the end of all `--global` tests and that obviously still works https://github.com/Jendker/pipx/actions/runs/8267933048.
> 
> Do you have any suggestions how to improve the situation?

`test_environment` `test_cli_global` does not use `pipx_temp_env`, so that will still leak into one other test. Maybe adding `pipx_temp_env` there will fix the tests?
Jendker: That's a good point but that's still something the developer needs to add to avoid tests leaking so there is no substantial difference between adding `make_local` or `pipx_temp_env` to each test so it doesn't fix the core issue. Please correct me if I'm missing something.
Gitznik: > That's a good point but that's still something the developer needs to add to avoid tests leaking so there is no substantial difference between adding `make_local` or `pipx_temp_env` to each test so it doesn't fix the core issue. Please correct me if I'm missing something.

Yes you're right. A lot of test have `pipx_temp_env` anyways, so having it there just allows you to not worry about `make_local` - most of the time. But yeah, having it in the tests that make use of the `--global` flag makes it very obvious that it needs to be done.

I'm fine with either version
Jendker: I have removed all the explicit `make_local` calls in the tests and used `pipx_temp_env` in all tests with `--global`. I had to update one of the tests to pass now. Please let me know if you have any suggestions.
Jendker: > Please add a quick note to the `ensurepath` docs that you need to run it with `--global` to add the global location to the `PATH`. Aside from that - looks good to me know. Great work :)

Done in [fbbdd4c](https://github.com/pypa/pipx/pull/1281/commits/fbbdd4cebcc50a23d553945aafc628812395559a) :)
mairas: I'm a bit late with the comment, but `/opt` is a thing on Macos, at least in practice. Specifically, Homebrew does install itself on `/opt/homebrew`. You need to create `/opt/pipx` with sudo and then set the permissions, but other than that, it works fine and I've already installed some packages there with the environment variable hacks described in Issue #754.

Homebrew used to install itself in `/usr/local` but switched the location some years ago. Don't remember the specifics but I think Macos wasn't happy with relaxing permisssions of `/usr/local` like Homebrew did. Risking a bit of cargo cult programming here, my preference would be to stick with `/opt/pipx`.
Jendker: If all looks good to you can this be merged? I am worried about the possible conflicts with other PR which may emerge if it doesn’t get merged into master some time soon ☺️

Patch:
diff --git a/README.md b/README.md
index 50db629..71c555b 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ _For comparison to other tools including pipsi, see
 ```
 brew install pipx
 pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions with --global argument
 ```
 
 Upgrade pipx with `brew update && brew upgrade pipx`.
@@ -49,6 +50,7 @@ Upgrade pipx with `brew update && brew upgrade pipx`.
 sudo apt update
 sudo apt install pipx
 pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions with --global argument
 ```
 
 - Fedora:
@@ -56,6 +58,7 @@ pipx ensurepath
 ```
 sudo dnf install pipx
 pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions with --global argument
 ```
 
 - Using `pip` on other distributions:
@@ -63,6 +66,7 @@ pipx ensurepath
 ```
 python3 -m pip install --user pipx
 python3 -m pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions with --global argument
 ```
 
 Upgrade pipx with `python3 -m pip install --user --upgrade pipx`.
diff --git a/changelog.d/754.feature.md b/changelog.d/754.feature.md
new file mode 100644
index 0000000..90bd30f
--- /dev/null
+++ b/changelog.d/754.feature.md
@@ -0,0 +1,3 @@
+Add `--global` option to `pipx` commands.
+
+This will run the action in a global scope and affect environment for all system users.
diff --git a/docs/examples.md b/docs/examples.md
index da88a1d..4c0c8fb 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -17,6 +17,7 @@ pipx install --include-deps jupyter
 pipx install --pip-args='--pre' poetry
 pipx install --pip-args='--index-url=<private-repo-host>:<private-repo-port> --trusted-host=<private-repo-host>:<private-repo-port>' private-repo-package
 pipx install --index-url https://test.pypi.org/simple/ --pip-args='--extra-index-url https://pypi.org/simple/' some-package
+pipx --global install pycowsay
 ```
 
 ## `pipx run` examples
diff --git a/docs/how-pipx-works.md b/docs/how-pipx-works.md
index c005ac2..2152454 100644
--- a/docs/how-pipx-works.md
+++ b/docs/how-pipx-works.md
@@ -16,6 +16,8 @@ When installing a package and its binaries on linux (`pipx install package`) pip
 - as long as `~/.local/bin/` is on your PATH, you can now invoke the new binaries globally
 - on operating systems which have the `man` command, as long as `~/.local/share/man` is a recognized search path of man,
   you can now view the new manual pages globally
+- adding `--global` flag to any `pipx` command will execute the action in global scope which will expose app to all
+  users - [reference](installation.md#global-installation). Note that this is not available on Windows.
 
 When running a binary (`pipx run BINARY`), pipx will
 
diff --git a/docs/installation.md b/docs/installation.md
index 805c8e8..c90e4ac 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -19,6 +19,7 @@ pipx works on macOS, linux, and Windows.
 ```
 brew install pipx
 pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions in global scope. See "Global installation" section below.
 ```
 
 ### On Linux:
@@ -29,6 +30,7 @@ pipx ensurepath
 sudo apt update
 sudo apt install pipx
 pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions in global scope. See "Global installation" section below.
 ```
 
 - Fedora:
@@ -36,6 +38,7 @@ pipx ensurepath
 ```
 sudo dnf install pipx
 pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions in global scope. See "Global installation" section below.
 ```
 
 - Using `pip` on other distributions:
@@ -43,6 +46,7 @@ pipx ensurepath
 ```
 python3 -m pip install --user pipx
 python3 -m pipx ensurepath
+sudo pipx ensurepath --global # optional to allow pipx actions in global scope. See "Global installation" section below.
 ```
 
 
@@ -149,6 +153,17 @@ sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin PIPX_MAN_DIR=/usr/local/sha
 > `user_data_dir()`, `user_cache_dir()` and `user_log_dir()` resolve to appropriate platform-specific user data, cache and log directories.
 > See the [platformdirs documentation](https://platformdirs.readthedocs.io/en/latest/api.html#platforms) for details.
 
+### Global installation
+
+Pipx also comes with a `--global` argument which helps to execute actions in global scope which exposes the app to
+all system users. By default the global binary location is set to `/usr/local/bin` and can be overridden with the
+environment variable `PIPX_GLOBAL_BIN_DIR`. Default global manual page location is `/usr/local/share/man`. This
+can be overridden with environment variable `PIPX_GLOBAL_MAN_DIR`. Finally, default global virtual environment location
+is `/opt/pipx`, can be overridden with environment variable `PIPX_GLOBAL_HOME`. Make sure to run `sudo pipx ensurepath --global`
+if you intend to use this feature.
+
+Note that the `--global` argument is not supported on Windows.
+
 ## Upgrade pipx
 
 On macOS:
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index a658369..e3651e5 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -13,9 +13,9 @@ from typing import Dict, List, Optional, Set, Tuple
 import userpath  # type: ignore
 from packaging.utils import canonicalize_name
 
-from pipx import constants
+from pipx import paths
 from pipx.colors import bold, red
-from pipx.constants import MAN_SECTIONS, PIPX_STANDALONE_PYTHON_CACHEDIR, WINDOWS
+from pipx.constants import MAN_SECTIONS, WINDOWS
 from pipx.emojis import hazard, stars
 from pipx.package_specifier import parse_specifier_for_install, valid_pypi_name
 from pipx.pipx_metadata_file import PackageInfo
@@ -231,7 +231,7 @@ def get_venv_summary(
 
     exposed_app_paths = get_exposed_paths_for_package(
         venv.bin_path,
-        constants.LOCAL_BIN_DIR,
+        paths.ctx.bin_dir,
         [add_suffix(app, package_metadata.suffix) for app in apps],
     )
     exposed_binary_names = sorted(p.name for p in exposed_app_paths)
@@ -242,7 +242,7 @@ def get_venv_summary(
     for man_section in MAN_SECTIONS:
         exposed_man_paths |= get_exposed_man_paths_for_package(
             venv.man_path / man_section,
-            constants.LOCAL_MAN_DIR / man_section,
+            paths.ctx.man_dir / man_section,
             man_pages,
         )
     exposed_man_pages = sorted(str(Path(p.parent.name) / p.name) for p in exposed_man_paths)
@@ -252,7 +252,7 @@ def get_venv_summary(
     python_version = venv.pipx_metadata.python_version if venv.pipx_metadata.python_version is not None else ""
     source_interpreter = venv.pipx_metadata.source_interpreter
     is_standalone = (
-        str(source_interpreter).startswith(str(PIPX_STANDALONE_PYTHON_CACHEDIR.resolve()))
+        str(source_interpreter).startswith(str(paths.ctx.standalone_python_cachedir.resolve()))
         if source_interpreter
         else False
     )
diff --git a/src/pipx/commands/ensure_path.py b/src/pipx/commands/ensure_path.py
index c24b56b..6bbfcca 100644
--- a/src/pipx/commands/ensure_path.py
+++ b/src/pipx/commands/ensure_path.py
@@ -6,7 +6,7 @@ from typing import Optional, Tuple
 
 import userpath  # type: ignore
 
-from pipx import constants
+from pipx import paths
 from pipx.constants import EXIT_CODE_OK, ExitCode
 from pipx.emojis import hazard, stars
 from pipx.util import pipx_wrap
@@ -97,7 +97,7 @@ def ensure_path(location: Path, *, force: bool) -> Tuple[bool, bool]:
 
 def ensure_pipx_paths(force: bool) -> ExitCode:
     """Returns pipx exit code."""
-    bin_paths = {constants.LOCAL_BIN_DIR}
+    bin_paths = {paths.ctx.bin_dir}
 
     pipx_user_bin_path = get_pipx_user_bin_path()
     if pipx_user_bin_path is not None:
diff --git a/src/pipx/commands/environment.py b/src/pipx/commands/environment.py
index 06b0cb9..cca49a1 100644
--- a/src/pipx/commands/environment.py
+++ b/src/pipx/commands/environment.py
@@ -1,18 +1,7 @@
 import os
 
-from pipx.constants import (
-    EXIT_CODE_OK,
-    LOCAL_BIN_DIR,
-    LOCAL_MAN_DIR,
-    PIPX_HOME,
-    PIPX_LOCAL_VENVS,
-    PIPX_LOG_DIR,
-    PIPX_SHARED_LIBS,
-    PIPX_STANDALONE_PYTHON_CACHEDIR,
-    PIPX_TRASH_DIR,
-    PIPX_VENV_CACHEDIR,
-    ExitCode,
-)
+from pipx import paths
+from pipx.constants import EXIT_CODE_OK, ExitCode
 from pipx.emojis import EMOJI_SUPPORT
 from pipx.interpreter import DEFAULT_PYTHON
 from pipx.util import PipxError
@@ -30,15 +19,15 @@ def environment(value: str) -> ExitCode:
         "USE_EMOJI",
     ]
     derived_values = {
-        "PIPX_HOME": PIPX_HOME,
-        "PIPX_BIN_DIR": LOCAL_BIN_DIR,
-        "PIPX_MAN_DIR": LOCAL_MAN_DIR,
-        "PIPX_SHARED_LIBS": PIPX_SHARED_LIBS,
-        "PIPX_LOCAL_VENVS": PIPX_LOCAL_VENVS,
-        "PIPX_LOG_DIR": PIPX_LOG_DIR,
-        "PIPX_TRASH_DIR": PIPX_TRASH_DIR,
-        "PIPX_VENV_CACHEDIR": PIPX_VENV_CACHEDIR,
-        "PIPX_STANDALONE_PYTHON_CACHEDIR": PIPX_STANDALONE_PYTHON_CACHEDIR,
+        "PIPX_HOME": paths.ctx.home,
+        "PIPX_BIN_DIR": paths.ctx.bin_dir,
+        "PIPX_MAN_DIR": paths.ctx.man_dir,
+        "PIPX_SHARED_LIBS": paths.ctx.shared_libs,
+        "PIPX_LOCAL_VENVS": paths.ctx.venvs,
+        "PIPX_LOG_DIR": paths.ctx.logs,
+        "PIPX_TRASH_DIR": paths.ctx.trash,
+        "PIPX_VENV_CACHEDIR": paths.ctx.venv_cache,
+        "PIPX_STANDALONE_PYTHON_CACHEDIR": paths.ctx.standalone_python_cachedir,
         "PIPX_DEFAULT_PYTHON": DEFAULT_PYTHON,
         "USE_EMOJI": str(EMOJI_SUPPORT).lower(),
     }
diff --git a/src/pipx/commands/inject.py b/src/pipx/commands/inject.py
index 916f35f..ab02820 100644
--- a/src/pipx/commands/inject.py
+++ b/src/pipx/commands/inject.py
@@ -3,7 +3,7 @@ import sys
 from pathlib import Path
 from typing import List, Optional
 
-from pipx import constants
+from pipx import paths
 from pipx.colors import bold
 from pipx.commands.common import package_name_from_spec, run_post_install_actions
 from pipx.constants import EXIT_CODE_INJECT_ERROR, EXIT_CODE_OK, ExitCode
@@ -71,8 +71,8 @@ def inject_dep(
         run_post_install_actions(
             venv,
             package_name,
-            constants.LOCAL_BIN_DIR,
-            constants.LOCAL_MAN_DIR,
+            paths.ctx.bin_dir,
+            paths.ctx.man_dir,
             venv_dir,
             include_dependencies,
             force=force,
diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py
index 860b3f2..2d36dec 100644
--- a/src/pipx/commands/install.py
+++ b/src/pipx/commands/install.py
@@ -1,7 +1,7 @@
 from pathlib import Path
 from typing import List, Optional
 
-from pipx import constants
+from pipx import paths
 from pipx.commands.common import package_name_from_spec, run_post_install_actions
 from pipx.constants import (
     EXIT_CODE_INSTALL_VENV_EXISTS,
@@ -46,7 +46,7 @@ def install(
 
     for package_name, package_spec in zip(package_names, package_specs):
         if venv_dir is None:
-            venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
+            venv_container = VenvContainer(paths.ctx.venvs)
             venv_dir = venv_container.get_venv_dir(f"{package_name}{suffix}")
 
         try:
diff --git a/src/pipx/commands/interpreter.py b/src/pipx/commands/interpreter.py
index 6181dbe..eaa28eb 100644
--- a/src/pipx/commands/interpreter.py
+++ b/src/pipx/commands/interpreter.py
@@ -1,14 +1,14 @@
 from pathlib import Path
 from typing import List
 
-from pipx import constants
+from pipx import constants, paths
 from pipx.pipx_metadata_file import PipxMetadata
 from pipx.util import is_paths_relative, rmdir
 from pipx.venv import Venv, VenvContainer
 
 
 def get_installed_standalone_interpreters() -> List[Path]:
-    return [python_dir for python_dir in constants.PIPX_STANDALONE_PYTHON_CACHEDIR.iterdir() if python_dir.is_dir()]
+    return [python_dir for python_dir in paths.ctx.standalone_python_cachedir.iterdir() if python_dir.is_dir()]
 
 
 def get_venvs_using_standalone_interpreter(venv_container: VenvContainer) -> List[Venv]:
@@ -35,7 +35,7 @@ def list_interpreters(
     interpreters = get_installed_standalone_interpreters()
     venvs = get_venvs_using_standalone_interpreter(venv_container)
     output: list[str] = []
-    output.append(f"Standalone interpreters are in {constants.PIPX_STANDALONE_PYTHON_CACHEDIR}")
+    output.append(f"Standalone interpreters are in {paths.ctx.standalone_python_cachedir}")
     for interpreter in interpreters:
         output.append(f"Python {interpreter.name}")
         used_in = get_interpreter_users(interpreter, venvs)
diff --git a/src/pipx/commands/list_packages.py b/src/pipx/commands/list_packages.py
index dba22e7..be75e1f 100644
--- a/src/pipx/commands/list_packages.py
+++ b/src/pipx/commands/list_packages.py
@@ -4,7 +4,7 @@ import sys
 from pathlib import Path
 from typing import Any, Collection, Dict, Tuple
 
-from pipx import constants, shared_libs
+from pipx import paths, shared_libs
 from pipx.colors import bold
 from pipx.commands.common import VenvProblems, get_venv_summary, venv_health_check
 from pipx.constants import EXIT_CODE_LIST_PROBLEM, EXIT_CODE_OK, ExitCode
@@ -45,8 +45,8 @@ def list_short(venv_dirs: Collection[Path]) -> VenvProblems:
 
 def list_text(venv_dirs: Collection[Path], include_injected: bool, venv_root_dir: str) -> VenvProblems:
     print(f"venvs are in {bold(venv_root_dir)}")
-    print(f"apps are exposed on your $PATH at {bold(str(constants.LOCAL_BIN_DIR))}")
-    print(f"manual pages are exposed at {bold(str(constants.LOCAL_MAN_DIR))}")
+    print(f"apps are exposed on your $PATH at {bold(str(paths.ctx.bin_dir))}")
+    print(f"manual pages are exposed at {bold(str(paths.ctx.man_dir))}")
 
     all_venv_problems = VenvProblems()
     for venv_dir in venv_dirs:
diff --git a/src/pipx/commands/run.py b/src/pipx/commands/run.py
index 1e036de..8b7c255 100644
--- a/src/pipx/commands/run.py
+++ b/src/pipx/commands/run.py
@@ -12,7 +12,7 @@ from typing import List, NoReturn, Optional, Union
 
 from packaging.requirements import InvalidRequirement, Requirement
 
-from pipx import constants
+from pipx import paths
 from pipx.commands.common import package_name_from_spec
 from pipx.constants import TEMP_VENV_EXPIRATION_THRESHOLD_DAYS, WINDOWS
 from pipx.emojis import hazard
@@ -288,7 +288,7 @@ def _get_temporary_venv_path(requirements: List[str], python: str, pip_args: Lis
     m.update("".join(pip_args).encode())
     m.update("".join(venv_args).encode())
     venv_folder_name = m.hexdigest()[:15]  # 15 chosen arbitrarily
-    return Path(constants.PIPX_VENV_CACHEDIR) / venv_folder_name
+    return Path(paths.ctx.venv_cache) / venv_folder_name
 
 
 def _is_temporary_venv_expired(venv_dir: Path) -> bool:
@@ -308,7 +308,7 @@ def _prepare_venv_cache(venv: Venv, bin_path: Optional[Path], use_cache: bool) -
 
 
 def _remove_all_expired_venvs() -> None:
-    for venv_dir in Path(constants.PIPX_VENV_CACHEDIR).iterdir():
+    for venv_dir in Path(paths.ctx.venv_cache).iterdir():
         if _is_temporary_venv_expired(venv_dir):
             logger.info(f"Removing expired venv {str(venv_dir)}")
             rmdir(venv_dir)
diff --git a/src/pipx/commands/upgrade.py b/src/pipx/commands/upgrade.py
index 5cc23f1..1a95e27 100644
--- a/src/pipx/commands/upgrade.py
+++ b/src/pipx/commands/upgrade.py
@@ -3,7 +3,7 @@ import os
 from pathlib import Path
 from typing import List, Optional, Sequence
 
-from pipx import commands, constants
+from pipx import commands, paths
 from pipx.colors import bold, red
 from pipx.commands.common import expose_resources_globally
 from pipx.constants import EXIT_CODE_OK, ExitCode
@@ -50,24 +50,24 @@ def _upgrade_package(
     if package_metadata.include_apps:
         expose_resources_globally(
             "app",
-            constants.LOCAL_BIN_DIR,
+            paths.ctx.bin_dir,
             package_metadata.app_paths,
             force=force,
             suffix=package_metadata.suffix,
         )
-        expose_resources_globally("man", constants.LOCAL_MAN_DIR, package_metadata.man_paths, force=force)
+        expose_resources_globally("man", paths.ctx.man_dir, package_metadata.man_paths, force=force)
 
     if package_metadata.include_dependencies:
         for _, app_paths in package_metadata.app_paths_of_dependencies.items():
             expose_resources_globally(
                 "app",
-                constants.LOCAL_BIN_DIR,
+                paths.ctx.bin_dir,
                 app_paths,
                 force=force,
                 suffix=package_metadata.suffix,
             )
         for _, man_paths in package_metadata.man_paths_of_dependencies.items():
-            expose_resources_globally("man", constants.LOCAL_MAN_DIR, man_paths, force=force)
+            expose_resources_globally("man", paths.ctx.man_dir, man_paths, force=force)
 
     if old_version == new_version:
         if upgrading_all:
@@ -113,8 +113,8 @@ def _upgrade_venv(
                 venv_args=[],
                 package_names=None,
                 package_specs=[str(venv_dir).split(os.path.sep)[-1]],
-                local_bin_dir=constants.LOCAL_BIN_DIR,
-                local_man_dir=constants.LOCAL_MAN_DIR,
+                local_bin_dir=paths.ctx.bin_dir,
+                local_man_dir=paths.ctx.man_dir,
                 python=python,
                 pip_args=pip_args,
                 verbose=verbose,
diff --git a/src/pipx/constants.py b/src/pipx/constants.py
index 2498790..1348524 100644
--- a/src/pipx/constants.py
+++ b/src/pipx/constants.py
@@ -1,48 +1,15 @@
 import os
 import sys
 import sysconfig
-from pathlib import Path
 from textwrap import dedent
-from typing import NewType, Optional
+from typing import NewType
 
-from platformdirs import user_cache_path, user_data_path, user_log_path
-
-
-def load_dir_from_environ(dir_name: str, default: Path) -> Path:
-    env = os.environ.get(dir_name, default)
-    return Path(os.path.expanduser(env)).resolve()
-
-
-DEFAULT_PIPX_HOME = user_data_path("pipx")
-FALLBACK_PIPX_HOME = Path.home() / ".local/pipx"
-DEFAULT_PIPX_BIN_DIR = Path.home() / ".local/bin"
-DEFAULT_PIPX_MAN_DIR = Path.home() / ".local/share/man"
-MAN_SECTIONS = ["man%d" % i for i in range(1, 10)]
-
-if FALLBACK_PIPX_HOME.exists() or os.environ.get("PIPX_HOME") is not None:
-    PIPX_HOME = load_dir_from_environ("PIPX_HOME", FALLBACK_PIPX_HOME)
-    PIPX_LOCAL_VENVS = PIPX_HOME / "venvs"
-    PIPX_STANDALONE_PYTHON_CACHEDIR = PIPX_HOME / "py"
-    PIPX_LOG_DIR = PIPX_HOME / "logs"
-    DEFAULT_PIPX_SHARED_LIBS = PIPX_HOME / "shared"
-    PIPX_TRASH_DIR = PIPX_HOME / ".trash"
-    PIPX_VENV_CACHEDIR = PIPX_HOME / ".cache"
-else:
-    PIPX_HOME = DEFAULT_PIPX_HOME
-    PIPX_LOCAL_VENVS = PIPX_HOME / "venvs"
-    PIPX_STANDALONE_PYTHON_CACHEDIR = PIPX_HOME / "py"
-    PIPX_LOG_DIR = user_log_path("pipx")
-    DEFAULT_PIPX_SHARED_LIBS = PIPX_HOME / "shared"
-    PIPX_TRASH_DIR = PIPX_HOME / "trash"
-    PIPX_VENV_CACHEDIR = user_cache_path("pipx")
-
-PIPX_SHARED_LIBS = load_dir_from_environ("PIPX_SHARED_LIBS", DEFAULT_PIPX_SHARED_LIBS)
 PIPX_SHARED_PTH = "pipx_shared.pth"
-LOCAL_BIN_DIR = load_dir_from_environ("PIPX_BIN_DIR", DEFAULT_PIPX_BIN_DIR)
-LOCAL_MAN_DIR = load_dir_from_environ("PIPX_MAN_DIR", DEFAULT_PIPX_MAN_DIR)
-FETCH_MISSING_PYTHON = os.environ.get("PIPX_FETCH_MISSING_PYTHON", False)
 TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14
 MINIMUM_PYTHON_VERSION = "3.8"
+MAN_SECTIONS = ["man%d" % i for i in range(1, 10)]
+FETCH_MISSING_PYTHON = os.environ.get("PIPX_FETCH_MISSING_PYTHON", False)
+
 
 ExitCode = NewType("ExitCode", int)
 # pipx shell exit codes
@@ -57,8 +24,6 @@ EXIT_CODE_REINSTALL_VENV_NONEXISTENT = ExitCode(1)
 EXIT_CODE_REINSTALL_INVALID_PYTHON = ExitCode(1)
 EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND = ExitCode(1)
 
-pipx_log_file: Optional[Path] = None
-
 
 def is_windows() -> bool:
     return sys.platform == "win32"
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 9e43784..2d6922d 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -19,8 +19,7 @@ import argcomplete
 import platformdirs
 from packaging.utils import canonicalize_name
 
-import pipx.constants
-from pipx import commands, constants
+from pipx import commands, constants, paths
 from pipx.animate import hide_cursor, show_cursor
 from pipx.colors import bold, green
 from pipx.constants import (
@@ -76,9 +75,9 @@ PIPX_DESCRIPTION = textwrap.dedent(
     Binaries can either be installed globally into isolated Virtual Environments
     or run directly in a temporary Virtual Environment.
 
-    Virtual Environment location is {str(constants.PIPX_LOCAL_VENVS)}.
-    Symlinks to apps are placed in {str(constants.LOCAL_BIN_DIR)}.
-    Symlinks to manual pages are placed in {str(constants.LOCAL_MAN_DIR)}.
+    Virtual Environment location is {str(paths.ctx.venvs)}.
+    Symlinks to apps are placed in {str(paths.ctx.bin_dir)}.
+    Symlinks to manual pages are placed in {str(paths.ctx.man_dir)}.
 
     """
 )
@@ -105,7 +104,7 @@ INSTALL_DESCRIPTION = textwrap.dedent(
     accessible on your $PATH. The package's manual pages installed in
     share/man/man[1-9] can be viewed with man on an operating system where
     it is available and the path in the environment variable `PIPX_MAN_DIR`
-    (default: {constants.DEFAULT_PIPX_MAN_DIR}) is in the man search path
+    (default: {paths.DEFAULT_PIPX_MAN_DIR}) is in the man search path
     ($MANPATH).
 
     The result: apps you can run from anywhere, located in packages
@@ -122,14 +121,14 @@ INSTALL_DESCRIPTION = textwrap.dedent(
 
     The PACKAGE_SPEC argument is passed directly to `pip install`.
 
-    The default virtual environment location is {constants.DEFAULT_PIPX_HOME}
+    The default virtual environment location is {paths.DEFAULT_PIPX_HOME}
     and can be overridden by setting the environment variable `PIPX_HOME`
     (Virtual Environments will be installed to `$PIPX_HOME/venvs`).
 
-    The default app location is {constants.DEFAULT_PIPX_BIN_DIR} and can be
+    The default app location is {paths.DEFAULT_PIPX_BIN_DIR} and can be
     overridden by setting the environment variable `PIPX_BIN_DIR`.
 
-    The default manual pages location is {constants.DEFAULT_PIPX_MAN_DIR} and
+    The default manual pages location is {paths.DEFAULT_PIPX_MAN_DIR} and
     can be overridden by setting the environment variable `PIPX_MAN_DIR`.
 
     The default python executable used to install a package is
@@ -177,10 +176,12 @@ def get_venv_args(parsed_args: Dict[str, str]) -> List[str]:
 
 def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.ArgumentParser]) -> ExitCode:  # noqa: C901
     verbose = args.verbose if "verbose" in args else False
+    if not constants.WINDOWS and args.is_global:
+        paths.ctx.make_global()
     pip_args = get_pip_args(vars(args))
     venv_args = get_venv_args(vars(args))
 
-    venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
+    venv_container = VenvContainer(paths.ctx.venvs)
 
     if "package" in args:
         package = args.package
@@ -232,8 +233,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
             None,
             None,
             args.package_spec,
-            constants.LOCAL_BIN_DIR,
-            constants.LOCAL_MAN_DIR,
+            paths.ctx.bin_dir,
+            paths.ctx.man_dir,
             args.python,
             pip_args,
             venv_args,
@@ -260,8 +261,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
         return commands.uninject(
             venv_dir,
             args.dependencies,
-            local_bin_dir=constants.LOCAL_BIN_DIR,
-            local_man_dir=constants.LOCAL_MAN_DIR,
+            local_bin_dir=paths.ctx.bin_dir,
+            local_man_dir=paths.ctx.man_dir,
             leave_deps=args.leave_deps,
             verbose=verbose,
         )
@@ -302,22 +303,27 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
         else:
             raise PipxError(f"Unknown interpreter command {args.interpreter_command}")
     elif args.command == "uninstall":
-        return commands.uninstall(venv_dir, constants.LOCAL_BIN_DIR, constants.LOCAL_MAN_DIR, verbose)
+        return commands.uninstall(venv_dir, paths.ctx.bin_dir, paths.ctx.man_dir, verbose)
     elif args.command == "uninstall-all":
-        return commands.uninstall_all(venv_container, constants.LOCAL_BIN_DIR, constants.LOCAL_MAN_DIR, verbose)
+        return commands.uninstall_all(
+            venv_container,
+            paths.ctx.bin_dir,
+            paths.ctx.man_dir,
+            verbose,
+        )
     elif args.command == "reinstall":
         return commands.reinstall(
             venv_dir=venv_dir,
-            local_bin_dir=constants.LOCAL_BIN_DIR,
-            local_man_dir=constants.LOCAL_MAN_DIR,
+            local_bin_dir=paths.ctx.bin_dir,
+            local_man_dir=paths.ctx.man_dir,
             python=args.python,
             verbose=verbose,
         )
     elif args.command == "reinstall-all":
         return commands.reinstall_all(
             venv_container,
-            constants.LOCAL_BIN_DIR,
-            constants.LOCAL_MAN_DIR,
+            paths.ctx.bin_dir,
+            paths.ctx.man_dir,
             args.python,
             verbose,
             skip=skip_list,
@@ -745,7 +751,7 @@ def _add_environment(subparsers: argparse._SubParsersAction, shared_parser: argp
 
 
 def get_command_parser() -> Tuple[argparse.ArgumentParser, Dict[str, argparse.ArgumentParser]]:
-    venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
+    venv_container = VenvContainer(paths.ctx.venvs)
 
     completer_venvs = InstalledVenvsCompleter(venv_container)
 
@@ -800,6 +806,13 @@ def get_command_parser() -> Tuple[argparse.ArgumentParser, Dict[str, argparse.Ar
     _add_ensurepath(subparsers, shared_parser)
     _add_environment(subparsers, shared_parser)
 
+    if not constants.WINDOWS:
+        parser.add_argument(
+            "--global",
+            action="store_true",
+            dest="is_global",
+            help="Perform action globally for all users.",
+        )
     parser.add_argument("--version", action="store_true", help="Print version and exit")
     subparsers.add_parser(
         "completions",
@@ -822,7 +835,7 @@ def delete_oldest_logs(file_list: List[Path], keep_number: int) -> None:
 
 def _setup_log_file(pipx_log_dir: Optional[Path] = None) -> Path:
     max_logs = 10
-    pipx_log_dir = pipx_log_dir or constants.PIPX_LOG_DIR
+    pipx_log_dir = pipx_log_dir or paths.ctx.logs
     # don't use utils.mkdir, to prevent emission of log message
     pipx_log_dir.mkdir(parents=True, exist_ok=True)
 
@@ -850,7 +863,7 @@ def setup_log_file() -> Path:
 
 def setup_logging(verbose: int) -> None:
     pipx_str = bold(green("pipx >")) if sys.stdout.isatty() else "pipx >"
-    pipx.constants.pipx_log_file = setup_log_file()
+    paths.ctx.log_file = setup_log_file()
 
     # Determine logging level, a value between 0 and 50
     level_number = min(max(0, logging.WARNING - 10 * verbose), 50)
@@ -886,7 +899,7 @@ def setup_logging(verbose: int) -> None:
             "file": {
                 "class": "logging.FileHandler",
                 "formatter": "file",
-                "filename": str(pipx.constants.pipx_log_file),
+                "filename": str(paths.ctx.log_file),
                 "encoding": "utf-8",
                 "level": "DEBUG",
             },
@@ -911,15 +924,15 @@ def setup(args: argparse.Namespace) -> None:
     logger.info(f"pipx version is {__version__}")
     logger.info(f"Default python interpreter is '{DEFAULT_PYTHON}'")
 
-    mkdir(constants.PIPX_LOCAL_VENVS)
-    mkdir(constants.LOCAL_BIN_DIR)
-    mkdir(constants.LOCAL_MAN_DIR)
-    mkdir(constants.PIPX_VENV_CACHEDIR)
-    mkdir(constants.PIPX_STANDALONE_PYTHON_CACHEDIR)
+    mkdir(paths.ctx.venvs)
+    mkdir(paths.ctx.bin_dir)
+    mkdir(paths.ctx.man_dir)
+    mkdir(paths.ctx.venv_cache)
+    mkdir(paths.ctx.standalone_python_cachedir)
 
     for cachedir in [
-        constants.PIPX_VENV_CACHEDIR,
-        constants.PIPX_STANDALONE_PYTHON_CACHEDIR,
+        paths.ctx.venv_cache,
+        paths.ctx.standalone_python_cachedir,
     ]:
         cachedir_tag = cachedir / "CACHEDIR.TAG"
         if not cachedir_tag.exists():
@@ -933,9 +946,9 @@ def setup(args: argparse.Namespace) -> None:
             with open(cachedir_tag, "w") as file:
                 file.write(signature)
 
-    rmdir(constants.PIPX_TRASH_DIR, False)
+    rmdir(paths.ctx.trash, False)
 
-    old_pipx_venv_location = constants.PIPX_LOCAL_VENVS / "pipx-app"
+    old_pipx_venv_location = paths.ctx.venvs / "pipx-app"
     if old_pipx_venv_location.exists():
         logger.warning(
             pipx_wrap(
diff --git a/src/pipx/paths.py b/src/pipx/paths.py
new file mode 100644
index 0000000..7f79000
--- /dev/null
+++ b/src/pipx/paths.py
@@ -0,0 +1,93 @@
+import os
+from pathlib import Path
+from typing import Optional, Union
+
+from platformdirs import user_cache_path, user_data_path, user_log_path
+
+DEFAULT_PIPX_HOME = user_data_path("pipx")
+FALLBACK_PIPX_HOME = Path.home() / ".local/pipx"
+DEFAULT_PIPX_BIN_DIR = Path.home() / ".local/bin"
+DEFAULT_PIPX_MAN_DIR = Path.home() / ".local/share/man"
+DEFAULT_PIPX_GLOBAL_HOME = "/opt/pipx"
+DEFAULT_PIPX_GLOBAL_BIN_DIR = "/usr/local/bin"
+DEFAULT_PIPX_GLOBAL_MAN_DIR = "/usr/local/share/man"
+
+
+def get_expanded_environ(env_name: str) -> Optional[Path]:
+    val = os.environ.get(env_name)
+    if val is not None:
+        return Path(val).expanduser().resolve()
+    return val
+
+
+class _PathContext:
+    _base_home: Optional[Union[Path, str]] = get_expanded_environ("PIPX_HOME")
+    _base_bin: Optional[Union[Path, str]] = get_expanded_environ("PIPX_BIN_DIR")
+    _base_man: Optional[Union[Path, str]] = get_expanded_environ("PIPX_MAN_DIR")
+    _base_shared_libs: Optional[Union[Path, str]] = get_expanded_environ("PIPX_SHARED_LIBS")
+    _fallback_home: Path = Path.home() / ".local/pipx"
+    _home_exists: bool = _base_home is not None or _fallback_home.exists()
+    log_file: Optional[Path] = None
+
+    @property
+    def venvs(self) -> Path:
+        return self.home / "venvs"
+
+    @property
+    def logs(self) -> Path:
+        if self._home_exists:
+            return self.home / "logs"
+        return user_log_path("pipx")
+
+    @property
+    def trash(self) -> Path:
+        if self._home_exists:
+            return self.home / ".trash"
+        return self.home / "trash"
+
+    @property
+    def venv_cache(self) -> Path:
+        if self._home_exists:
+            return self.home / ".cache"
+        return user_cache_path("pipx")
+
+    @property
+    def bin_dir(self) -> Path:
+        return Path(self._base_bin or DEFAULT_PIPX_BIN_DIR).resolve()
+
+    @property
+    def man_dir(self) -> Path:
+        return Path(self._base_man or DEFAULT_PIPX_MAN_DIR).resolve()
+
+    @property
+    def home(self) -> Path:
+        if self._base_home:
+            home = Path(self._base_home)
+        elif self._fallback_home.exists():
+            home = self._fallback_home
+        else:
+            home = Path(DEFAULT_PIPX_HOME)
+        return home.resolve()
+
+    @property
+    def shared_libs(self) -> Path:
+        return Path(self._base_shared_libs or self.home / "shared").resolve()
+
+    def make_local(self) -> None:
+        self._base_home = get_expanded_environ("PIPX_HOME")
+        self._base_bin = get_expanded_environ("PIPX_BIN_DIR")
+        self._base_man = get_expanded_environ("PIPX_MAN_DIR")
+        self._home_exists = self._base_home is not None or self._fallback_home.exists()
+
+    def make_global(self) -> None:
+        self._base_home = get_expanded_environ("PIPX_GLOBAL_HOME") or DEFAULT_PIPX_GLOBAL_HOME
+        self._base_bin = get_expanded_environ("PIPX_GLOBAL_BIN_DIR") or DEFAULT_PIPX_GLOBAL_BIN_DIR
+        self._base_man = get_expanded_environ("PIPX_GLOBAL_MAN_DIR") or DEFAULT_PIPX_GLOBAL_MAN_DIR
+        self._home_exists = self._base_home is not None or self._fallback_home.exists()
+
+    @property
+    def standalone_python_cachedir(self) -> Path:
+        return self.home / "py"
+
+
+ctx = _PathContext()
diff --git a/src/pipx/shared_libs.py b/src/pipx/shared_libs.py
index 51a9bf6..bd64e96 100644
--- a/src/pipx/shared_libs.py
+++ b/src/pipx/shared_libs.py
@@ -4,7 +4,7 @@ import time
 from pathlib import Path
 from typing import List, Optional
 
-from pipx import constants
+from pipx import paths
 from pipx.animate import animate
 from pipx.constants import WINDOWS
 from pipx.interpreter import DEFAULT_PYTHON
@@ -23,7 +23,7 @@ SHARED_LIBS_MAX_AGE_SEC = datetime.timedelta(days=30).total_seconds()
 
 class _SharedLibs:
     def __init__(self) -> None:
-        self.root = constants.PIPX_SHARED_LIBS
+        self.root = paths.ctx.shared_libs
         self.bin_path, self.python_path, self.man_path = get_venv_paths(self.root)
         self.pip_path = self.bin_path / ("pip" if not WINDOWS else "pip.exe")
         # i.e. bin_path is ~/.local/share/pipx/shared/bin
diff --git a/src/pipx/standalone_python.py b/src/pipx/standalone_python.py
index 346203f..8d772f5 100644
--- a/src/pipx/standalone_python.py
+++ b/src/pipx/standalone_python.py
@@ -13,7 +13,7 @@ from pathlib import Path
 from typing import Any, Dict, List
 from urllib.request import urlopen
 
-from pipx import constants
+from pipx import constants, paths
 from pipx.animate import animate
 from pipx.util import PipxError
 
@@ -58,7 +58,7 @@ def download_python_build_standalone(python_version: str):
     # we'll convert it to a bare version number
     python_version = re.sub(r"[c]?python", "", python_version)
 
-    install_dir = constants.PIPX_STANDALONE_PYTHON_CACHEDIR / python_version
+    install_dir = paths.ctx.standalone_python_cachedir / python_version
     installed_python = install_dir / "python.exe" if constants.WINDOWS else install_dir / "bin" / "python3"
 
     if installed_python.exists():
@@ -125,7 +125,7 @@ def _unpack(full_version, download_link, archive: Path, download_dir: Path):
 def get_or_update_index():
     """Get or update the index of available python builds from
     the python-build-standalone repository."""
-    index_file = constants.PIPX_STANDALONE_PYTHON_CACHEDIR / "index.json"
+    index_file = paths.ctx.standalone_python_cachedir / "index.json"
     if index_file.exists():
         index = json.loads(index_file.read_text())
         # update index after 30 days
diff --git a/src/pipx/util.py b/src/pipx/util.py
index de89c93..ed49a52 100644
--- a/src/pipx/util.py
+++ b/src/pipx/util.py
@@ -21,9 +21,9 @@ from typing import (
     Union,
 )
 
-import pipx.constants
+from pipx import paths
 from pipx.animate import show_cursor
-from pipx.constants import MINGW, PIPX_TRASH_DIR, WINDOWS
+from pipx.constants import MINGW, WINDOWS
 
 logger = logging.getLogger(__name__)
 
@@ -42,10 +42,10 @@ class RelevantSearch(NamedTuple):
 
 
 def _get_trash_file(path: Path) -> Path:
-    if not PIPX_TRASH_DIR.is_dir():
-        PIPX_TRASH_DIR.mkdir()
+    if not paths.ctx.trash.is_dir():
+        paths.ctx.trash.mkdir()
     prefix = "".join(random.choices(string.ascii_lowercase, k=8))
-    return PIPX_TRASH_DIR / f"{prefix}.{path.name}"
+    return paths.ctx.trash / f"{prefix}.{path.name}"
 
 
 def rmdir(path: Path, safe_rm: bool = True) -> None:
@@ -330,9 +330,9 @@ def subprocess_post_check_handle_pip_error(
     if completed_process.returncode:
         logger.info(f"{' '.join(completed_process.args)!r} failed")
         # Save STDOUT and STDERR to file in pipx/logs/
-        if pipx.constants.pipx_log_file is None:
+        if paths.ctx.log_file is None:
             raise PipxError("Pipx internal error: No log_file present.")
-        pip_error_file = pipx.constants.pipx_log_file.parent / (pipx.constants.pipx_log_file.stem + "_pip_errors.log")
+        pip_error_file = paths.ctx.log_file.parent / (paths.ctx.log_file.stem + "_pip_errors.log")
         with pip_error_file.open("w", encoding="utf-8") as pip_error_fh:
             print("PIP STDOUT", file=pip_error_fh)
             print("----------", file=pip_error_fh)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e05f724..894d1af 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,7 +14,7 @@ from urllib.request import urlopen
 import pytest  # type: ignore
 
 from helpers import WIN
-from pipx import commands, constants, interpreter, shared_libs, standalone_python, venv
+from pipx import commands, interpreter, paths, shared_libs, standalone_python, venv
 
 PIPX_TESTS_DIR = Path(".pipx_tests")
 PIPX_TESTS_PACKAGE_LIST_DIR = Path("testdata/tests_packages")
@@ -69,18 +69,22 @@ def pipx_temp_env_helper(pipx_shared_dir, tmp_path, monkeypatch, request, utils_
     bin_dir = Path(tmp_path) / "otherdir" / "pipxbindir"
     man_dir = Path(tmp_path) / "otherdir" / "pipxmandir"
 
-    monkeypatch.setattr(constants, "PIPX_SHARED_LIBS", pipx_shared_dir)
+    global_home_dir = Path(tmp_path) / "global" / "pipxhome"
+    global_bin_dir = Path(tmp_path) / "global_otherdir" / "pipxbindir"
+    global_man_dir = Path(tmp_path) / "global_otherdir" / "pipxmandir"
+
+    # Patch in test specific base paths
+    monkeypatch.setattr(paths.ctx, "_base_shared_libs", pipx_shared_dir)
+    monkeypatch.setattr(paths.ctx, "_base_home", home_dir)
+    monkeypatch.setattr(paths.ctx, "_base_bin", bin_dir)
+    monkeypatch.setattr(paths.ctx, "_base_man", man_dir)
+    # Patch the default global paths so developers don't contaminate their own systems
+    monkeypatch.setattr(paths, "DEFAULT_PIPX_GLOBAL_BIN_DIR", global_bin_dir)
+    monkeypatch.setattr(paths, "DEFAULT_PIPX_GLOBAL_HOME", global_home_dir)
+    monkeypatch.setattr(paths, "DEFAULT_PIPX_GLOBAL_MAN_DIR", global_man_dir)
     monkeypatch.setattr(shared_libs, "shared_libs", shared_libs._SharedLibs())
     monkeypatch.setattr(venv, "shared_libs", shared_libs.shared_libs)
 
-    monkeypatch.setattr(constants, "PIPX_HOME", home_dir)
-    monkeypatch.setattr(constants, "LOCAL_BIN_DIR", bin_dir)
-    monkeypatch.setattr(constants, "LOCAL_MAN_DIR", man_dir)
-    monkeypatch.setattr(constants, "PIPX_STANDALONE_PYTHON_CACHEDIR", home_dir / "py")
-    monkeypatch.setattr(constants, "PIPX_LOCAL_VENVS", home_dir / "venvs")
-    monkeypatch.setattr(constants, "PIPX_VENV_CACHEDIR", home_dir / ".cache")
-    monkeypatch.setattr(constants, "PIPX_LOG_DIR", home_dir / "logs")
-
     monkeypatch.setattr(interpreter, "DEFAULT_PYTHON", sys.executable)
 
     if "PIPX_DEFAULT_PYTHON" in os.environ:
@@ -89,16 +93,16 @@ def pipx_temp_env_helper(pipx_shared_dir, tmp_path, monkeypatch, request, utils_
     # macOS needs /usr/bin in PATH to compile certain packages, but
     #   applications in /usr/bin cause test_install.py tests to raise warnings
     #   which make tests fail (e.g. on Github ansible apps exist in /usr/bin)
-    monkeypatch.setenv("PATH_ORIG", str(bin_dir) + os.pathsep + os.environ["PATH"])
-    monkeypatch.setenv("PATH_TEST", str(bin_dir))
-    monkeypatch.setenv("PATH", str(bin_dir) + os.pathsep + str(utils_temp_dir))
+    monkeypatch.setenv("PATH_ORIG", str(paths.ctx.bin_dir) + os.pathsep + os.environ["PATH"])
+    monkeypatch.setenv("PATH_TEST", str(paths.ctx.bin_dir))
+    monkeypatch.setenv("PATH", str(paths.ctx.bin_dir) + os.pathsep + str(utils_temp_dir))
     # On Windows, monkeypatch pipx.commands.common._can_symlink_cache to
-    #   indicate that constants.LOCAL_BIN_DIR and constants.LOCAL_MAN_DIR
+    #   indicate that paths.ctx.bin_dir and paths.ctx.man_dir
     #   cannot use symlinks, even if we're running as administrator and
     #   symlinks are actually possible.
     if WIN:
-        monkeypatch.setitem(commands.common._can_symlink_cache, constants.LOCAL_BIN_DIR, False)
-        monkeypatch.setitem(commands.common._can_symlink_cache, constants.LOCAL_MAN_DIR, False)
+        monkeypatch.setitem(commands.common._can_symlink_cache, paths.ctx.bin_dir, False)
+        monkeypatch.setitem(commands.common._can_symlink_cache, paths.ctx.man_dir, False)
     if not request.config.option.net_pypiserver:
         # IMPORTANT: use 127.0.0.1 not localhost
         #   Using localhost on Windows creates enormous slowdowns
@@ -197,6 +201,8 @@ def pipx_temp_env(tmp_path, monkeypatch, pipx_session_shared_dir, request, utils
     seamless.
     """
     pipx_temp_env_helper(pipx_session_shared_dir, tmp_path, monkeypatch, request, utils_temp_dir, pipx_local_pypiserver)
+    yield
+    paths.ctx.make_local()
 
 
 @pytest.fixture
@@ -211,3 +217,5 @@ def pipx_ultra_temp_env(tmp_path, monkeypatch, request, utils_temp_dir, pipx_loc
     """
     shared_dir = Path(tmp_path) / "shareddir"
     pipx_temp_env_helper(shared_dir, tmp_path, monkeypatch, request, utils_temp_dir, pipx_local_pypiserver)
+    yield
+    paths.ctx.make_local()
diff --git a/tests/helpers.py b/tests/helpers.py
index ad3b8df..d5f6c41 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -6,10 +6,11 @@ from pathlib import Path
 from typing import Any, Dict, List, Optional
 from unittest import mock
 
+import pytest  # type: ignore
 from packaging.utils import canonicalize_name
 
 from package_info import PKG
-from pipx import constants, main, pipx_metadata_file, util
+from pipx import constants, main, paths, pipx_metadata_file, util
 
 WIN = sys.platform.startswith("win")
 
@@ -110,7 +111,7 @@ def mock_legacy_venv(venv_name: str, metadata_version: Optional[str] = None) ->
     one with a previous metadata version.
     metadata_version=None refers to no metadata file (pipx pre-0.15.0.0)
     """
-    venv_dir = Path(constants.PIPX_LOCAL_VENVS) / canonicalize_name(venv_name)
+    venv_dir = Path(paths.ctx.venvs) / canonicalize_name(venv_name)
 
     if metadata_version == "0.4":
         # Current metadata version, do nothing
@@ -204,7 +205,10 @@ def assert_package_metadata(test_metadata, ref_metadata):
 
 
 def remove_venv_interpreter(venv_name):
-    _, venv_python_path, _ = util.get_venv_paths(constants.PIPX_LOCAL_VENVS / venv_name)
+    _, venv_python_path, _ = util.get_venv_paths(paths.ctx.venvs / venv_name)
     assert venv_python_path.is_file()
     venv_python_path.unlink()
     assert not venv_python_path.is_file()
+
+
+skip_if_windows = pytest.mark.skipif(sys.platform.startswith("win"), reason="This behavior is undefined on Windows")
diff --git a/tests/test_environment.py b/tests/test_environment.py
index e12d0e3..f1ff77d 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -1,20 +1,27 @@
+import fnmatch
+import os
 from pathlib import Path
 
-from helpers import run_pipx_cli
-from pipx.constants import load_dir_from_environ
+from helpers import run_pipx_cli, skip_if_windows
 
 
-def test_cli(monkeypatch, capsys):
+def load_dir_from_environ(dir_name: str, default: Path) -> Path:
+    env = os.environ.get(dir_name, default)
+    return Path(os.path.expanduser(env)).resolve()
+
+
+def test_cli(pipx_temp_env, monkeypatch, capsys):
     assert not run_pipx_cli(["environment"])
     captured = capsys.readouterr()
-    assert "PIPX_HOME" in captured.out
-    assert "PIPX_BIN_DIR" in captured.out
-    assert "PIPX_MAN_DIR" in captured.out
+    assert fnmatch.fnmatch(captured.out, "*PIPX_HOME=*subdir/pipxhome*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_BIN_DIR=*otherdir/pipxbindir*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_MAN_DIR=*otherdir/pipxmandir*")
     assert "PIPX_SHARED_LIBS" in captured.out
-    assert "PIPX_LOCAL_VENVS" in captured.out
-    assert "PIPX_LOG_DIR" in captured.out
-    assert "PIPX_TRASH_DIR" in captured.out
-    assert "PIPX_VENV_CACHEDIR" in captured.out
+    assert fnmatch.fnmatch(captured.out, "*PIPX_LOCAL_VENVS=*subdir/pipxhome/venvs*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_LOG_DIR=*subdir/pipxhome/logs*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_TRASH_DIR=*subdir/pipxhome/.trash*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_VENV_CACHEDIR=*subdir/pipxhome/.cache*")
+    # Checking just for the sake of completeness
     assert "PIPX_DEFAULT_PYTHON" in captured.out
     assert "USE_EMOJI" in captured.out
     assert "Environment variables (set by user):" in captured.out
@@ -49,3 +56,20 @@ def test_resolve_user_dir_in_env_paths_env_not_set(monkeypatch):
     home = Path.home()
     env_dir = load_dir_from_environ("TEST_DIR", Path.home())
     assert env_dir == home
+
+
+@skip_if_windows
+def test_cli_global(pipx_temp_env, monkeypatch, capsys):
+    assert not run_pipx_cli(["--global", "environment"])
+    captured = capsys.readouterr()
+    assert fnmatch.fnmatch(captured.out, "*PIPX_HOME=*global/pipxhome*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_BIN_DIR=*global_otherdir/pipxbindir*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_MAN_DIR=*global_otherdir/pipxmandir*")
+    assert "PIPX_SHARED_LIBS" in captured.out
+    assert fnmatch.fnmatch(captured.out, "*PIPX_LOCAL_VENVS=*global/pipxhome/venvs*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_LOG_DIR=*global/pipxhome/logs*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_TRASH_DIR=*global/pipxhome/.trash*")
+    assert fnmatch.fnmatch(captured.out, "*PIPX_VENV_CACHEDIR=*global/pipxhome/.cache*")
+    # Checking just for the sake of completeness
+    assert "PIPX_DEFAULT_PYTHON" in captured.out
+    assert "USE_EMOJI" in captured.out
diff --git a/tests/test_inject.py b/tests/test_inject.py
index 01c4ff0..f673ed2 100644
--- a/tests/test_inject.py
+++ b/tests/test_inject.py
@@ -1,6 +1,6 @@
 import pytest  # type: ignore
 
-from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli
+from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows
 from package_info import PKG
 
 
@@ -9,6 +9,12 @@ def test_inject_simple(pipx_temp_env, capsys):
     assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]])
 
 
+@skip_if_windows
+def test_inject_simple_global(pipx_temp_env, capsys):
+    assert not run_pipx_cli(["--global", "install", "pycowsay"])
+    assert not run_pipx_cli(["--global", "inject", "pycowsay", PKG["black"]["spec"]])
+
+
 @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS)
 def test_inject_simple_legacy_venv(pipx_temp_env, capsys, metadata_version):
     assert not run_pipx_cli(["install", "pycowsay"])
diff --git a/tests/test_install.py b/tests/test_install.py
index 23cfc52..46c5ecc 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -7,9 +7,9 @@ from unittest import mock
 
 import pytest  # type: ignore
 
-from helpers import app_name, run_pipx_cli, unwrap_log_text
+from helpers import app_name, run_pipx_cli, skip_if_windows, unwrap_log_text
 from package_info import PKG
-from pipx import constants
+from pipx import paths
 
 TEST_DATA_PATH = "./testdata/test_package_specifier"
 
@@ -58,6 +58,15 @@ def test_install_easy_multiple_packages(capsys, pipx_temp_env, caplog):
     )
 
 
+@pytest.mark.parametrize(
+    "package_name, package_spec",
+    [("pycowsay", "pycowsay"), ("black", PKG["black"]["spec"])],
+)
+@skip_if_windows
+def test_install_easy_packages_globally(capsys, pipx_temp_env, caplog, package_name, package_spec):
+    install_packages(capsys, pipx_temp_env, caplog, [package_spec], [package_name])
+
+
 @pytest.mark.parametrize(
     "package_name, package_spec",
     [
@@ -165,12 +174,10 @@ def test_path_warning(pipx_temp_env, capsys, monkeypatch, caplog):
     assert "is not on your PATH environment variable" in unwrap_log_text(caplog.text)
 
 
+@skip_if_windows
 def test_existing_symlink_points_to_existing_wrong_location_warning(pipx_temp_env, caplog, capsys):
-    if sys.platform.startswith("win"):
-        pytest.skip("pipx does not use symlinks on Windows")
-
-    constants.LOCAL_BIN_DIR.mkdir(exist_ok=True, parents=True)
-    (constants.LOCAL_BIN_DIR / "pycowsay").symlink_to(os.devnull)
+    paths.ctx.bin_dir.mkdir(exist_ok=True, parents=True)
+    (paths.ctx.bin_dir / "pycowsay").symlink_to(os.devnull)
     assert not run_pipx_cli(["install", "pycowsay"])
     captured = capsys.readouterr()
     assert "File exists at" in unwrap_log_text(caplog.text)
@@ -180,24 +187,20 @@ def test_existing_symlink_points_to_existing_wrong_location_warning(pipx_temp_en
     assert "is not on your PATH environment variable" not in captured.err
 
 
+@skip_if_windows
 def test_existing_man_page_symlink_points_to_existing_wrong_location_warning(pipx_temp_env, caplog, capsys):
-    if sys.platform.startswith("win"):
-        pytest.skip("pipx does not use symlinks on Windows")
-
-    (constants.LOCAL_MAN_DIR / "man6").mkdir(exist_ok=True, parents=True)
-    (constants.LOCAL_MAN_DIR / "man6" / "pycowsay.6").symlink_to(os.devnull)
+    (paths.ctx.man_dir / "man6").mkdir(exist_ok=True, parents=True)
+    (paths.ctx.man_dir / "man6" / "pycowsay.6").symlink_to(os.devnull)
     assert not run_pipx_cli(["install", "pycowsay"])
     captured = capsys.readouterr()
     assert "File exists at" in unwrap_log_text(caplog.text)
     assert "symlink missing or pointing to unexpected location" in captured.out
 
 
+@skip_if_windows
 def test_existing_symlink_points_to_nothing(pipx_temp_env, capsys):
-    if sys.platform.startswith("win"):
-        pytest.skip("pipx does not use symlinks on Windows")
-
-    constants.LOCAL_BIN_DIR.mkdir(exist_ok=True, parents=True)
-    (constants.LOCAL_BIN_DIR / "pycowsay").symlink_to("/asdf/jkl")
+    paths.ctx.bin_dir.mkdir(exist_ok=True, parents=True)
+    (paths.ctx.bin_dir / "pycowsay").symlink_to("/asdf/jkl")
     assert not run_pipx_cli(["install", "pycowsay"])
     captured = capsys.readouterr()
     # pipx should realize the symlink points to nothing and replace it,
@@ -205,12 +208,10 @@ def test_existing_symlink_points_to_nothing(pipx_temp_env, capsys):
     assert "symlink missing or pointing to unexpected location" not in captured.out
 
 
+@skip_if_windows
 def test_existing_man_page_symlink_points_to_nothing(pipx_temp_env, capsys):
-    if sys.platform.startswith("win"):
-        pytest.skip("pipx does not use symlinks on Windows")
-
-    (constants.LOCAL_MAN_DIR / "man6").mkdir(exist_ok=True, parents=True)
-    (constants.LOCAL_MAN_DIR / "man6" / "pycowsay.6").symlink_to("/asdf/jkl")
+    (paths.ctx.man_dir / "man6").mkdir(exist_ok=True, parents=True)
+    (paths.ctx.man_dir / "man6" / "pycowsay.6").symlink_to("/asdf/jkl")
     assert not run_pipx_cli(["install", "pycowsay"])
     captured = capsys.readouterr()
     # pipx should realize the symlink points to nothing and replace it,
@@ -263,15 +264,15 @@ def test_install_suffix(pipx_temp_env, capsys):
     name_b = app_name(f"{name}{suffix}")
     assert f"- {name_b}" in captured.out
 
-    assert (constants.LOCAL_BIN_DIR / name_a).exists()
-    assert (constants.LOCAL_BIN_DIR / name_b).exists()
+    assert (paths.ctx.bin_dir / name_a).exists()
+    assert (paths.ctx.bin_dir / name_b).exists()
 
 
 def test_man_page_install(pipx_temp_env, capsys):
     assert not run_pipx_cli(["install", "pycowsay"])
     captured = capsys.readouterr()
     assert f"- {Path('man6/pycowsay.6')}" in captured.out
-    assert (constants.LOCAL_MAN_DIR / "man6" / "pycowsay.6").exists()
+    assert (paths.ctx.man_dir / "man6" / "pycowsay.6").exists()
 
 
 def test_install_pip_failure(pipx_temp_env, capsys):
diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py
index aa5c7bb..446b69c 100644
--- a/tests/test_interpreter.py
+++ b/tests/test_interpreter.py
@@ -6,8 +6,9 @@ from unittest.mock import Mock
 import pytest  # type: ignore
 
 import pipx.interpreter
+import pipx.paths
 import pipx.standalone_python
-from pipx.constants import PIPX_STANDALONE_PYTHON_CACHEDIR, WINDOWS
+from pipx.constants import WINDOWS
 from pipx.interpreter import (
     InterpreterResolutionError,
     _find_default_windows_python,
@@ -190,7 +191,7 @@ def test_fetch_missing_python(monkeypatch, mocked_github_api):
         python_path = find_python_interpreter(target_python, fetch_missing_python=True)
         assert python_path is not None
         assert target_python in python_path
-        assert str(PIPX_STANDALONE_PYTHON_CACHEDIR) in python_path
+        assert str(pipx.paths.ctx.standalone_python_cachedir) in python_path
         if WINDOWS:
             assert python_path.endswith("python.exe")
         else:
diff --git a/tests/test_list.py b/tests/test_list.py
index 15ef990..838ed2e 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -15,9 +15,10 @@ from helpers import (
     mock_legacy_venv,
     remove_venv_interpreter,
     run_pipx_cli,
+    skip_if_windows,
 )
 from package_info import PKG
-from pipx import constants, shared_libs
+from pipx import constants, paths, shared_libs
 from pipx.pipx_metadata_file import PackageInfo, _json_decoder_object_hook
 
 
@@ -27,6 +28,17 @@ def test_cli(pipx_temp_env, monkeypatch, capsys):
     assert "nothing has been installed with pipx" in captured.err
 
 
+@skip_if_windows
+def test_cli_global(pipx_temp_env, monkeypatch, capsys):
+    assert not run_pipx_cli(["install", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "installed package" in captured.out
+
+    assert not run_pipx_cli(["--global", "list"])
+    captured = capsys.readouterr()
+    assert "nothing has been installed with pipx" in captured.err
+
+
 def test_missing_interpreter(pipx_temp_env, monkeypatch, capsys):
     assert not run_pipx_cli(["install", "pycowsay"])
 
@@ -77,7 +89,7 @@ def test_list_suffix_legacy_venv(pipx_temp_env, monkeypatch, capsys, metadata_ve
 
 
 def test_list_json(pipx_temp_env, capsys):
-    pipx_venvs_dir = constants.PIPX_HOME / "venvs"
+    pipx_venvs_dir = paths.ctx.home / "venvs"
     venv_bin_dir = "Scripts" if constants.WINDOWS else "bin"
 
     assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py
index 11e3aeb..9e1cc78 100644
--- a/tests/test_pipx_metadata_file.py
+++ b/tests/test_pipx_metadata_file.py
@@ -3,9 +3,9 @@ from pathlib import Path
 
 import pytest  # type: ignore
 
-import pipx.constants
 from helpers import assert_package_metadata, create_package_info_ref, run_pipx_cli
 from package_info import PKG
+from pipx import paths
 from pipx.pipx_metadata_file import PackageInfo, PipxMetadata
 from pipx.util import PipxError
 
@@ -89,7 +89,7 @@ def test_pipx_metadata_file_validation(tmp_path, test_package):
 
 
 def test_package_install(monkeypatch, tmp_path, pipx_temp_env):
-    pipx_venvs_dir = pipx.constants.PIPX_HOME / "venvs"
+    pipx_venvs_dir = paths.ctx.home / "venvs"
 
     run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
     assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file()
@@ -101,7 +101,7 @@ def test_package_install(monkeypatch, tmp_path, pipx_temp_env):
 
 
 def test_package_inject(monkeypatch, tmp_path, pipx_temp_env):
-    pipx_venvs_dir = pipx.constants.PIPX_HOME / "venvs"
+    pipx_venvs_dir = paths.ctx.home / "venvs"
 
     run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
     run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]])
diff --git a/tests/test_reinstall.py b/tests/test_reinstall.py
index ce2d7ec..38f013f 100644
--- a/tests/test_reinstall.py
+++ b/tests/test_reinstall.py
@@ -2,7 +2,7 @@ import sys
 
 import pytest  # type: ignore
 
-from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli
+from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows
 
 
 def test_reinstall(pipx_temp_env, capsys):
@@ -10,6 +10,12 @@ def test_reinstall(pipx_temp_env, capsys):
     assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"])
 
 
+@skip_if_windows
+def test_reinstall_global(pipx_temp_env, capsys):
+    assert not run_pipx_cli(["--global", "install", "pycowsay"])
+    assert not run_pipx_cli(["--global", "reinstall", "--python", sys.executable, "pycowsay"])
+
+
 def test_reinstall_nonexistent(pipx_temp_env, capsys):
     assert run_pipx_cli(["reinstall", "--python", sys.executable, "nonexistent"])
     assert "Nothing to reinstall for nonexistent" in capsys.readouterr().out
diff --git a/tests/test_run.py b/tests/test_run.py
index df7abde..065ea54 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -11,7 +11,7 @@ import pipx.main
 import pipx.util
 from helpers import run_pipx_cli
 from package_info import PKG
-from pipx import constants
+from pipx import paths
 
 
 def test_help_text(pipx_temp_env, monkeypatch, capsys):
@@ -63,7 +63,7 @@ def test_cache(pipx_temp_env, monkeypatch, capsys, caplog):
 
 @mock.patch("os.execvpe", new=execvpe_mock)
 def test_cachedir_tag(pipx_ultra_temp_env, monkeypatch, capsys, caplog):
-    tag_path = constants.PIPX_VENV_CACHEDIR / "CACHEDIR.TAG"
+    tag_path = paths.ctx.venv_cache / "CACHEDIR.TAG"
     assert not tag_path.exists()
 
     # Run pipx to create tag
diff --git a/tests/test_runpip.py b/tests/test_runpip.py
index b3c74f1..801bbdc 100644
--- a/tests/test_runpip.py
+++ b/tests/test_runpip.py
@@ -1,6 +1,12 @@
-from helpers import run_pipx_cli
+from helpers import run_pipx_cli, skip_if_windows
 
 
 def test_runpip(pipx_temp_env, monkeypatch, capsys):
     assert not run_pipx_cli(["install", "pycowsay"])
     assert not run_pipx_cli(["runpip", "pycowsay", "list"])
+
+
+@skip_if_windows
+def test_runpip_global(pipx_temp_env, monkeypatch, capsys):
+    assert not run_pipx_cli(["--global", "install", "pycowsay"])
+    assert not run_pipx_cli(["--global", "runpip", "pycowsay", "list"])
diff --git a/tests/test_uninject.py b/tests/test_uninject.py
index 164b29f..054d266 100644
--- a/tests/test_uninject.py
+++ b/tests/test_uninject.py
@@ -1,4 +1,4 @@
-from helpers import run_pipx_cli
+from helpers import run_pipx_cli, skip_if_windows
 from package_info import PKG
 
 
@@ -13,6 +13,18 @@ def test_uninject_simple(pipx_temp_env, capsys):
     assert "black" not in captured.out
 
 
+@skip_if_windows
+def test_uninject_simple_global(pipx_temp_env, capsys):
+    assert not run_pipx_cli(["--global", "install", "pycowsay"])
+    assert not run_pipx_cli(["--global", "inject", "pycowsay", PKG["black"]["spec"]])
+    assert not run_pipx_cli(["--global", "uninject", "pycowsay", "black"])
+    captured = capsys.readouterr()
+    assert "Uninjected package black" in captured.out
+    assert not run_pipx_cli(["--global", "list", "--include-injected"])
+    captured = capsys.readouterr()
+    assert "black" not in captured.out
+
+
 def test_uninject_with_include_apps(pipx_temp_env, capsys, caplog):
     assert not run_pipx_cli(["install", "pycowsay"])
     assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps", "--include-apps"])
diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py
index 42cbd2a..9bf9360 100644
--- a/tests/test_uninstall.py
+++ b/tests/test_uninstall.py
@@ -2,9 +2,16 @@ import sys
 
 import pytest  # type: ignore
 
-from helpers import PIPX_METADATA_LEGACY_VERSIONS, app_name, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli
+from helpers import (
+    PIPX_METADATA_LEGACY_VERSIONS,
+    app_name,
+    mock_legacy_venv,
+    remove_venv_interpreter,
+    run_pipx_cli,
+    skip_if_windows,
+)
 from package_info import PKG
-from pipx import constants
+from pipx import paths
 
 
 def file_or_symlink(filepath):
@@ -22,6 +29,12 @@ def test_uninstall(pipx_temp_env):
     assert not run_pipx_cli(["uninstall", "pycowsay"])
 
 
+@skip_if_windows
+def test_uninstall_global(pipx_temp_env):
+    assert not run_pipx_cli(["--global", "install", "pycowsay"])
+    assert not run_pipx_cli(["--global", "uninstall", "pycowsay"])
+
+
 def test_uninstall_circular_deps(pipx_temp_env):
     assert not run_pipx_cli(["install", PKG["cloudtoken"]["spec"]])
     assert not run_pipx_cli(["uninstall", "cloudtoken"])
@@ -29,7 +42,7 @@ def test_uninstall_circular_deps(pipx_temp_env):
 
 @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS)
 def test_uninstall_legacy_venv(pipx_temp_env, metadata_version):
-    executable_path = constants.LOCAL_BIN_DIR / app_name("pycowsay")
+    executable_path = paths.ctx.bin_dir / app_name("pycowsay")
 
     assert not run_pipx_cli(["install", "pycowsay"])
     assert executable_path.exists()
@@ -42,7 +55,7 @@ def test_uninstall_legacy_venv(pipx_temp_env, metadata_version):
 def test_uninstall_suffix(pipx_temp_env):
     name = "pbr"
     suffix = "_a"
-    executable_path = constants.LOCAL_BIN_DIR / app_name(f"{name}{suffix}")
+    executable_path = paths.ctx.bin_dir / app_name(f"{name}{suffix}")
 
     assert not run_pipx_cli(["install", PKG[name]["spec"], f"--suffix={suffix}"])
     assert executable_path.exists()
@@ -52,7 +65,7 @@ def test_uninstall_suffix(pipx_temp_env):
 
 
 def test_uninstall_man_page(pipx_temp_env):
-    man_page_path = constants.LOCAL_MAN_DIR / "man6" / "pycowsay.6"
+    man_page_path = paths.ctx.man_dir / "man6" / "pycowsay.6"
     assert not run_pipx_cli(["install", "pycowsay"])
     assert man_page_path.exists()
     assert not run_pipx_cli(["uninstall", "pycowsay"])
@@ -60,9 +73,9 @@ def test_uninstall_man_page(pipx_temp_env):
 
 
 def test_uninstall_injected(pipx_temp_env):
-    pycowsay_app_paths = [constants.LOCAL_BIN_DIR / app for app in PKG["pycowsay"]["apps"]]
-    pycowsay_man_page_paths = [constants.LOCAL_MAN_DIR / man_page for man_page in PKG["pycowsay"]["man_pages"]]
-    pylint_app_paths = [constants.LOCAL_BIN_DIR / app for app in PKG["pylint"]["apps"]]
+    pycowsay_app_paths = [paths.ctx.bin_dir / app for app in PKG["pycowsay"]["apps"]]
+    pycowsay_man_page_paths = [paths.ctx.man_dir / man_page for man_page in PKG["pycowsay"]["man_pages"]]
+    pylint_app_paths = [paths.ctx.bin_dir / app for app in PKG["pylint"]["apps"]]
     app_paths = pycowsay_app_paths + pylint_app_paths
     man_page_paths = pycowsay_man_page_paths
 
@@ -90,7 +103,7 @@ def test_uninstall_suffix_legacy_venv(pipx_temp_env, metadata_version):
     # legacy uninstall on Windows only works with "canonical name characters"
     #   in suffix
     suffix = "-a"
-    executable_path = constants.LOCAL_BIN_DIR / app_name(f"{name}{suffix}")
+    executable_path = paths.ctx.bin_dir / app_name(f"{name}{suffix}")
 
     assert not run_pipx_cli(["install", PKG[name]["spec"], f"--suffix={suffix}"])
     mock_legacy_venv(f"{name}{suffix}", metadata_version=metadata_version)
@@ -102,7 +115,7 @@ def test_uninstall_suffix_legacy_venv(pipx_temp_env, metadata_version):
 
 @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS)
 def test_uninstall_with_missing_interpreter(pipx_temp_env, metadata_version):
-    executable_path = constants.LOCAL_BIN_DIR / app_name("pycowsay")
+    executable_path = paths.ctx.bin_dir / app_name("pycowsay")
 
     assert not run_pipx_cli(["install", "pycowsay"])
     assert executable_path.exists()
@@ -120,8 +133,8 @@ def test_uninstall_with_missing_interpreter(pipx_temp_env, metadata_version):
 def test_uninstall_proper_dep_behavior(pipx_temp_env, metadata_version):
     # isort is a dependency of pylint.  Make sure that uninstalling pylint
     #   does not also uninstall isort app in LOCAL_BIN_DIR
-    isort_app_paths = [constants.LOCAL_BIN_DIR / app for app in PKG["isort"]["apps"]]
-    pylint_app_paths = [constants.LOCAL_BIN_DIR / app for app in PKG["pylint"]["apps"]]
+    isort_app_paths = [paths.ctx.bin_dir / app for app in PKG["isort"]["apps"]]
+    pylint_app_paths = [paths.ctx.bin_dir / app for app in PKG["pylint"]["apps"]]
 
     assert not run_pipx_cli(["install", PKG["pylint"]["spec"]])
     assert not run_pipx_cli(["install", PKG["isort"]["spec"]])
@@ -145,8 +158,8 @@ def test_uninstall_proper_dep_behavior(pipx_temp_env, metadata_version):
 def test_uninstall_proper_dep_behavior_missing_interpreter(pipx_temp_env, metadata_version):
     # isort is a dependency of pylint.  Make sure that uninstalling pylint
     #   does not also uninstall isort app in LOCAL_BIN_DIR
-    isort_app_paths = [constants.LOCAL_BIN_DIR / app for app in PKG["isort"]["apps"]]
-    pylint_app_paths = [constants.LOCAL_BIN_DIR / app for app in PKG["pylint"]["apps"]]
+    isort_app_paths = [paths.ctx.bin_dir / app for app in PKG["isort"]["apps"]]
+    pylint_app_paths = [paths.ctx.bin_dir / app for app in PKG["pylint"]["apps"]]
 
     assert not run_pipx_cli(["install", PKG["pylint"]["spec"]])
     assert not run_pipx_cli(["install", PKG["isort"]["spec"]])
diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py
index c89629d..4892286 100644
--- a/tests/test_upgrade.py
+++ b/tests/test_upgrade.py
@@ -1,13 +1,36 @@
 import pytest  # type: ignore
 
-from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli
+from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows
 from package_info import PKG
 
 
 def test_upgrade(pipx_temp_env, capsys):
     assert run_pipx_cli(["upgrade", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "Package is not installed" in captured.err
+
     assert not run_pipx_cli(["install", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "installed package pycowsay" in captured.out
+
     assert not run_pipx_cli(["upgrade", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "pycowsay is already at latest version" in captured.out
+
+
+@skip_if_windows
+def test_upgrade_global(pipx_temp_env, capsys):
+    assert run_pipx_cli(["--global", "upgrade", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "Package is not installed" in captured.err
+
+    assert not run_pipx_cli(["--global", "install", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "installed package pycowsay" in captured.out
+
+    assert not run_pipx_cli(["--global", "upgrade", "pycowsay"])
+    captured = capsys.readouterr()
+    assert "pycowsay is already at latest version" in captured.out
 
 
 @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS)