Metadata-Version: 2.4
Name: ducktools-env
Version: 0.5.0
Summary: Virtual environment management tools and application bundle builder
Author: David C Ellis
License-Expression: MIT
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: ducktools-classbuilder>=0.12.6
Requires-Dist: ducktools-lazyimporter>=0.8.4
Requires-Dist: ducktools-scriptmetadata>=0.2.1
Requires-Dist: ducktools-pythonfinder>=0.10.5
Requires-Dist: packaging>=26.0
Dynamic: license-file

# DuckTools: Env

`ducktools-env` intends to provide a few tools to aid in running and distributing
applications and scripts written in Python that require additional dependencies.

## Why wouldn't I just use uv/pipx/hatch/... to run scripts

You probably would, I wrote the core of this before those had support for inline metadata
back when it was based on the since rejected PEP-722. It has a few additional features I
like and had a few features before they were implemented elsewhere.

1. There's a script registry so you can access scripts from anywhere without them needing
   to be on `PATH`
2. lockfiles are basic `requirements.txt` format rather than the custom `uv.lock` format
    * Ideally this will eventually be replaced by the pylock.toml format once `pip` can
      install from it directly.
3. It is possible to bundle `ducktools-env` into a zipapp with the script so the user does
   not need to have `ducktools-env` installed locally.
    * Additional data can also be bundled into the zipapp

## What is this for?

Suppose you have a Python script that you wish to share with someone else, but it relies
on a third party dependency such as `requests`. In order for someone else to run your code
they need to both have an appropriate version of Python and to create a virtual
environment in which to install `requests` and subsequently run your script.

PEP-723 introduced
[inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata)
which allows users to declare dependencies for single python files in a standardized
format. This is designed to make sharing scripts with PyPI dependencies easier as now the
script can define its own requirements.

However, using this format requires the use of an extra package such as 'UV' or 'hatch'
using a specific command such as `uv run my_script.py` or `hatch run my_script.py`.

