Skip to content

Commit 32cdde2

Browse files
committed
Add --standalone command line option
When using this option everything from the base Python environment is included in the package. The output package can be unpacked on another PC without requiring the base Python package to exist. Fixes #1
1 parent 3ad7d8c commit 32cdde2

File tree

3 files changed

+124
-41
lines changed

3 files changed

+124
-41
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ Pack an environment located at an explicit path into my_env.zip
5858
venv-pack -p explicit\path\to\env -o env.zip
5959
```
6060

61+
Pack a standalone package that doesn't require the base Python environment
62+
```
63+
venv-pack --standalone -o env.zip
64+
```
65+
6166
### On the target machine
6267

6368
Unpack environment into directory `my_env`
@@ -72,5 +77,5 @@ All features of venv should keep working from my_env folder.
7277

7378
This tool is new, and has a few caveats.
7479

75-
1. Python is not packaged with the environment, but rather symlinked in the environment. On Windows python venv does so in a pyvenv.cfg file. This is useful for deployment situations where Python is already installed on the machine, but the required library dependencies may not be.
80+
1. Python is not packaged with the environment unless the '--standalone' option is specified when packing. By default Python is symlinked in the environment. On Windows python venv does so in a pyvenv.cfg file. This is useful for deployment situations where Python is already installed on the machine, but the required library dependencies may not be.
7681
2. The os type where the environment was built must match the os type of the target. This means that environments built on windows can’t be relocated to linux.

venv_pack/__main__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ def build_parser():
8484
parser.add_argument("--force", "-f",
8585
action="store_true",
8686
help="Overwrite any existing archive at the output path.")
87+
parser.add_argument("--standalone",
88+
action="store_true",
89+
help="Include the entire base Python distribution in the package.")
8790
parser.add_argument("--quiet", "-q",
8891
action="store_true",
8992
help="Do not report progress")
@@ -114,7 +117,8 @@ def main(args=None, pack=pack):
114117
zip_symlinks=args.zip_symlinks,
115118
zip_64=not args.no_zip_64,
116119
verbose=not args.quiet,
117-
filters=args.filters)
120+
filters=args.filters,
121+
standalone=args.standalone)
118122
except VenvPackException as e:
119123
fail("VenvPackError: %s" % e)
120124
except KeyboardInterrupt: # pragma: nocover

venv_pack/core.py

Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,15 @@ class Env(object):
8686
>>> Env().pack(output="environment.tar.gz")
8787
"/full/path/to/environment.tar.gz"
8888
"""
89-
__slots__ = ('_context', 'files', '_excluded_files')
89+
__slots__ = ('_context', 'files', '_excluded_files', '_base_env')
9090

9191
def __init__(self, prefix=None):
9292
context, files = load_environment(prefix)
9393

9494
self._context = context
9595
self.files = files
9696
self._excluded_files = []
97+
self._base_env = None
9798

9899
def _copy_with_files(self, files, excluded_files):
99100
out = object.__new__(Env)
@@ -102,6 +103,13 @@ def _copy_with_files(self, files, excluded_files):
102103
out._excluded_files = excluded_files
103104
return out
104105

106+
@property
107+
def base_env(self):
108+
if self._base_env is None \
109+
and self.kind in ("virtualenv", "venv"):
110+
self._base_env = Env(self.orig_prefix)
111+
return self._base_env
112+
105113
@property
106114
def prefix(self):
107115
return self._context.prefix
@@ -224,7 +232,7 @@ def _output_and_format(self, output=None, format='infer'):
224232

225233
def pack(self, output=None, format='infer', python_prefix=None,
226234
verbose=False, force=False, compress_level=4, zip_symlinks=False,
227-
zip_64=True):
235+
zip_64=True, standalone=False):
228236
"""Package the virtual environment into an archive file.
229237
230238
Parameters
@@ -259,6 +267,10 @@ def pack(self, output=None, format='infer', python_prefix=None,
259267
symlinks*. Default is False. Ignored if format isn't ``zip``.
260268
zip_64 : bool, optional
261269
Whether to enable ZIP64 extensions. Default is True.
270+
standalone : bool, optional
271+
If True include the files from the base Python environment in the
272+
package and create a standalone Python environment that does not
273+
need the base environment when unpacked.
262274
263275
Returns
264276
-------
@@ -274,6 +286,10 @@ def pack(self, output=None, format='infer', python_prefix=None,
274286
if verbose:
275287
print("Packing environment at %r to %r" % (self.prefix, output))
276288

289+
all_files = list(self.files)
290+
if standalone:
291+
all_files = combine_env_files(self, self.base_env)
292+
277293
fd, temp_path = tempfile.mkstemp()
278294

279295
try:
@@ -282,8 +298,8 @@ def pack(self, output=None, format='infer', python_prefix=None,
282298
compress_level=compress_level,
283299
zip_symlinks=zip_symlinks,
284300
zip_64=zip_64) as arc:
285-
packer = Packer(self._context, arc, python_prefix)
286-
with progressbar(self.files, enabled=verbose) as files:
301+
packer = Packer(self._context, arc, python_prefix, standalone=standalone)
302+
with progressbar(all_files, enabled=verbose) as files:
287303
try:
288304
for f in files:
289305
packer.add(f)
@@ -323,7 +339,7 @@ class File(namedtuple('File', ('source', 'target'))):
323339

324340
def pack(prefix=None, output=None, format='infer', python_prefix=None,
325341
verbose=False, force=False, compress_level=4, zip_symlinks=False,
326-
zip_64=True, filters=None):
342+
zip_64=True, filters=None, standalone=False):
327343
"""Package an existing virtual environment into an archive file.
328344
329345
Parameters
@@ -365,6 +381,10 @@ def pack(prefix=None, output=None, format='infer', python_prefix=None,
365381
``(kind, pattern)``, where ``kind`` is either ``'exclude'`` or
366382
``'include'`` and ``pattern`` is a file pattern. Filters are applied in
367383
the order specified.
384+
standalone : bool, optional
385+
If True include the files from the base Python environment in the
386+
package and create a standalone Python environment that does not
387+
need the base environment when unpacked.
368388
369389
Returns
370390
-------
@@ -389,7 +409,8 @@ def pack(prefix=None, output=None, format='infer', python_prefix=None,
389409
python_prefix=python_prefix,
390410
verbose=verbose, force=force,
391411
compress_level=compress_level,
392-
zip_symlinks=zip_symlinks, zip_64=zip_64)
412+
zip_symlinks=zip_symlinks, zip_64=zip_64,
413+
standalone=standalone)
393414

394415

395416
def check_prefix(prefix=None):
@@ -404,7 +425,7 @@ def check_prefix(prefix=None):
404425
if not os.path.exists(prefix):
405426
raise VenvPackException("Environment path %r doesn't exist" % prefix)
406427

407-
for check in [check_venv, check_virtualenv]:
428+
for check in [check_venv, check_virtualenv, check_baseenv]:
408429
try:
409430
return check(prefix)
410431
except VenvPackException:
@@ -460,6 +481,22 @@ def check_virtualenv(prefix):
460481

461482
return context
462483

484+
def check_baseenv(prefix):
485+
python_lib, python_include = find_python_lib_include(prefix)
486+
487+
if not os.path.exists(os.path.join(prefix, python_lib, "os.py")):
488+
raise VenvPackException("%r is not a valid Python environment" % prefix)
489+
490+
491+
context = AttrDict()
492+
493+
context.kind = 'base'
494+
context.prefix = prefix
495+
context.orig_prefix = None
496+
context.py_lib = python_lib
497+
context.py_include = python_include
498+
499+
return context
463500

464501
def find_python_lib_include(prefix):
465502
if on_win:
@@ -513,10 +550,13 @@ def load_environment(prefix):
513550

514551
# Files to ignore
515552
remove = {join(BIN_DIR, "activate" + suffix) for suffix in ('', '.csh', '.fish', '.bat', '.ps1')}
553+
for script in ("activate", "Activate"):
554+
for suffix in ('', '.csh', '.fish', '.bat', '.ps1'):
555+
remove.add(join(BIN_DIR, script + suffix))
516556

517557
if context.kind == 'virtualenv':
518558
remove.add(join(context.py_lib, 'orig-prefix.txt'))
519-
else:
559+
elif context.kind == 'venv':
520560
remove.add('pyvenv.cfg')
521561

522562
res = []
@@ -659,11 +699,44 @@ def check_python_prefix(python_prefix, context):
659699
return python_prefix, rewrites
660700

661701

702+
def combine_env_files(venv, base_env):
703+
"""Combines the files from a virtual environment and it's base environment.
704+
Returns a new list of files.
705+
"""
706+
all_files = {}
707+
exe_suffix = '.exe' if on_win else ''
708+
709+
# Don't include the Python executables from the virtual environment
710+
exclude = {
711+
os.path.join(venv.prefix, BIN_DIR, 'python' + exe_suffix).lower(),
712+
os.path.join(venv.prefix, BIN_DIR, 'pythonw' + exe_suffix).lower()
713+
}
714+
715+
# Copy the base Python excectuables and shared libraries into BIN_DIR as the
716+
# other scripts need them to be there for them to work.
717+
for file in os.listdir(base_env.prefix):
718+
source = os.path.join(base_env.prefix, file)
719+
if os.path.isfile(source):
720+
_, ext = os.path.splitext(file)
721+
if ext.lower() in ("", ".dll", ".so", ".exe", ".pdb"):
722+
target = os.path.join(BIN_DIR, file)
723+
all_files[target] = File(source, target)
724+
exclude.add(source.lower())
725+
726+
# Include all files from the base env, and for any files that exist in both use the
727+
# file from the virtual env.
728+
all_files.update({f.target: f for f in base_env.files if f.source.lower() not in exclude})
729+
all_files.update({f.target: f for f in venv.files if f.source.lower() not in exclude})
730+
731+
return all_files.values()
732+
733+
662734
class Packer(object):
663-
def __init__(self, context, archive, python_prefix):
735+
def __init__(self, context, archive, python_prefix, standalone=False):
664736
self.context = context
665737
self.prefix = context.prefix
666738
self.archive = archive
739+
self.standalone = standalone
667740

668741
python_prefix, rewrites = check_python_prefix(python_prefix, context)
669742
self.python_prefix = python_prefix
@@ -689,34 +762,35 @@ def add(self, file):
689762
self.archive.add(file.source, file.target)
690763

691764
def finish(self):
692-
script_dirs = ['common', 'nt']
693-
694-
for d in script_dirs:
695-
dirpath = os.path.join(SCRIPTS, d)
696-
for f in os.listdir(dirpath):
697-
source = os.path.join(dirpath, f)
698-
target = os.path.join(BIN_DIR, f)
699-
self.archive.add(source, target)
700-
701-
if self.context.kind == 'venv':
702-
pyvenv_cfg = os.path.join(self.prefix, 'pyvenv.cfg')
703-
if self.python_prefix is None:
704-
self.archive.add(pyvenv_cfg, 'pyvenv.cfg')
705-
else:
706-
with open(pyvenv_cfg) as fil:
707-
data = fil.read()
708-
data = data.replace(self.context.orig_prefix,
709-
self.python_prefix)
710-
self.archive.add_bytes(pyvenv_cfg, data.encode(), 'pyvenv.cfg')
711-
else:
712-
origprefix_txt = os.path.join(self.context.prefix,
713-
self.context.py_lib,
714-
'orig-prefix.txt')
715-
target = os.path.relpath(origprefix_txt, self.prefix)
716-
717-
if self.python_prefix is None:
718-
self.archive.add(origprefix_txt, target)
765+
if not self.standalone:
766+
script_dirs = ['common', 'nt']
767+
768+
for d in script_dirs:
769+
dirpath = os.path.join(SCRIPTS, d)
770+
for f in os.listdir(dirpath):
771+
source = os.path.join(dirpath, f)
772+
target = os.path.join(BIN_DIR, f)
773+
self.archive.add(source, target)
774+
775+
if self.context.kind == 'venv':
776+
pyvenv_cfg = os.path.join(self.prefix, 'pyvenv.cfg')
777+
if self.python_prefix is None:
778+
self.archive.add(pyvenv_cfg, 'pyvenv.cfg')
779+
else:
780+
with open(pyvenv_cfg) as fil:
781+
data = fil.read()
782+
data = data.replace(self.context.orig_prefix,
783+
self.python_prefix)
784+
self.archive.add_bytes(pyvenv_cfg, data.encode(), 'pyvenv.cfg')
719785
else:
720-
self.archive.add_bytes(origprefix_txt,
721-
self.python_prefix.encode(),
722-
target)
786+
origprefix_txt = os.path.join(self.context.prefix,
787+
self.context.py_lib,
788+
'orig-prefix.txt')
789+
target = os.path.relpath(origprefix_txt, self.prefix)
790+
791+
if self.python_prefix is None:
792+
self.archive.add(origprefix_txt, target)
793+
else:
794+
self.archive.add_bytes(origprefix_txt,
795+
self.python_prefix.encode(),
796+
target)

0 commit comments

Comments
 (0)