codecov-io: # [Codecov](https://codecov.io/gh/sernst/pipper/pull/5?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@6ff102b`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `90.19%`.

[![Impacted file tree graph](https://codecov.io/gh/sernst/pipper/pull/5/graphs/tree.svg?token=SS8H0dT8hx&src=pr&width=650&height=150)](https://codecov.io/gh/sernst/pipper/pull/5?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##             master       #5   +/-   ##
=========================================
  Coverage          ?   48.19%           
=========================================
  Files             ?       16           
  Lines             ?      884           
  Branches          ?        0           
=========================================
  Hits              ?      426           
  Misses            ?      458           
  Partials          ?        0
```


| [Impacted Files](https://codecov.io/gh/sernst/pipper/pull/5?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pipper/test/versioning/test\_serialize\_prefix.py](https://codecov.io/gh/sernst/pipper/pull/5/diff?src=pr&el=tree#diff-cGlwcGVyL3Rlc3QvdmVyc2lvbmluZy90ZXN0X3NlcmlhbGl6ZV9wcmVmaXgucHk=) | `100% <100%> (ø)` | |
| [pipper/test/utils.py](https://codecov.io/gh/sernst/pipper/pull/5/diff?src=pr&el=tree#diff-cGlwcGVyL3Rlc3QvdXRpbHMucHk=) | `100% <100%> (ø)` | |
| [pipper/test/test\_commands.py](https://codecov.io/gh/sernst/pipper/pull/5/diff?src=pr&el=tree#diff-cGlwcGVyL3Rlc3QvdGVzdF9jb21tYW5kcy5weQ==) | `100% <100%> (ø)` | |
| [pipper/s3.py](https://codecov.io/gh/sernst/pipper/pull/5/diff?src=pr&el=tree#diff-cGlwcGVyL3MzLnB5) | `34.78% <66.66%> (ø)` | |
| [pipper/versioning.py](https://codecov.io/gh/sernst/pipper/pull/5/diff?src=pr&el=tree#diff-cGlwcGVyL3ZlcnNpb25pbmcucHk=) | `57.25% <81.25%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/sernst/pipper/pull/5?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/sernst/pipper/pull/5?src=pr&el=footer). Last update [6ff102b...894d4e6](https://codecov.io/gh/sernst/pipper/pull/5?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..3880345
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,3 @@
+[run]
+omit =
+    setup.py
diff --git a/conda.dockerfile b/conda.dockerfile
new file mode 100644
index 0000000..383fdef
--- /dev/null
+++ b/conda.dockerfile
@@ -0,0 +1,11 @@
+FROM continuumio/miniconda3:latest
+
+RUN apt-get update \
+ && apt-get install nano \
+ && pip install pip --upgrade
+
+COPY requirements.txt /root/requirements.txt
+
+RUN pip install -r /root/requirements.txt
+
+WORKDIR "/root/"
diff --git a/docker-compose.yaml b/docker-compose.yaml
index f07622f..7c6db5b 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,8 +1,23 @@
 version: "3"
 services:
-  dev:
-    build: .
+  vanilla:
+    build:
+      context: .
+      dockerfile: vanilla.dockerfile
     entrypoint: /bin/bash
+    stdin_open: true
+    tty: true
+    volumes:
+      - ~/.aws:/root/.aws
+      - ./:/root/libraries
+      - ~/.pipper:/root/.pipper
+  conda:
+    build:
+      context: .
+      dockerfile: conda.dockerfile
+    entrypoint: /bin/bash
+    stdin_open: true
+    tty: true
     volumes:
       - ~/.aws:/root/.aws
       - ./:/root/libraries
diff --git a/pipper/__init__.py b/pipper/__init__.py
index 12fc0f7..e69de29 100644
--- a/pipper/__init__.py
+++ b/pipper/__init__.py
@@ -1,14 +0,0 @@
-"""
-This package requires that pip be available for import or many
-operations will fail. So a test import of pip is tried immediately.
-
-The pip library also has a bug (9.0.2) that requires it be imported
-before the requests library is imported or it will fail. This initial
-import serves to maintain order as well until that bug is fixed.
-"""
-
-try:
-    import pip  # noqa
-except ImportError:
-    print('Pipper requires that the pip package be available for import')
-    raise
diff --git a/pipper/authorizer.py b/pipper/authorizer.py
index d054c51..40b93bb 100644
--- a/pipper/authorizer.py
+++ b/pipper/authorizer.py
@@ -12,7 +12,7 @@ DELTA_REGEX = re.compile('(?P<number>[0-9]+)\s*(?P<unit>[a-zA-Z]+)')
 
 
 def to_time_delta(age: str) -> timedelta:
-    """ 
+    """
     Converts an age string into a timedelta object, parsing the number and 
     units of the age. Valid units are:
         * hour, hrs, hr, h
@@ -24,7 +24,6 @@ def to_time_delta(age: str) -> timedelta:
         24mins -> 24 minutes
         3hours -> 3 hours
     """
-
     try:
         result = DELTA_REGEX.search(age)
         unit = result.group('unit').lower()
@@ -41,7 +40,7 @@ def to_time_delta(age: str) -> timedelta:
 
 
 def parse_url(pipper_url: str) -> dict:
-    """ 
+    """
     Parses a wheel url into a dictionary containing information about the wheel 
     file
     """
diff --git a/pipper/bundler.py b/pipper/bundler.py
index c302749..1072ca6 100644
--- a/pipper/bundler.py
+++ b/pipper/bundler.py
@@ -18,7 +18,7 @@ def zip_bundle(
         output_directory: str,
         distribution_data: dict
 ) -> str:
-    """ 
+    """
     Creates a pipper zip file from the temporarily stored meta data and wheel
     files and saves that zip file to the output directory location with the
     pipper extension.
@@ -56,7 +56,7 @@ def create_meta(
         bundle_directory: str,
         distribution_data: dict
 ) -> str:
-    """ 
+    """
     Creates a JSON-formatted metadata file with information about the package 
     being bundled that is saved into the specified output directory.
     
@@ -100,7 +100,7 @@ def create_meta(
 
 
 def create_wheel(package_directory: str, bundle_directory: str) -> dict:
-    """ 
+    """
     Creates a universally wheel distribution of the specified package and
     saves that to the bundle directory.
     
@@ -146,7 +146,7 @@ def create_wheel(package_directory: str, bundle_directory: str) -> dict:
 
 
 def run(env: Environment):
-    """ 
+    """
     Executes the bundling process on the specified package directory and saves
     the pipper bundle file in the specified output directory.
             
diff --git a/pipper/command.py b/pipper/command.py
index 7ed073a..7f2a1d4 100644
--- a/pipper/command.py
+++ b/pipper/command.py
@@ -41,7 +41,6 @@ def show_version(env: Environment):
 
 def run(cli_args: list = None):
     """ """
-
     args = parser.parse(cli_args)
     env = Environment(args)
 
diff --git a/pipper/downloader.py b/pipper/downloader.py
index bab9daa..ad18a03 100644
--- a/pipper/downloader.py
+++ b/pipper/downloader.py
@@ -1,42 +1,24 @@
-import os
 import json
-import zipfile
+import os
 import shutil
-from urllib.parse import urlparse
+import zipfile
 from contextlib import closing
 
 import requests
+
 from pipper import environment
-from pipper import info
 from pipper import versioning
 from pipper import wrapper
 from pipper.environment import Environment
 
 
-def parse_package_url(package_url: str) -> dict:
-    """ """
-
-    url_data = urlparse(package_url)
-    parts = url_data.path.strip('/').split('/')
-    filename = parts[-1]
-    safe_version = filename.rsplit('.', 1)[0]
-
-    return dict(
-        url=package_url,
-        bucket=parts[0],
-        name=parts[-2],
-        safe_version=safe_version,
-        version=versioning.deserialize(safe_version),
-        key='/'.join(parts[1:])
-    )
-
-
 def parse_package_id(
         env: Environment,
         package_id: str,
-        use_latest_version: bool = False
+        use_latest_version: bool = False,
+        include_prereleases: bool = False
 ) -> dict:
-    """ 
+    """
     Parses a package id into its constituent name and version information. If
     the version is not specified as part of the identifier, a version will
     be determined. If the package is already installed and the upgrade flag is
@@ -51,6 +33,9 @@ def parse_package_id(
         name, or a package name and version (NAME:VERSION) combination.
     :param use_latest_version:
         Whether or not to use the latest version.
+    :param include_prereleases:
+        Whether or not to include prelease versions in the list of available
+        packages.
     :return:
         A dictionary containing installation information for the specified
         package. The dictionary has the following fields:
@@ -60,24 +45,37 @@ def parse_package_id(
             - key: S3 key for the remote pipper file where the specified 
                     package name and version reside
     """
-
     if package_id.startswith('https://'):
-        return parse_package_url(package_id)
+        r = versioning.parse_package_url(package_id)
+        return dict(
+            url=r.url,
+            bucket=r.bucket,
+            name=r.package_name,
+            safe_version=r.safe_version,
+            version=r.version,
+            key=r.key
+        )
 
     package_parts = package_id.split(':')
     name = package_parts[0]
     upgrade = use_latest_version or env.args.get('upgrade')
+    unstable = include_prereleases or env.args.get('unstable')
 
     def possible_versions():
-        yield (
-            versioning.find_latest_match(env, *package_parts).version
-            if len(package_parts) > 1 else
-            None
-        )
+        if len(package_parts) > 1:
+            yield versioning.find_latest_match(
+                env,
+                *package_parts,
+                include_prereleases=unstable
+            ).version
         if not upgrade:
             existing = wrapper.status(name)
             yield existing.version if existing else None
-        yield versioning.find_latest_match(env, name).version
+        yield versioning.find_latest_match(
+            env,
+            name,
+            include_prereleases=unstable
+        ).version
 
     try:
         version = next((v for v in possible_versions() if v is not None))
@@ -96,9 +94,7 @@ def parse_package_id(
 
 
 def save(url: str, local_path: str) -> str:
-    """ 
-    """
-
+    """..."""
     with closing(requests.get(url, stream=True)) as response:
         if response.status_code != 200:
             print((
@@ -185,24 +181,18 @@ def download_package(env: Environment, package_id: str) -> str:
 
 
 def download_many(env: Environment, package_ids: list) -> dict:
-    """ 
-    """
-
+    """..."""
     return {pid: download_package(env, pid) for pid in package_ids}
 
 
 def download_from_configs(env: Environment, configs_path: str = None) -> dict:
-    """ 
-    """
-
+    """..."""
     configs = environment.load_configs(configs_path)
     return download_many(env, configs.get('dependencies') or [])
 
 
 def run(env: Environment):
-    """ 
-    """
-
+    """..."""
     package_ids = env.args.get('packages')
     if not package_ids:
         return download_from_configs(env, env.args.get('configs_path'))
diff --git a/pipper/environment.py b/pipper/environment.py
index 8d3b033..be24ccc 100644
--- a/pipper/environment.py
+++ b/pipper/environment.py
@@ -70,7 +70,6 @@ def load_repositories() -> dict:
 
 def save_repositories(config_data: dict) -> dict:
     """ """
-
     directory = os.path.dirname(REPOSITORY_CONFIGS_PATH)
     if not os.path.exists(directory):
         os.makedirs(directory)
@@ -86,7 +85,6 @@ def load_repository(
         allow_default: bool = False
 ) -> dict:
     """ """
-
     results = load_repositories()
 
     try:
@@ -103,7 +101,6 @@ def load_repository(
 
 def load_configs(configs_path: str = None):
     """ """
-
     path = os.path.realpath(
         configs_path or
         os.path.join(os.curdir, 'pipper.json')
@@ -121,11 +118,10 @@ def get_session(
         repository: dict,
         default_repository: dict
 ) -> Session:
-    """ 
+    """
     Creates an S3 session using AWS credentials, which can be specified in a 
     myriad of potential ways.
     """
-
     aws_profile = args.get('aws_profile')
     command_credentials = args.get('aws_credentials') or []
 
diff --git a/pipper/info.py b/pipper/info.py
index 90e81f2..0ccefd5 100644
--- a/pipper/info.py
+++ b/pipper/info.py
@@ -9,55 +9,21 @@ from pipper import versioning
 from pipper.environment import Environment
 
 
-def list_remote_package_keys(
-        env: Environment,
-        package_name: str
-) -> typing.List[str]:
-    """ """
-    return [v.key for v in versioning.list_versions(env, package_name)]
-
-
-def list_remote_version_info(env: Environment, package_name: str) -> list:
-    """ """
-
-    def from_key(key: str) -> dict:
-        filename = key.strip('/').split('/')[-1]
-        safe_version = filename.rsplit('.', 1)[0]
-        return dict(
-            name=package_name,
-            safe_version=safe_version,
-            version=versioning.deserialize(safe_version)
-        )
-
-    versions = [
-        from_key(key)
-        for key in list_remote_package_keys(env, package_name)
-    ]
-
-    def compare_versions(a: dict, b: dict) -> int:
-        return semver.compare(a['version'], b['version'])
-
-    return sorted(versions, key=functools.cmp_to_key(compare_versions))
-
-
 def get_package_metadata(
         env: Environment,
         package_name: str,
         package_version: str
 ):
     """ """
-
     response = env.s3_client.head_object(
         Bucket=env.bucket,
         Key=versioning.make_s3_key(package_name, package_version)
     )
-
     return {key: value for key, value in response['Metadata'].items()}
 
 
 def print_local_only(package_name: str):
     """ """
-
     print('[PACKAGE]: {}'.format(package_name))
 
     local_data = wrapper.status(package_name)
@@ -73,14 +39,13 @@ def print_local_only(package_name: str):
 
 def print_with_remote(env: Environment, package_name: str):
     """ """
-
-    remote_versions = list_remote_version_info(env, package_name)
+    remote_versions = versioning.list_versions(env, package_name)
 
     try:
         latest = get_package_metadata(
             env,
             package_name,
-            remote_versions[-1]['version']
+            remote_versions[-1].version
         )
     except IndexError:
         latest = dict(
@@ -125,13 +90,11 @@ def print_with_remote(env: Environment, package_name: str):
 
 
 def run(env: Environment):
-    """ """
-
+    """Executes an info command for the specified environment."""
     local_only = env.args.get('local_only')
     package_name = env.args.get('package_name')
 
     if local_only:
         return print_local_only(package_name)
-
     return print_with_remote(env, package_name)
 
diff --git a/pipper/installer.py b/pipper/installer.py
index c722473..54efba2 100644
--- a/pipper/installer.py
+++ b/pipper/installer.py
@@ -13,9 +13,9 @@ from pipper.environment import Environment
 def install_pipper_file(
         local_source_path: str,
         to_user: bool = False,
-        target: str = None,
+        target_directory: str = None
 ) -> dict:
-    """ 
+    """
     Installs the specified local pipper bundle file.
     
     :param local_source_path:
@@ -23,27 +23,32 @@ def install_pipper_file(
     :param to_user:
         Whether or not to install the package for the user or not. If not a
         user package, the package will be installed globally.
-    :param target:
+    :param target_directory:
         Alternate installation location if specified.
     :return
         The package metadata from the pipper bundle
     """
-
     directory = tempfile.mkdtemp(prefix='pipper-install-')
 
-    extracted = downloader.extract_pipper_file(
-        local_source_path,
-        directory
-    )
-
-    wrapper.install_wheel(extracted['wheel_path'], to_user, target)
-    shutil.rmtree(directory)
-
-    return extracted['metadata']
+    try:
+        extracted = downloader.extract_pipper_file(
+            local_source_path,
+            directory
+        )
+        wrapper.install_wheel(
+            wheel_path=extracted['wheel_path'],
+            to_user=to_user,
+            target_directory=target_directory
+        )
+        return extracted['metadata']
+    except Exception:
+        raise
+    finally:
+        shutil.rmtree(directory)
 
 
 def install_dependencies(env: Environment, dependencies: typing.List[str]):
-    """ 
+    """
     
     :param env:
         Command environment in which this function is being executed
@@ -53,7 +58,6 @@ def install_dependencies(env: Environment, dependencies: typing.List[str]):
         (NAME:VERSION) combination, but this version information is ignored for
         dependencies.
     """
-
     def do_install(package_name: str):
         try:
             data = downloader.parse_package_id(env, package_name)
@@ -77,7 +81,6 @@ def install(env: Environment, package_id: str):
         Identifier for the package to be loaded. This can be either a package
         name, or a package name and version (NAME:VERSION) combination.
     """
-
     upgrade = env.args.get('upgrade')
     data = downloader.parse_package_id(env, package_id)
     is_url = 'url' in data
@@ -98,8 +101,8 @@ def install(env: Environment, package_id: str):
         return
 
     remote_version_exists = (
-        is_url or
-        s3.key_exists(env.s3_client, data['bucket'], data['key'])
+        is_url
+        or s3.key_exists(env.s3_client, data['bucket'], data['key'])
     )
 
     if not remote_version_exists:
@@ -125,9 +128,9 @@ def install(env: Environment, package_id: str):
 
     try:
         metadata = install_pipper_file(
-            path,
+            local_source_path=path,
             to_user=env.args.get('pip_user'),
-            target=env.args.get('target'),
+            target_directory=env.args.get('target_directory')
         )
     except Exception:
         raise
@@ -139,7 +142,7 @@ def install(env: Environment, package_id: str):
 
 
 def install_many(env: Environment, package_ids: typing.List[str]):
-    """ 
+    """
     Installs a list of package identifiers, which can be either package names
     or package name and version combinations.
     
@@ -149,13 +152,12 @@ def install_many(env: Environment, package_ids: typing.List[str]):
         A list of package names or package name and version combinations to
         install
     """
-
-    for package_id in package_ids:
+    for package_id in (package_ids or []):
         install(env, package_id)
 
 
 def install_from_configs(env: Environment, configs_path: str = None):
-    """ 
+    """
     Installs pipper dependencies specified in a pipper configs file. If the
     path to the configs file is not specified, the default path will be used
     instead. The default location is a pipper.json file in the current
@@ -168,22 +170,30 @@ def install_from_configs(env: Environment, configs_path: str = None):
         path will be used instead
     """
     to_user = env.args.get('pip_user')
-    target = env.args.get('target')
+    target_directory = env.args.get('target_directory')
     configs = environment.load_configs(configs_path)
 
     for package in configs.get('pypi', []):
         print('\n=== PYPI {} ==='.format(package))
-        wrapper.install_pypi(package, to_user=to_user, target=target)
+        wrapper.install_pypi(
+            package_name=package,
+            to_user=to_user,
+            target_directory=target_directory
+        )
 
     for package in configs.get('conda', []):
         print('\n=== CONDA {} ==='.format(package))
-        wrapper.install_conda(package, to_user=to_user, target=target)
+        wrapper.install_conda(
+            package=package,
+            to_user=to_user,
+            target_directory=target_directory
+        )
 
     return install_many(env, configs.get('dependencies'))
 
 
 def run(env: Environment):
-    """ 
+    """
     Executes an installation command action under the given environmental
     conditions. If a packages argument is specified and contains one or more
     package IDs, they will be installed. If a path to a JSON pipper configs 
@@ -194,7 +204,6 @@ def run(env: Environment):
     :param env:
         Command environment in which this function is being executed
     """
-
     packages = env.args.get('packages')
     if packages:
         return install_many(env, packages)
diff --git a/pipper/parser.py b/pipper/parser.py
index b943ebb..f879b67 100644
--- a/pipper/parser.py
+++ b/pipper/parser.py
@@ -1,5 +1,5 @@
-import os
 import argparse
+import os
 from argparse import ArgumentParser
 
 package_directory = os.path.dirname(os.path.realpath(__file__))
@@ -7,7 +7,6 @@ package_directory = os.path.dirname(os.path.realpath(__file__))
 
 def required_length(minimum: int, maximum: int, optional: bool = False):
     """Returns a custom required length class"""
-
     class RequiredLength(argparse.Action):
         def __call__(self, parser, args, values, option_string=None):
             is_allowed = (
@@ -15,7 +14,7 @@ def required_length(minimum: int, maximum: int, optional: bool = False):
                 (optional and not len(values))
             )
 
-            if  is_allowed:
+            if is_allowed:
                 setattr(args, self.dest, values)
                 return
 
@@ -32,7 +31,6 @@ def required_length(minimum: int, maximum: int, optional: bool = False):
 
 def read_file(*args) -> str:
     """ """
-
     path = os.path.join(package_directory, *args)
     with open(path, 'r') as f:
         return f.read()
@@ -40,7 +38,6 @@ def read_file(*args) -> str:
 
 def populate_with_common(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.add_argument(
         '-q', '--quiet',
         dest='quiet',
@@ -54,7 +51,6 @@ def populate_with_common(parser: ArgumentParser) -> ArgumentParser:
 
 def populate_with_credentials(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.add_argument(
         '-r', '--repository',
         dest='repository_name',
@@ -86,7 +82,6 @@ def populate_with_credentials(parser: ArgumentParser) -> ArgumentParser:
 
 def populate_install(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.description = read_file('resources', 'install_action.txt')
 
     parser.add_argument(
@@ -108,12 +103,21 @@ def populate_install(parser: ArgumentParser) -> ArgumentParser:
 
     parser.add_argument(
         '-t', '--target',
-        dest='target',
-        help='Install packages into the specified directory.',
+        dest='target_directory',
+        metavar='<dir>',
+        help=(
+            'Install packages into <dir>. By default this '
+            'will not replace existing files/folders in '
+            '<dir>. Use --upgrade to replace existing '
+            'packages in <dir> with new versions. '
+            'When applied to conda packages, this should '
+            'be the root directory of the conda environment '
+            'where the libraries will be installed.'
+        )
     )
 
     parser.add_argument(
-        '-u', '--upgrade',
+        '-u', '--upgrade', '--update',
         dest='upgrade',
         action='store_true',
         default=False,
@@ -125,7 +129,6 @@ def populate_install(parser: ArgumentParser) -> ArgumentParser:
 
 def populate_bundle(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.description = read_file('resources', 'bundle_action.txt')
 
     parser.add_argument('package_directory')
@@ -139,7 +142,6 @@ def populate_bundle(parser: ArgumentParser) -> ArgumentParser:
 
 def populate_publish(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.description = read_file('resources', 'publish_action.txt')
 
     parser.add_argument(
@@ -171,7 +173,6 @@ def populate_publish(parser: ArgumentParser) -> ArgumentParser:
 
 def populate_info(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.description = read_file('resources', 'info_action.txt')
 
     parser.add_argument(
@@ -192,7 +193,6 @@ def populate_info(parser: ArgumentParser) -> ArgumentParser:
 
 def populate_download(parser: ArgumentParser) -> ArgumentParser:
     """ """
-
     parser.description = read_file('resources', 'download_action.txt')
 
     parser.add_argument(
@@ -297,8 +297,14 @@ def populate_authorize(parser: ArgumentParser) -> ArgumentParser:
 
 
 def parse(cli_args: list = None) -> dict:
-    """ """
-
+    """
+    Parses command line arguments for consumption by the invoked action
+    and returns the parsed arguments as a dictionary.
+
+    :param cli_args:
+        Overrides the command line argument inputs. By default the sys args
+        will be used.
+    """
     parser = ArgumentParser(
         description=read_file('resources', 'command_description.txt'),
         add_help=True
diff --git a/pipper/resources/command_description.txt b/pipper/resources/command_description.txt
index 6547d5b..6da1cf3 100644
--- a/pipper/resources/command_description.txt
+++ b/pipper/resources/command_description.txt
@@ -1,3 +1,4 @@
 Pipper is a package manager built on Python's Pip package installer that
 provides a lightweight package management system for custom packages that
-you do not want distributed through pypi.
\ No newline at end of file
+you do not want distributed through pypi that requires only an S3 bucket
+for hosting.
diff --git a/pipper/s3.py b/pipper/s3.py
index 9cafacd..4f83d41 100644
--- a/pipper/s3.py
+++ b/pipper/s3.py
@@ -1,14 +1,13 @@
-import os
-
 import typing
+
 from boto3.session import Session
+from botocore.client import BaseClient
 
 
 def session_from_credentials_list(
         credentials: list
 ) -> typing.Union[Session, None]:
     """ """
-
     is_valid = (
         credentials and
         len(credentials) > 1 and
@@ -42,7 +41,6 @@ def session_from_profile_name(
 
 def key_exists(s3_client, bucket: str, key: str) -> bool:
     """ """
-
     try:
         response = s3_client.list_objects(
             Bucket=bucket,
@@ -52,3 +50,18 @@ def key_exists(s3_client, bucket: str, key: str) -> bool:
         return len(response['Contents']) > 0
     except Exception:
         return False
+
+
+def list_objects(
+        execution_identifier: str,
+        s3_client: BaseClient,
+        bucket: str,
+        prefix: str,
+        **kwargs
+) -> dict:
+    """..."""
+    return s3_client.list_objects_v2(
+        Bucket=bucket,
+        Prefix=prefix,
+        **kwargs
+    )
diff --git a/pipper/settings.json b/pipper/settings.json
index ff99585..156772d 100644
--- a/pipper/settings.json
+++ b/pipper/settings.json
@@ -1,3 +1,3 @@
 {
-  "version": "0.4.0"
+  "version": "0.5.0"
 }
diff --git a/pipper/versioning.py b/pipper/versioning.py
deleted file mode 100644
index 027436a..0000000
--- a/pipper/versioning.py
+++ /dev/null
@@ -1,221 +0,0 @@
-import typing
-import pkg_resources
-
-import semver
-
-from pipper.environment import Environment
-
-
-class RemoteVersion(object):
-    """
-    Data structure for storing information about remote data sources
-    """
-
-    def __init__(self, bucket: str, key: str):
-        """_ doc..."""
-        self._key = key
-        self._bucket = bucket
-
-    @property
-    def key(self) -> str:
-        return self._key
-
-    @property
-    def bucket(self) -> str:
-        return self._bucket
-
-    @property
-    def package_name(self) -> str:
-        return self._key.strip('/').split('/')[1]
-
-    @property
-    def filename(self) -> str:
-        return self.key.rsplit('/', 1)[-1]
-
-    @property
-    def version(self) -> str:
-        return deserialize(self.key.rsplit('/', 1)[-1].rsplit('.', 1)[0])
-
-    @property
-    def safe_version(self) -> str:
-        return self.key.rsplit('/', 1)[-1].rsplit('.', 1)[0]
-
-    def __lt__(self, other):
-        return semver.compare(self.version, other.version) < 0
-
-    def __le__(self, other):
-        return semver.compare(self.version, other.version) != 1
-
-    def __gt__(self, other):
-        return semver.compare(self.version, other.version) > 0
-
-    def __ge__(self, other):
-        return semver.compare(self.version, other.version) != -1
-
-    def __eq__(self, other):
-        return semver.compare(self.version, other.version) == 0
-
-    def __repr__(self):
-        return '<{} {}:{}>'.format(
-            self.__class__.__name__,
-            self.package_name,
-            self.version
-        )
-
-
-def serialize(version: str) -> str:
-    """ """
-    try:
-        info = semver.parse_version_info(version)
-    except ValueError:
-        raise ValueError('Invalid semantic version "{}"'.format(version))
-
-    pre = info.prerelease.replace('.', '_') if info.prerelease else None
-    build = info.build.replace('.', '_') if info.build else None
-
-    return 'v{}-{}-{}{}{}'.format(
-        info.major,
-        info.minor,
-        info.patch,
-        '-p-{}'.format(pre) if pre else '',
-        '-b-{}'.format(build) if build else ''
-    )
-
-
-def deserialize(version: str) -> str:
-    """ """
-    return (
-        version
-        .lstrip('v')
-        .replace('-', '.', 2)
-        .replace('-p-', '-')
-        .replace('-b-', '+')
-    )
-
-
-def make_s3_key(package_name: str, package_version: str) -> str:
-    """ """
-
-    safe_version = (
-        serialize(package_version)
-        if not package_version.startswith('v') else
-        package_version
-    )
-
-    return 'pipper/{}/{}.pipper'.format(package_name, safe_version)
-
-
-def parse_s3_key(key: str) -> dict:
-    """ """
-
-    parts = key.strip('/').split('/')
-    safe_version = parts[2].rsplit('.', 1)[0]
-
-    return dict(
-        name=parts[1],
-        safe_version=safe_version,
-        version=deserialize(safe_version)
-    )
-
-
-def list_versions(
-        environment: Environment,
-        package_name: str,
-        version_prefix: str = None
-) -> typing.List[RemoteVersion]:
-    """..."""
-    client = environment.aws_session.client('s3')
-    prefix = (
-        (version_prefix or '')
-        .lstrip('v')
-        .replace('.', '-')
-        .split('*')[0]
-    )
-    key_prefix = 'pipper/{}/v{}'.format(package_name, prefix)
-
-    responses = []
-    while not responses or responses[-1].get('NextContinuationToken'):
-        continuation_kwargs = (
-            {'ContinuationToken': responses[-1].get('NextContinuationToken')}
-            if responses else
-            {}
-        )
-        responses.append(client.list_objects_v2(
-            Bucket=environment.bucket,
-            Prefix=key_prefix,
-            **continuation_kwargs
-        ))
-
-    results = [
-        RemoteVersion(key=entry['Key'], bucket=environment.bucket)
-        for response in responses
-        for entry in response.get('Contents')
-        if entry['Key'].endswith('.pipper')
-    ]
-
-    return sorted(results)
-
-
-def compare_constraint(version: str, constraint: str) -> int:
-    """Returns comparison between versions"""
-    if version == constraint:
-        return 0
-
-    version_parts = version.split('.')
-    constraint_parts = constraint.split('.')
-
-    def compare_part(a: str, b: str) -> int:
-        if a == b or a == '*' or b == '*':
-            return 0
-        return -1 if int(a) < int(b) else 1
-
-    comparisons = [
-        compare_part(v, c)
-        for v, c in zip(version_parts, constraint_parts)
-    ]
-
-    return next((c for c in comparisons if c != 0), 0)
-
-
-def find_latest_match(
-        environment: Environment,
-        package_name: str,
-        version_constraint: str = None
-) -> typing.Union[RemoteVersion, None]:
-    """..."""
-
-    available = list_versions(environment, package_name)
-    available.reverse()
-
-    if not version_constraint:
-        return available[0]
-
-    comparison = version_constraint.strip('=<>')
-
-    if version_constraint.startswith('<='):
-        choices = (
-            a for a in available
-            if compare_constraint(a.version, comparison) != 1
-        )
-    elif version_constraint.startswith('<'):
-        choices = (
-            a for a in available
-            if compare_constraint(a.version, comparison) < 0
-        )
-    elif version_constraint.startswith('>='):
-        choices = (
-            a for a in available
-            if compare_constraint(a.version, comparison) != -1
-        )
-    elif version_constraint.startswith('>'):
-        choices = (
-            a for a in available
-            if compare_constraint(a.version, comparison) > 0
-        )
-    else:
-        choices = (
-            a for a in available
-            if compare_constraint(a.version, comparison) == 0
-        )
-
-    return next(choices, None)
diff --git a/pipper/versioning/__init__.py b/pipper/versioning/__init__.py
new file mode 100644
index 0000000..7536078
--- /dev/null
+++ b/pipper/versioning/__init__.py
@@ -0,0 +1,224 @@
+import typing
+from urllib.parse import urlparse
+
+from pipper import s3
+from pipper.environment import Environment
+from pipper.versioning.definitions import RemoteVersion
+from pipper.versioning.serde import deserialize
+from pipper.versioning.serde import deserialize_prefix
+from pipper.versioning.serde import explode
+from pipper.versioning.serde import serialize
+from pipper.versioning.serde import serialize_prefix
+
+
+def to_remote_version(
+        package_name: str,
+        package_version: str,
+        bucket: str
+) -> RemoteVersion:
+    """
+    Converts the constituent properties of a pipper remote file into a
+    RemoteVersion object.
+    """
+    return RemoteVersion(
+        bucket=bucket,
+        key=make_s3_key(package_name, package_version)
+    )
+
+
+def parse_package_url(package_url: str) -> RemoteVersion:
+    """
+    Parses a standard S3 URL of the format:
+
+    `https://s3.amazonaws.com/bucket-name/pipper/package-name/v0-0-18.pipper`
+
+    into a RemoteVersion object.
+    """
+    url_data = urlparse(package_url)
+    parts = url_data.path.strip('/').split('/', 1)
+    return RemoteVersion(bucket=parts[0], key=parts[1], url=package_url)
+
+
+def make_s3_key(package_name: str, package_version: str) -> str:
+    """
+    Converts a package name and version into a fully-qualified S3 key to the
+    location where the file resides in the hosting S3 bucket. The package
+    version must be a complete semantic version but can be serialized or not.
+    """
+    safe_version = (
+        serialize(package_version)
+        if not package_version.startswith('v') else
+        package_version
+    )
+    return 'pipper/{}/{}.pipper'.format(package_name, safe_version)
+
+
+def list_versions(
+        environment: Environment,
+        package_name: str,
+        version_prefix: str = None,
+        include_prereleases: bool = False,
+        reverse: bool = False
+) -> typing.List[RemoteVersion]:
+    """
+    Lists the available versions of the specified package by querying the
+    remote S3 storage and returns those as keys. The results are sorted in
+    order of increasing version unless `reverse` is True in which case the
+    returned list is sorted from highest version to lowest one.
+
+    By default, only stable releases are returned, but pre-releases can be
+    included as well if the `include_prereleases` argument is set to True.
+
+    :param environment:
+        Context object for the currently running command invocation.
+    :param package_name:
+        Name of the pipper package to list versions of.
+    :param version_prefix:
+        A constraining version prefix, which may include wildcard characters.
+        Constraints are hierarchical, which means satisfying the highest
+        level constraint automatically satisfies the subsequent ones.
+        Therefore, a constraint like `1.*.4` would ignore the `4` patch value.
+    :param reverse:
+        Whether or not to reverse the order of the returned results.
+    :param include_prereleases:
+        Whether or not to include pre-release versions in the results.
+    """
+    prefix = serialize_prefix(version_prefix or '').split('*')[0]
+    key_prefix = 'pipper/{}/v{}'.format(package_name, prefix)
+
+    responses = []
+    while not responses or responses[-1].get('NextContinuationToken'):
+        continuation_kwargs = (
+            {'ContinuationToken': responses[-1].get('NextContinuationToken')}
+            if responses else
+            {}
+        )
+        responses.append(s3.list_objects(
+            execution_identifier='list_versions',
+            s3_client=environment.s3_client,
+            bucket=environment.bucket,
+            prefix=key_prefix,
+            **continuation_kwargs
+        ))
+
+    results = [
+        RemoteVersion(key=entry['Key'], bucket=environment.bucket)
+        for response in responses
+        for entry in response.get('Contents')
+        if entry['Key'].endswith('.pipper')
+    ]
+
+    return [
+        r for r in sorted(results, reverse=reverse)
+        if not r.is_prerelease or include_prereleases
+    ]
+
+
+def compare_constraint(version: str, constraint: str) -> int:
+    """
+    Returns an integer representing the sortable comparison between two
+    versions using standard sorting values:
+        -1 (version is less than constraint)
+        0 (version is equal to constraint)
+        1 (version is greater than constraint)
+    The use-case is to compare a version against a version
+    constraint to determine how the version satisfies the constraint.
+    """
+    if version == constraint:
+        return 0
+
+    version_parts = explode(version)
+    constraint_parts = explode(constraint)
+
+    def compare_part(v: str, c: str) -> int:
+        print(version_parts, constraint_parts)
+        is_equal = (
+            v == c  # direct match
+            or '*' in [v, c]  # one is a wildcard
+            or c == ''  # no constraint specified
+        )
+        if is_equal:
+            return 0
+
+        if v == '':
+            return -1
+
+        a = ''.join([x.zfill(32) for x in v.split('.')])
+        b = ''.join([x.zfill(32) for x in c.split('.')])
+        items = sorted([a, b])
+        return -1 if items.index(a) == 0 else 1
+
+    comparisons = (
+        compare_part(v, c)
+        for v, c in zip(version_parts, constraint_parts)
+    )
+
+    return next((c for c in comparisons if c != 0), 0)
+
+
+def find_latest_match(
+        environment: Environment,
+        package_name: str,
+        version_constraint: str = None,
+        include_prereleases: bool = False
+) -> typing.Union[RemoteVersion, None]:
+    """
+    Searches through available remote versions of the specified package and
+    returns the highest version that satisfies the specified version
+    constraint. If no constraint is specified, the highest version available
+    is returned. If no match is found, a `None` value is returned instead.
+
+    :param environment:
+        Context object for the currently running command invocation.
+    :param package_name:
+        Name of the pipper package to list versions of.
+    :param version_constraint:
+        A constraining version or partial version, which may include wildcard
+        characters. Constraints are hierarchical, which means satisfying the
+        highest level constraint automatically satisfies the subsequent ones.
+        Therefore, a constraint like `=1.*.4` would ignore the `4` patch value.
+        Constraints should be prefixed by an equality such as `<`, `<=`, `=`,
+        `>=` or `>`.
+    :param include_prereleases:
+        Whether or not to include pre-release versions when looking for a
+        match.
+    """
+    available = list_versions(
+        environment=environment,
+        package_name=package_name,
+        reverse=True,
+        include_prereleases=include_prereleases
+    )
+
+    if not version_constraint:
+        return available[0]
+
+    comparison = version_constraint.strip('=<>')
+
+    if version_constraint.startswith('<='):
+        choices = (
+            a for a in available
+            if compare_constraint(a.version, comparison) != 1
+        )
+    elif version_constraint.startswith('<'):
+        choices = (
+            a for a in available
+            if compare_constraint(a.version, comparison) < 0
+        )
+    elif version_constraint.startswith('>='):
+        choices = (
+            a for a in available
+            if compare_constraint(a.version, comparison) != -1
+        )
+    elif version_constraint.startswith('>'):
+        choices = (
+            a for a in available
+            if compare_constraint(a.version, comparison) > 0
+        )
+    else:
+        choices = (
+            a for a in available
+            if compare_constraint(a.version, comparison) == 0
+        )
+
+    return next(choices, None)
diff --git a/pipper/versioning/definitions.py b/pipper/versioning/definitions.py
new file mode 100644
index 0000000..9957c15
--- /dev/null
+++ b/pipper/versioning/definitions.py
@@ -0,0 +1,80 @@
+import semver
+
+from pipper.versioning import serde
+
+
+class RemoteVersion(object):
+    """
+    Data structure for storing information about remote data sources.
+    """
+
+    def __init__(self, bucket: str, key: str, url: str = None):
+        """_ doc..."""
+        self._key = key
+        self._bucket = bucket
+        self._url = url
+
+    @property
+    def key(self) -> str:
+        return self._key
+
+    @property
+    def bucket(self) -> str:
+        return self._bucket
+
+    @property
+    def package_name(self) -> str:
+        return self._key.strip('/').split('/')[1]
+
+    @property
+    def filename(self) -> str:
+        return self.key.rsplit('/', 1)[-1]
+
+    @property
+    def version(self) -> str:
+        return serde.deserialize(self.key.rsplit('/', 1)[-1].rsplit('.', 1)[0])
+
+    @property
+    def is_url_based(self) -> bool:
+        return bool(self._url is not None)
+
+    @property
+    def safe_version(self) -> str:
+        return self.key.rsplit('/', 1)[-1].rsplit('.', 1)[0]
+
+    @property
+    def url(self) -> str:
+        standard_url = '/'.join([
+            'https://s3.amazonaws.com',
+            self.bucket,
+            'pipper',
+            self.package_name,
+            '{}.pipper'.format(self.safe_version)
+        ])
+        return self._url or standard_url
+
+    @property
+    def is_prerelease(self) -> bool:
+        return self.version.find('-') != -1
+
+    def __lt__(self, other):
+        return semver.compare(self.version, other.version) < 0
+
+    def __le__(self, other):
+        return semver.compare(self.version, other.version) != 1
+
+    def __gt__(self, other):
+        return semver.compare(self.version, other.version) > 0
+
+    def __ge__(self, other):
+        return semver.compare(self.version, other.version) != -1
+
+    def __eq__(self, other):
+        return semver.compare(self.version, other.version) == 0
+
+    def __repr__(self):
+        return '<{} {}:{}>'.format(
+            self.__class__.__name__,
+            self.package_name,
+            self.version
+        )
diff --git a/pipper/versioning/serde.py b/pipper/versioning/serde.py
new file mode 100644
index 0000000..d1a53a5
--- /dev/null
+++ b/pipper/versioning/serde.py
@@ -0,0 +1,115 @@
+import semver
+
+
+def explode(version_prefix: str) -> tuple:
+    """
+    Breaks apart a semantic version or partial semantic version string into
+    its constituent elements and returns them as a tuple of strings. Any
+    missing elements will be returned as empty strings.
+
+    :param version_prefix:
+        A semantic version or part of a semantic version, which can include
+        wildcard characters.
+    """
+    sections = []
+    remainder = version_prefix.rstrip('.')
+    for separator in ('+', '-'):
+        parts = remainder.split(separator, 1)
+        remainder = parts[0]
+        section = parts[1] if len(parts) == 2 else ''
+        sections.insert(0, section)
+
+    parts = remainder.split('.')
+    parts.extend(['', ''])
+    sections = parts[:3] + sections
+
+    return tuple(sections)
+
+
+def serialize(version: str) -> str:
+    """
+    Converts the specified semantic version into a URL/filesystem safe
+    version. If the version argument is not a valid semantic version a
+    ValueError will be raised.
+    """
+    try:
+        semver.parse_version_info(version)
+    except ValueError:
+        raise ValueError('Invalid semantic version "{}"'.format(version))
+
+    return serialize_prefix(version)
+
+
+def serialize_prefix(version_prefix: str) -> str:
+    """
+    Serializes the specified prefix into a URL/filesystem safe version that
+    can be used as a filename to store the versioned bundle.
+
+    :param version_prefix:
+        A partial or complete semantic version to be converted into its
+        URL/filesystem equivalent.
+    """
+    if version_prefix.startswith('v'):
+        return version_prefix
+
+    sections = [part.replace('.', '_') for part in explode(version_prefix)]
+    prefix = '-'.join([section for section in sections[:3] if section])
+    if sections[3]:
+        prefix += '__pre_{}'.format(sections[3])
+    if sections[4]:
+        prefix += '__build_{}'.format(sections[4])
+
+    return 'v{}'.format(prefix) if prefix else ''
+
+
+def deserialize_prefix(safe_version_prefix: str) -> str:
+    """
+    Deserializes the specified prefix from a URL/filesystem safe version into
+    its standard semantic version equivalent.
+
+    :param safe_version_prefix:
+        A partial or complete URL/filesystem safe version prefix to convert
+        into a standard semantic version prefix.
+    """
+    if not safe_version_prefix.startswith('v'):
+        return safe_version_prefix
+
+    searches = [
+        ('__build_', 'split'),
+        ('__pre_', 'split'),
+        ('-', 'rsplit'),
+        ('-', 'rsplit')
+    ]
+
+    sections = []
+    remainder = safe_version_prefix.strip('v').rstrip('_')
+    for separator, operator in searches:
+        parts = getattr(remainder, operator)(separator, 1)
+        remainder = parts[0]
+        section = parts[1] if len(parts) == 2 else ''
+        sections.insert(0, section.replace('_', '.'))
+    sections.insert(0, remainder)
+
+    prefix = '.'.join([section for section in sections[:3] if section])
+    if sections[3]:
+        prefix += '-{}'.format(sections[3])
+    if sections[4]:
+        prefix += '+{}'.format(sections[4])
+
+    return prefix
+
+
+def deserialize(safe_version: str) -> str:
+    """
+    Converts the specified URL/filesystem safe version into a standard semantic
+    version. If the converted output is not a valid semantic version a
+    ValueError will be raised.
+    """
+    result = deserialize_prefix(safe_version)
+
+    try:
+        semver.parse_version_info(result)
+    except ValueError:
+        raise ValueError('Invalid semantic version "{}"'.format(result))
+
+    return result
diff --git a/pipper/wrapper.py b/pipper/wrapper.py
index b53cd93..439108a 100644
--- a/pipper/wrapper.py
+++ b/pipper/wrapper.py
@@ -1,3 +1,4 @@
+import os
 import subprocess
 import sys
 import typing
@@ -25,6 +26,12 @@ def update_required(package_name: str, install_version: str) -> bool:
     return current != target
 
 
+def clean_path(path: str) -> str:
+    """Returns a fully-qualified absolute path for the source"""
+    path = os.path.expanduser(path) if path.startswith('~') else path
+    return os.path.realpath(path)
+
+
 def status(package_name: str):
     """ """
     try:
@@ -38,7 +45,7 @@ def status(package_name: str):
 def install_wheel(
         wheel_path: str,
         to_user: bool = False,
-        target: str = None,
+        target_directory: str = None
 ):
     """
     Installs the specified wheel using the pip associated with the
@@ -48,11 +55,14 @@ def install_wheel(
         sys.executable,
         '-m', 'pip',
         'install', wheel_path,
-        '--user' if to_user else None,
-        '--target={}'.format(target) if target else None,
     ]
-    cmd = [c for c in cmd if c is not None]
-    print('COMMAND:', ' '.join(cmd))
+    cmd += ['--user'] if to_user else []
+    cmd += (
+        ['--target={}'.format(clean_path(target_directory))]
+        if target_directory else
+        []
+    )
+    print('[COMMAND]:\n', ' '.join(cmd).replace(' --', '\n  --'))
 
     result = subprocess.run(cmd)
     result.check_returncode()
@@ -61,7 +71,7 @@ def install_wheel(
 def install_pypi(
         package_name: str,
         to_user: bool = False,
-        target: str = None,
+        target_directory: str = None
 ):
     """
     Installs the specified package from pypi using pip.
@@ -70,11 +80,14 @@ def install_pypi(
         sys.executable,
         '-m', 'pip',
         'install', package_name,
-        '--user' if to_user else None,
-        '--target={}'.format(target) if target else None,
     ]
-    cmd = [c for c in cmd if c is not None]
-    print('COMMAND:', ' '.join(cmd))
+    cmd += ['--user'] if to_user else []
+    cmd += (
+        ['--target={}'.format(clean_path(target_directory))]
+        if target_directory else
+        []
+    )
+    print('[COMMAND]:\n', ' '.join(cmd).replace(' --', '\n  --'))
 
     result = subprocess.run(cmd)
     result.check_returncode()
@@ -83,7 +96,7 @@ def install_pypi(
 def install_conda(
         package: typing.Union[str, dict],
         to_user: bool = False,
-        target: str = None,
+        target_directory: str = None
 ):
     """
     Installs the specified package using conda.
@@ -102,8 +115,12 @@ def install_conda(
     ]
     cmd += ['--channel', channel] if channel else []
     cmd += ['--user'] if to_user else []
-    cmd += ['--target', target] if target else []
-    print('COMMAND:', ' '.join(cmd))
+    cmd += (
+        ['--prefix={}'.format(clean_path(target_directory))]
+        if target_directory else
+        []
+    )
+    print('[COMMAND]:\n', ' '.join(cmd).replace(' --', '\n  --'))
 
     result = subprocess.run(cmd)
     result.check_returncode()
diff --git a/requirements.txt b/requirements.txt
index a1054cd..f39df9e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,8 @@
+pip
 requests
 wheel
 setuptools
 semver
 boto3
+pytest
+pytest-runner
diff --git a/setup.py b/setup.py
index 20114ef..94b74ec 100644
--- a/setup.py
+++ b/setup.py
@@ -30,7 +30,6 @@ def populate_extra_files():
     """
     Creates a list of non-python data files to include in package distribution
     """
-
     glob_path = os.path.join(MY_DIRECTORY, PACKAGE_NAME, '**', '*.txt')
     return (
         [SETTINGS_PATH] +

Enhanced Versioning
Enhance versioning to better support pre-release and build options in semantic versioning.

diff --git a/pipper/test/__init__.py b/pipper/test/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/pipper/test/__init__.py
@@ -0,0 +1,1 @@
+
diff --git a/pipper/test/commands/__init__.py b/pipper/test/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipper/test/commands/test_install.py b/pipper/test/commands/test_install.py
new file mode 100644
index 0000000..b106549
--- /dev/null
+++ b/pipper/test/commands/test_install.py
@@ -0,0 +1,15 @@
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from pipper import command
+from pipper.test import utils
+
+
+@patch('pipper.installer.install')
+@utils.PatchSession()
+def test_install(
+        boto_mocks: utils.BotoMocks,
+        install: MagicMock
+):
+    """..."""
+    command.run(['install', 'foo'])
diff --git a/pipper/test/test_authorizer.py b/pipper/test/test_authorizer.py
new file mode 100644
index 0000000..2f8e2a3
--- /dev/null
+++ b/pipper/test/test_authorizer.py
@@ -0,0 +1,19 @@
+import pytest
+
+
+scenarios = [
+    ('72s', 72),
+    ('85sec', 85),
+    ('92Seconds', 92),
+    ('1m', 60),
+    ('5min', 300),
+    ('2MINUTES', 120),
+    ('1hr', 3600),
+    ('10hrs', 36000),
+    ('100hours', 360000)
+]
+
+
+@pytest.mark.parametrize('age,total_seconds')
+def to_time_delta(age: str, total_seconds: int):
+    """Should convert the age to the expected number of seconds"""
\ No newline at end of file
diff --git a/pipper/test/test_commands.py b/pipper/test/test_commands.py
new file mode 100644
index 0000000..6a631e4
--- /dev/null
+++ b/pipper/test/test_commands.py
@@ -0,0 +1,25 @@
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from pipper import command
+from pipper.test import utils
+
+
+@patch('pipper.s3.list_objects')
+@utils.PatchSession()
+def test_info(boto_mocks: utils.BotoMocks, list_objects: MagicMock):
+    """..."""
+    list_objects.side_effect = utils.affect_by_identifier(
+        list_versions=utils.make_list_objects_response(contents=[])
+    )
+    command.run(['info', 'fake-package'])
+
+
+@patch('pipper.s3.list_objects')
+@utils.PatchSession()
+def test_info_local(boto_mocks: utils.BotoMocks, list_objects: MagicMock):
+    """..."""
+    list_objects.side_effect = utils.affect_by_identifier(
+        list_versions=utils.make_list_objects_response(contents=[])
+    )
+    command.run(['info', 'fake-package', '--local'])
diff --git a/pipper/test/utils.py b/pipper/test/utils.py
new file mode 100644
index 0000000..2276c71
--- /dev/null
+++ b/pipper/test/utils.py
@@ -0,0 +1,70 @@
+import typing
+import functools
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+
+class BotoMocks(typing.NamedTuple):
+    """Data structure for boto3 mocked objects"""
+
+    session: MagicMock
+    s3_client: MagicMock
+
+
+class PatchSession:
+    """A Patch function for the boto3 session"""
+
+    def __init__(self, *args, **kwargs):
+        """Create decorator with arguments"""
+        pass
+
+    def __call__(self, test_function):
+        """
+        Decorates the specified test function by returning a new function
+        that wraps it with patching in place for mocked phalanx functions.
+        """
+        @patch('pipper.environment.get_session')
+        def patch_session(get_session: MagicMock, *args, **kwargs):
+            boto_mocks = _create_boto_mocks()
+            get_session.return_value = boto_mocks.session
+            test_function(boto_mocks, *args, **kwargs)
+
+        return patch_session
+
+
+def affect_by_identifier(**identifiers):
+    """..."""
+    def side_effect(execution_identifier: str, *args, **kwargs):
+        return identifiers.get(execution_identifier)
+    return side_effect
+
+
+def make_list_objects_response(
+        contents: list = None,
+        next_continuation_token: str = None
+) -> dict:
+    """..."""
+    return dict(
+        Contents=contents or [],
+        NextContinuationToken=next_continuation_token
+    )
+
+
+def _get_client(
+        mocked_clients: typing.Dict[str, MagicMock],
+        identifier: str,
+        **kwargs
+) -> MagicMock:
+    """..."""
+    return mocked_clients.get(identifier) or MagicMock()
+
+
+def _create_boto_mocks() -> BotoMocks:
+    """..."""
+    s3_client = MagicMock()
+    session = MagicMock()
+    session.client.side_effect = functools.partial(
+        _get_client,
+        {'s3': s3_client}
+    )
+    return BotoMocks(session=session, s3_client=s3_client)  # noqa
diff --git a/pipper/test/versioning/__init__.py b/pipper/test/versioning/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/pipper/test/versioning/__init__.py
@@ -0,0 +1,1 @@
+
diff --git a/pipper/test/versioning/test_compare_constraint.py b/pipper/test/versioning/test_compare_constraint.py
new file mode 100644
index 0000000..6f1dc3d
--- /dev/null
+++ b/pipper/test/versioning/test_compare_constraint.py
@@ -0,0 +1,25 @@
+from pipper import versioning
+import pytest
+
+comparisons = [
+    ('0.0.1', '0.0.1', 0),
+    ('0.0.1', '0.0.2', -1),
+    ('0.0.1', '0.0.1-alpha.1', -1),
+    ('0.0.1', '0.0.1-alpha.1+build.2', -1),
+    ('0.0.1', '0.0.*', 0)
+]
+
+
+@pytest.mark.parametrize('version,constraint,expected', comparisons)
+def test_compare_constraint(version, constraint, expected):
+    """Should correctly compare between two versions"""
+    result = versioning.compare_constraint(version, constraint)
+    assert expected == result, """
+        Expect comparison of "{version}" with "{constraint}" to produce
+        a {expected} result instead of a {result} result.
+        """.format(
+            version=version,
+            constraint=constraint,
+            expected=expected,
+            result=result
+        )
diff --git a/pipper/test/versioning/test_explode.py b/pipper/test/versioning/test_explode.py
new file mode 100644
index 0000000..ec9a9e8
--- /dev/null
+++ b/pipper/test/versioning/test_explode.py
@@ -0,0 +1,20 @@
+import pytest
+
+from pipper.versioning import serde
+
+
+checks = [
+    ('0.0.1', ('0', '0', '1', '', '')),
+    ('0.1.12+build.1', ('0', '1', '12', '', 'build.1')),
+    ('0.1.*-beta.4', ('0', '1', '*', 'beta.4', '')),
+    ('1.*.*-beta.4+build.12', ('1', '*', '*', 'beta.4', 'build.12')),
+    ('1.12-beta.4', ('1', '12', '', 'beta.4', ''))
+]
+
+
+@pytest.mark.parametrize('version,expected', checks)
+def test_explode(version, expected):
+    """..."""
+    observed = serde.explode(version)
+    assert expected == observed
+
diff --git a/pipper/test/versioning/test_find_latest_match.py b/pipper/test/versioning/test_find_latest_match.py
new file mode 100644
index 0000000..21e85c2
--- /dev/null
+++ b/pipper/test/versioning/test_find_latest_match.py
@@ -0,0 +1,65 @@
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+import pytest
+
+from pipper import versioning
+
+listed_versions = list(reversed([
+    versioning.to_remote_version('test', '0.0.1', 'FAKE'),
+    versioning.to_remote_version('test', '0.0.1-alpha.1', 'FAKE'),
+    versioning.to_remote_version('test', '0.0.1-alpha.2', 'FAKE'),
+    versioning.to_remote_version('test', '0.0.1-alpha.2+build.122', 'FAKE'),
+    versioning.to_remote_version('test', '0.0.1-alpha.2+build.123', 'FAKE'),
+    versioning.to_remote_version('test', '0.0.2', 'FAKE'),
+    versioning.to_remote_version('test', '0.1.0', 'FAKE'),
+    versioning.to_remote_version('test', '0.1.1', 'FAKE'),
+    versioning.to_remote_version('test', '1.0.0', 'FAKE'),
+    versioning.to_remote_version('test', '2.0.0-alpha.1', 'FAKE'),
+    versioning.to_remote_version('test', '2.0.0-alpha.2', 'FAKE'),
+    versioning.to_remote_version('test', '2.0.0-beta.1', 'FAKE'),
+    versioning.to_remote_version('test', '2.0.0-beta.2', 'FAKE'),
+    versioning.to_remote_version('test', '2.0.0-rc.1+build.2', 'FAKE'),
+    versioning.to_remote_version('test', '2.0.0-rc.1+build.3', 'FAKE')
+]))
+
+validations = [
+    ('=0.0.1', False, '0.0.1'),
+    ('=0.0.1', True, '0.0.1-alpha.2+build.123'),
+    ('=0.0.1+build.122', True, '0.0.1-alpha.2+build.122'),
+    ('<0.0.1+build.123', True, '0.0.1-alpha.2+build.122'),
+    ('=0.0.1-alpha.1', True, '0.0.1-alpha.1'),
+    ('<0.0.2', False, '0.0.1'),
+    ('<=0.0.2', False, '0.0.2'),
+    ('=0.0.*', False, '0.0.2'),
+    ('=0.*', False, '0.1.1'),
+    ('=*', False, '1.0.0'),
+    ('=*', True, '2.0.0-rc.1+build.3'),
+    ('>0.1.1', False, '1.0.0'),
+    ('>=1.0.0', False, '1.0.0'),
+    ('', False, '1.0.0'),
+    ('', True, '2.0.0-rc.1+build.3'),
+]
+
+
+@pytest.mark.parametrize('constraint,unstable,expected', validations)
+@patch('pipper.versioning.list_versions')
+def test_find_latest_match(
+        list_versions: MagicMock,
+        constraint: str,
+        expected: str,
+        unstable: bool
+):
+    """Should find correct latest match for given constraint"""
+    list_versions.return_value = [
+        v for v in listed_versions
+        if not v.is_prerelease or unstable
+    ]
+
+    result = versioning.find_latest_match(
+        environment=MagicMock(),
+        package_name='test',
+        version_constraint=constraint,
+        include_prereleases=unstable
+    )
+    assert expected == result.version
diff --git a/pipper/test/versioning/test_parse_package_url.py b/pipper/test/versioning/test_parse_package_url.py
new file mode 100644
index 0000000..666e882
--- /dev/null
+++ b/pipper/test/versioning/test_parse_package_url.py
@@ -0,0 +1,9 @@
+
+from pipper import versioning
+
+
+def test_parse_package_url():
+    """Should parse package URL"""
+    rv = versioning.to_remote_version('fake', '1.0.1-alpha.1', 'fake-bucket')
+    rv_url = versioning.parse_package_url(rv.url)
+    assert rv == rv_url, 'Expect URL parsing to be consistent.'
diff --git a/pipper/test/versioning/test_remote_version.py b/pipper/test/versioning/test_remote_version.py
new file mode 100644
index 0000000..e7d70ba
--- /dev/null
+++ b/pipper/test/versioning/test_remote_version.py
@@ -0,0 +1,28 @@
+from pipper import versioning
+
+
+def test_attributes():
+    """Should have correct attributes specified in assignment"""
+    bucket = 'FAKE'
+    package = 'test'
+    version = '0.0.1'
+    rv = versioning.to_remote_version(package, version, bucket)
+    assert rv.bucket == bucket
+    assert rv.key.find(package) > 0
+    assert str(rv).find(package) > 0
+    assert str(rv).find(version) > 0
+    assert not rv.is_url_based
+
+
+def test_comparison():
+    """Should compare two remote versions correctly"""
+    rv1 = versioning.to_remote_version('test', '0.0.1-alpha.1', 'FAKE')
+    rv2 = versioning.to_remote_version('test', '0.0.1', 'FAKE')
+    rv3 = versioning.to_remote_version('test', '0.1.0', 'FAKE')
+
+    assert rv1 < rv2 < rv3
+    assert rv1 <= rv2 <= rv3
+    assert not rv1 >= rv2
+    assert not rv1 > rv2
+    assert not rv1 == rv2
+    assert rv1 == rv1
diff --git a/pipper/test/versioning/test_serialize.py b/pipper/test/versioning/test_serialize.py
new file mode 100644
index 0000000..d923368
--- /dev/null
+++ b/pipper/test/versioning/test_serialize.py
@@ -0,0 +1,40 @@
+import pytest
+
+from pipper import versioning
+
+TEST_PARAMETERS = [
+    ('1.2.3-alpha-beta.1', 'v1-2-3__pre_alpha-beta_1'),
+    ('1.2.3-alpha.1+2-test', 'v1-2-3__pre_alpha_1__build_2-test'),
+    ('1.2.3+2-test', 'v1-2-3__build_2-test'),
+    ('1.2.0-2+2-test', 'v1-2-0__pre_2__build_2-test')
+]
+
+
+@pytest.mark.parametrize('source,expected', TEST_PARAMETERS)
+def test_serialize(source: str, expected: str):
+    """Should serialize the source version to the expected value"""
+    result = versioning.serialize(source)
+    assert result == expected, """
+        Expected "{}" to be serialized as "{}" and not "{}".
+        """.format(source, expected, result)
+
+
+@pytest.mark.parametrize('expected,source', TEST_PARAMETERS)
+def test_deserialize(source: str, expected: str):
+    """Should deserialize the source version to the expected value"""
+    result = versioning.deserialize(source)
+    assert result == expected, """
+        Expected "{}" to be deserialized as "{}" and not "{}".
+        """.format(source, expected, result)
+
+
+def test_serialize_invalid():
+    """Should raise error trying to serialize invalid value."""
+    with pytest.raises(ValueError):
+        versioning.serialize('1.2')
+
+
+def test_deserialize_invalid():
+    """Should raise error trying to deserialize invalid value."""
+    with pytest.raises(ValueError):
+        versioning.deserialize('v1-2')
diff --git a/pipper/test/versioning/test_serialize_prefix.py b/pipper/test/versioning/test_serialize_prefix.py
new file mode 100644
index 0000000..1d0b182
--- /dev/null
+++ b/pipper/test/versioning/test_serialize_prefix.py
@@ -0,0 +1,45 @@
+import pytest
+
+from pipper import versioning
+
+TEST_PARAMETERS = [
+    ('', ''),
+    ('1.2', 'v1-2'),
+    ('0.4.', 'v0-4'),
+    ('1.2.3-alpha-beta.1', 'v1-2-3__pre_alpha-beta_1'),
+    ('1.2.3-alpha.1+2-test', 'v1-2-3__pre_alpha_1__build_2-test'),
+    ('1.2.3+2-test', 'v1-2-3__build_2-test'),
+    ('1.2-2+2-test', 'v1-2__pre_2__build_2-test'),
+    ('1+2-test', 'v1__build_2-test'),
+]
+
+
+@pytest.mark.parametrize('source,expected', TEST_PARAMETERS)
+def test_serialize_prefix(source: str, expected: str):
+    """Should serialize the source prefix to the expected value"""
+    result = versioning.serialize_prefix(source)
+    assert result == expected, """
+        Expected "{}" to be serialized as "{}" and not "{}".
+        """.format(source, expected, result)
+
+
+@pytest.mark.parametrize('expected,source', TEST_PARAMETERS)
+def test_deserialize_prefix(source: str, expected: str):
+    """Should deserialize the source prefix to the expected value"""
+    expected = expected.rstrip('.)+-_')
+    result = versioning.deserialize_prefix(source)
+    assert result == expected, """
+        Expected "{}" to be deserialized as "{}" and not "{}".
+        """.format(source, expected, result)
+
+
+def test_serialize_prefix_already():
+    """Should not modify a prefix that is already serialized."""
+    result = versioning.serialize_prefix('v1-2')
+    assert 'v1-2' == result, 'Expected prefix to remain unchanged.'
+
+
+def test_deserialize_prefix_already():
+    """Should not modify a prefix that has already been deserialized."""
+    result = versioning.deserialize_prefix('1.2')
+    assert '1.2' == result, 'Expected prefix to remain unchanged.'
diff --git a/pipper/test/versioning/test_versioning.py b/pipper/test/versioning/test_versioning.py
new file mode 100644
index 0000000..96bdb07
--- /dev/null
+++ b/pipper/test/versioning/test_versioning.py
@@ -0,0 +1,49 @@
+from pipper import versioning
+
+
+def test_make_s3_key():
+    """Should convert package name and version to an associated S3 key."""
+    name = 'fake-package'
+    version = '1.2.3-alpha.1+2-test'
+    key = versioning.make_s3_key(name, version)
+    remote = versioning.RemoteVersion('FAKE', key)
+
+    assert key.endswith('.pipper'), 'Expected a pipper file key.'
+    assert key.find(f'/{name}/'), 'Expected the package name as a folder.'
+    assert name == remote.package_name
+    assert version == remote.version
+    assert key.endswith('{}.pipper'.format(remote.safe_version)), """
+        Expected the key to end with the safe version as the file name.
+        """
+
+
+def test_to_remote_version():
+    """Should convert the package information into a RemoteVersion object."""
+    name = 'fake-package'
+    version = '1.2.3'
+    bucket = 'FAKE'
+    remote = versioning.to_remote_version(name, version, bucket)
+    assert version == remote.version
+    assert 'v1-2-3' == remote.safe_version
+    assert name == remote.package_name
+    assert f'pipper/{name}/v1-2-3.pipper' == remote.key
+    assert 'v1-2-3.pipper' == remote.filename
+    assert remote == remote, 'Should be equal to itself when compared'
+
+
+def test_remote_sorting():
+    """Should sort remote packages correctly according to version."""
+    remotes = [
+        versioning.to_remote_version('f', '1.0.1', 'FAKE'),
+        versioning.to_remote_version('a', '0.1.0', 'FAKE'),
+        versioning.to_remote_version('c', '0.1.1-alpha.2+1-test', 'FAKE'),
+        versioning.to_remote_version('d', '0.1.1-alpha.2+2-test', 'FAKE'),
+        versioning.to_remote_version('e', '0.1.1-alpha.2', 'FAKE'),
+        versioning.to_remote_version('b', '0.1.1-alpha.1', 'FAKE'),
+    ]
+    result = sorted(remotes)
+    comparison = sorted(remotes, key=lambda r: r.package_name)
+    assert comparison == result, """
+        Expected the RemoteVersions to be sorted by version such that their
+        package names are sorted alphabetically.
+        """

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest-cov
apt-get update apt-get install -y gcc
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
boto3==1.33.13
botocore==1.33.13
certifi @ file:///croot/certifi_1671487769961/work/certifi
charset-normalizer==3.4.1
coverage==7.2.7
exceptiongroup==1.2.2
idna==3.10
importlib-metadata==6.7.0
iniconfig==2.0.0
jmespath==1.0.1
packaging==24.0
-e git+https://github.com/sernst/pipper.git@3c6bec29901aa6f4e65e7024edd8ded3f85f9a3c#egg=pipper
pluggy==1.2.0
pytest==7.4.4
pytest-cov==4.1.0
python-dateutil==2.9.0.post0
requests==2.31.0
s3transfer==0.8.2
semver==3.0.4
six==1.17.0
tomli==2.0.1
typing_extensions==4.7.1
urllib3==1.26.20
zipp==3.15.0

name: pipper
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - boto3==1.33.13
      - botocore==1.33.13
      - charset-normalizer==3.4.1
      - coverage==7.2.7
      - exceptiongroup==1.2.2
      - idna==3.10
      - importlib-metadata==6.7.0
      - iniconfig==2.0.0
      - jmespath==1.0.1
      - packaging==24.0
      - pluggy==1.2.0
      - pytest==7.4.4
      - pytest-cov==4.1.0
      - python-dateutil==2.9.0.post0
      - requests==2.31.0
      - s3transfer==0.8.2
      - semver==3.0.4
      - six==1.17.0
      - tomli==2.0.1
      - typing-extensions==4.7.1
      - urllib3==1.26.20
      - zipp==3.15.0
prefix: /opt/conda/envs/pipper

pipper/test/commands/test_install.py::test_install pipper/test/test_commands.py::test_info pipper/test/test_commands.py::test_info_local pipper/test/versioning/test_compare_constraint.py::test_compare_constraint[0.0.1-0.0.1-0] pipper/test/versioning/test_compare_constraint.py::test_compare_constraint[0.0.1-0.0.2--1] pipper/test/versioning/test_compare_constraint.py::test_compare_constraint[0.0.1-0.0.1-alpha.1--1] pipper/test/versioning/test_compare_constraint.py::test_compare_constraint[0.0.1-0.0.1-alpha.1+build.2--1] pipper/test/versioning/test_compare_constraint.py::test_compare_constraint[0.0.1-0.0.*-0] pipper/test/versioning/test_explode.py::test_explode[0.0.1-expected0] pipper/test/versioning/test_explode.py::test_explode[0.1.12+build.1-expected1] pipper/test/versioning/test_explode.py::test_explode[0.1.*-beta.4-expected2] pipper/test/versioning/test_explode.py::test_explode[1.*.*-beta.4+build.12-expected3] pipper/test/versioning/test_explode.py::test_explode[1.12-beta.4-expected4] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=0.0.1-False-0.0.1] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=0.0.1-True-0.0.1-alpha.2+build.123] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=0.0.1+build.122-True-0.0.1-alpha.2+build.122] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[<0.0.1+build.123-True-0.0.1-alpha.2+build.122] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=0.0.1-alpha.1-True-0.0.1-alpha.1] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[<0.0.2-False-0.0.1] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[<=0.0.2-False-0.0.2] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=0.0.*-False-0.0.2] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=0.*-False-0.1.1] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=*-False-1.0.0] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[=*-True-2.0.0-rc.1+build.3] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[>0.1.1-False-1.0.0] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[>=1.0.0-False-1.0.0] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[-False-1.0.0] pipper/test/versioning/test_find_latest_match.py::test_find_latest_match[-True-2.0.0-rc.1+build.3] pipper/test/versioning/test_parse_package_url.py::test_parse_package_url pipper/test/versioning/test_remote_version.py::test_attributes pipper/test/versioning/test_remote_version.py::test_comparison pipper/test/versioning/test_serialize.py::test_serialize[1.2.3-alpha-beta.1-v1-2-3__pre_alpha-beta_1] pipper/test/versioning/test_serialize.py::test_serialize[1.2.3-alpha.1+2-test-v1-2-3__pre_alpha_1__build_2-test] pipper/test/versioning/test_serialize.py::test_serialize[1.2.3+2-test-v1-2-3__build_2-test] pipper/test/versioning/test_serialize.py::test_serialize[1.2.0-2+2-test-v1-2-0__pre_2__build_2-test] pipper/test/versioning/test_serialize.py::test_deserialize[1.2.3-alpha-beta.1-v1-2-3__pre_alpha-beta_1] pipper/test/versioning/test_serialize.py::test_deserialize[1.2.3-alpha.1+2-test-v1-2-3__pre_alpha_1__build_2-test] pipper/test/versioning/test_serialize.py::test_deserialize[1.2.3+2-test-v1-2-3__build_2-test] pipper/test/versioning/test_serialize.py::test_deserialize[1.2.0-2+2-test-v1-2-0__pre_2__build_2-test] pipper/test/versioning/test_serialize.py::test_serialize_invalid pipper/test/versioning/test_serialize.py::test_deserialize_invalid pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[-] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[1.2-v1-2] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[0.4.-v0-4] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[1.2.3-alpha-beta.1-v1-2-3__pre_alpha-beta_1] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[1.2.3-alpha.1+2-test-v1-2-3__pre_alpha_1__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[1.2.3+2-test-v1-2-3__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[1.2-2+2-test-v1-2__pre_2__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix[1+2-test-v1__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[-] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[1.2-v1-2] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[0.4.-v0-4] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[1.2.3-alpha-beta.1-v1-2-3__pre_alpha-beta_1] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[1.2.3-alpha.1+2-test-v1-2-3__pre_alpha_1__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[1.2.3+2-test-v1-2-3__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[1.2-2+2-test-v1-2__pre_2__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix[1+2-test-v1__build_2-test] pipper/test/versioning/test_serialize_prefix.py::test_serialize_prefix_already pipper/test/versioning/test_serialize_prefix.py::test_deserialize_prefix_already pipper/test/versioning/test_versioning.py::test_make_s3_key pipper/test/versioning/test_versioning.py::test_to_remote_version pipper/test/versioning/test_versioning.py::test_remote_sorting
pipper/environment.py pipper/resources/command_description.txt pipper/settings.json requirements.txt pipper/versioning/definitions.py conda.dockerfile pipper/command.py pipper/versioning/__init__.py pipper/wrapper.py pipper/bundler.py .coveragerc pipper/info.py setup.py pipper/versioning/serde.py pipper/__init__.py docker-compose.yaml pipper/s3.py pipper/versioning.py pipper/authorizer.py pipper/parser.py pipper/downloader.py pipper/installer.py
pipper/environment.py pipper/resources/command_description.txt pipper/settings.json requirements.txt pipper/versioning/definitions.py conda.dockerfile pipper/command.py pipper/versioning/__init__.py pipper/wrapper.py pipper/bundler.py .coveragerc pipper/info.py setup.py pipper/versioning/serde.py pipper/__init__.py docker-compose.yaml pipper/s3.py pipper/versioning.py pipper/authorizer.py pipper/parser.py pipper/downloader.py pipper/installer.py