`ducktools-env` is designed to bundle your script into a Python
[zipapp](https://docs.python.org/3/library/zipapp.html) which can be run by any Python
3.12+ install and will handle creating the virtualenv and launching the script with the
appropriate dependencies *without* needing the other user to have any specific script
running tool installed.

To aid this, `ducktools-env` provides the `bundle` and `run` commands.

`ducktools-env run my_script.py`

Will run your script much like some of the other script runners.

`ducktools-env bundle my_script.py`

Will then generate a zipapp bundle of your script and the required tools to extract and
execute it in the same way as it is executed via the `run` command.

The resulting bundle will include `ducktools-env` and the `pip` zipapp in order to
bootstrap the unbundling process.

## What if the user does not have Python installed

Running the bundle requires the user to have an install of Python 3.12 or later. This
should be available via python.org with installers for Windows/Mac and either already
included or available from any up to date Linux distribution. This is all that should be
needed for your script to run.

The version of Python that will actually be used to build the environment will be the
latest version that can be found via
[ducktools-pythonfinder](https://github.com/DavidCEllis/ducktools-pythonfinder) that
satisfies the `requires-python` specification.

## Where is data stored?

Environment data and the application itself will be stored in the following locations:

- Windows: `%LOCALAPPDATA%\ducktools\env`
- Linux/Mac/Other:
  - Data: `~/.local/share/ducktools/env`
  - Config: `~/.config/ducktools/env` (Not yet used)

## Usage

The tool can be used in multiple ways:

- Installed via `uv tool` (or `pipx`)
  - `uv tool install ducktools-env`
  - `ducktools-env <command>`
  - This adds the `dtrun` shortcut for `ducktools-env run`
- Executed from the zipapp
  - Download from: https://github.com/DavidCEllis/ducktools-env/releases/latest
  - Run with: `ducktools-env.pyz <command>`
  - The `dtrun.pyz` zipapp is available as a shortcut for `ducktools-env.pyz run`
- Installed in an environment
  - Download with `pip` or `uv` in a virtual environment: `pip install ducktools-env`
  - Run with: `ducktools-env <command>`
  - The `dtrun` shortcut is also available
- Accessed directly via `uvx` with uv
  - `uvx ducktools-env <command>`
  - No access to the `dtrun` shortcut this way

These examples will use the `ducktools-env` command as the base as if installed via
`uv tool` or a similar tool.

Run a script that uses inline script metadata:

`ducktools-env run my_script.py`

If installed via `uv`, `pipx` or `pip` there is an alias `dtrun` for this command. Unlike
the full command it does not accept optional arguments and all arguments are passed on to
the script.

`dtrun my_script.py`

Bundle the script into a zipapp:

`ducktools-env bundle my_script.py`

Clear the temporary environment cache:

`ducktools-env clear_cache`

Clear the full `ducktools/env` install directory:

`ducktools-env clear_cache --full`

Build the env folder from the installed package:

`ducktools-env rebuild_env`

### Registering scripts

It is also now possible to register scripts with `ducktools-env`.

`ducktools-env register path/to/my_script.py`

which can then be run by using the script name without the extension:

`ducktools-env run my_script` or `dtrun my_script`

## Locking environments

When generating zipapp bundles it may be desirable to also generate a lockfile to make
sure that the versions of installed dependencies do not change between generation and
execution without having to over specify in the original script.

This generation feature requires `uv` be installed in order to create a universal
requirements.txt format lockfile. As such `uv` is **not** required to use the generated
lockfile, only to create it.

Create a lockfile without running a script:

`ducktools-env generate_lock my_script.py`

Run a script and output the generated lockfile (output as my_script.py.dtenv.lock):

`ducktools-env run --generate-lock my_script.py` (--generate-lock does not work with
`dtrun`)

Run a script using a pre-generated lockfile:

`ducktools-env run --with-lock my_script.py.dtenv.lock my_script.py`

**If a `my_script.py.dtenv.lock` file is found for a script it will automatically be used
without needing to be specified**

Bundle a script and generate a lockfile (that will be bundled):

`ducktools-env bundle --generate-lock my_script.py`

Bundle a script with a pre-generated lockfile:

`ducktools-env bundle --with-lock my_script.py.dtenv.lock my_script.py`

**If a `my_script.py.dtenv.lock` file exists it will automatically be used in the bundle
also.**

The lockfile extension is now `.dtenv.lock` as `uv` will try to use a `.lock` file if it
exists and uses its own tool-specific lockfile format. To avoid a clash with `uv` this was
renamed.

## Including data files with script bundles

If you wish to include data files with your script you can do so using a tool table in the
toml block.

```python
# /// script
# requires-python = ">=3.10"
# dependencies = ["cowsay"]
#
# [tool.ducktools.env]
# include.data = ["path/to/folder", "path/to/file.txt"]
# ///
```

If this is made into a bundle these files and folders will be collected into a bundle_data
folder included in the zipapp.

This data can be retrieved on demand using `get_data_folder` from
`ducktools.env.bundled_data` which will create a temporary folder containing a copy of the
data files and return the path to the folder.

Note: Paths are relative to the script folder. If you include a folder, the folder itself
will be included, not just its contents. This means that if you include `./` you will get
the name of the folder the script is in (along with all of its contents).

This can be used to include additional code by inserting the relevant folder into
`sys.path` before executing the body of a script.

```python
# /// script
# requires-python = ">=3.12"
# dependencies = ["ducktools-env>=0.1.0"]
#
# [tool.ducktools.env]
# include.data = ["./"]
# include.license = ["license.md"]
# ///
from pathlib import Path

from ducktools.env.bundled_data import get_data_folder

with get_data_folder() as fld_name:
    for f in Path(fld_name).rglob("*"):
        print(f)
```

## Application Environments

If you wish your script to persist as an "application" you can define 'owner', 'name' and
'version' fields.

These environments **require** generation of a lockfile.

A new version of the application will update the environment to depend on that version.
The environment will be rebuilt if the lockfile is updated on updating to a new version.
If the lockfile has changed but the version has not, running the application will fail
(unless the version is a pre-release). Old versions will also fail to run if the
environment has been created for a new version.

```python
# /// script
# requires-python = ">=3.8.0"
# dependencies = ["cowsay"]
# [tool.ducktools.env]
# app.owner = "ducktools_testing"
# app.name = "cowsay_example"
# app.version = "0.1.0"
# ///

from cowsay.__main__ import cli

if __name__ == "__main__":
    cli()
```

## Listing and deleting environments

Existing environments can be listed with the command

`ducktools-env list`

and deleted with

`ducktools-env delete_env <envname>`

where `<envname>` is the `name` of a temporary environment or the combination `owner/name`
of an application environment as shown in the list.

## Goals

Future goals for this tool:

- Optionally bundle requirements inside the zipapp for use as offline bundles.

## Dependencies

Currently `ducktools.env` relies on the following tools.

Subprocesses:

- `venv` via subprocess on python installs
- `pip` as a zipapp via subprocess as the dependency installer (the zipapp will be
  downloaded automatically)
  - This is done rather than including `pip` as a dependency to avoid a clash with `pip`
    installed in an environment
- `uv` to generate lockfiles
  - This requires that the user has `uv` installed and on `PATH`

PyPI:

- `ducktools-classbuilder` (A lazy, faster implementation of the building blocks behind
  things like dataclasses)
  - `reannotate` (for correctly handling Python 3.14 annotations)
- `ducktools-lazyimporter` (A simple class based tool to handle deferred imports)
- `ducktools-scriptmetadata` (The parser for inline script metadata blocks)
- `ducktools-pythonfinder` (A tool to discover python installs available for environment
  creation)
- `packaging` (for comparing dependency lists to cached environments)

## Other tools in this space

### zipapp

The standard library [`zipapp`](https://docs.python.org/3/library/zipapp.html) is at the
core of how `ducktools-env` works. However it doesn't support running with C extensions
and it has no inbuilt way to control which Python it will run under.

By contrast `ducktools-env` will respect a specified python version and required
extensions, these can be bundled or downloaded on first launch via `pip`.

### Shiv

[`shiv`](https://github.com/linkedin/shiv) allows you to bundle zipapps with C extensions,
but doesn't provide for any `online` installs and will extract everything into one
`~/.shiv` directory unless otherwise specified. At the time of writing support for using
inline script metadata has not yet been merged but there is a PR to add support.

`ducktools-env` creates and manages virtual environments for each unique set of script
requirements. These are kept in more platform specific directories documented earlier in
the readme.

### Pex

[`Pex`](https://github.com/pex-tool/pex) provides an assortment of related tools for
developers alongside a `.pex` bundler. It has (undocumented) support for inline script
metadata for building its archives and will bundle dependencies including C extensions
inside the archive, with the option to also include a Python runtime. It does not support
`online` installs, so archives may be platform dependent or large.

### PyInstaller

[Pyinstaller](https://pyinstaller.org/en/stable/) will generate an executable from your
script but will also bundle all of your dependencies in a platform specific way. It also
bundles Python itself, which while convenient if python is not installed, is unnecessary
if we can treat Python as a shared library.

### Hatch

[`Hatch`](https://hatch.pypa.io/) allows you to run scripts with inline dependencies, but
requires the user on the other end already have hatch installed. The goal of
`ducktools-env` is to make it so you can quickly bundle the script into a zipapp that will
work on the other end with only Python as the requirement.

### pipx

[`pipx`](https://pipx.pypa.io/) is another tool that allows you to install packages from
PyPI and run them as applications based on their `[project.scripts]` and
`[project.gui-scripts]`. It also allows you to run inline scripts with more recent
versions.

### uv

[`uv`](https://docs.astral.sh/uv) can run PEP-723 scripts directly. `ducktools-env` mostly
still exists for the extra zipapp bundling and script registry tools.
