-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbootstrap.py
More file actions
1151 lines (927 loc) · 45.3 KB
/
bootstrap.py
File metadata and controls
1151 lines (927 loc) · 45.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Zero-dependency, cross-platform bootstrap script to set up a local Python
development virtual environment.
This is a specialized version of the bootstrap script for projects that
require Python 3.10 or later. It may use features that were deprecated
in later versions of Python, so it is kept separate to ensure compatibility.
It is NOT intended to be run with Python versions earlier than 3.10
and will exit with an error if attempted.
It has been tested with Python 3.10 through 3.14 as of December 2025.
This script is part of the 'python-env-bootstrap' project available at
https://github.com/JerilynFranz/python-env-bootstrap
Instructions
------------
This script performs the **MINIMUM** steps required to set up a local
development environment for a Python project. It creates a virtual environment,
installs necessary development tools, and performs any project-specific
setup steps.
It is expected to be customized as needed for each project by modifying
the constants and functions defined in this script.
It DOES NOT attempt to handle the installation of project dependencies
or other setup tasks beyond the minimum required to get started with
development. Those tasks should be handled by the project's own
dependency management and build tools (e.g., 'uv', 'tox', 'poetry', etc.)
It is intended to get a new developer on a project to the point where they
*CAN* use those tools to manage the rest of the project development environment.
It is designed to be easily customizable and extensible for project-specific
needs. If you wish to add additional setup steps, you can modify the
`run_post_install_steps()` function to perform any additional tasks
required after installing the core development tools.
You should regard this script as a starting point for bootstrapping
your project's development environment, and modify it as needed to suit your
project's specific requirements.
License
-------
Licensed under the Apache License, Version 2.0 (SPDX-License-Identifier: Apache-2.0)
https://www.apache.org/licenses/LICENSE-2.0.txt
Copyright [2025] Jerilyn Franz
See https://github.com/JerilynFranz/python-env-bootstrap/blob/main/LICENSE
for details.
You can get the most recent version of this script for your own use
in a project at https://github.com/JerilynFranz/python-env-bootstrap
Description
-----------
It is designed to be run after cloning a git or Mercurial repository, to create
a local virtual environment (.venv-tools), and install necessary development tools.
This includes installation of git or Mercurial hooks if applicable.
It relies only on the Python standard library and network access to PyPI and
does not require any pre-installed packages or change your system Python installation.
This example project requires Python 3.10 or later, so this script
checks the Python version meets that requirement before proceeding.
The minimum Python version can be changed as needed for your project and
the lowest supported version is Python 3.10.
This script installs the following tools by default:
- uv (for managing Python packages and dependencies)
- tox (for running tests, linters, and building documentation)
- tox-uv (to integrate uv with tox)
That is the minimum set of tools required to start development for this project.
The minimum supported Python version, virtual environment directory name,
and list of tools to install can be customized by modifying the
corresponding constants in this script. The script can also be extended to
perform additional setup steps as needed via the run_post_install_steps() function
such as installing additional packages from requirements.txt files
or configuring settings.
The choices of installing 'uv' and 'tox' for the bootstrap are just examples;
you can modify the BOOTSTRAP_MODULES list to only include any packages you need
for your development bootstrap workflow.
Settable Options
----------------
- VENV_DIR: The name of the virtual environment directory to create.
- BOOTSTRAP_MODULES: A list of InstallSpec instances specifying the PyPI packages
to install into the virtual environment during bootstrap.
- POST_INSTALL_MESSAGE: You can customize the message displayed after installation
by modifying the POST_INSTALL_MESSAGE constant.
- TOOL_USAGE_INSTRUCTIONS: You can customize the usage instructions displayed after installation
by modifying the TOOL_USAGE_INSTRUCTIONS constant.
- Post-install steps: You can customize the `run_post_install_steps()`
function to perform additional setup tasks after installing the core tools.
- Output control: You can set `DEFAULT_DEBUG` and `DEFAULT_QUIET` constants
to control whether debug output or quiet mode is enabled by default.
- Command-line options: You can use '--debug'/'--no-debug' and '--quiet'/'--verbose'
to control output verbosity when running the script.
- Automatic confirmation: You can use '--yes'/'-y' to skip confirmation prompts.
- You can configure the supported Python versions by modifying
the version check ("if sys.version_info") at the start of the script.
Usage
-----
python bootstrap.py [-h] [--yes] [--debug | --no-debug] [-q | -v]
CLI Help
--------
-h, --help show this help message and exit
--yes, -y Automatically confirm and proceed without prompting.
--debug Enable debug output.
--no-debug Disable debug output.
-q, --quiet Suppress non-error output.
-v, --verbose Enable verbose output (default).
"""
# pylint: disable=wrong-import-position,too-many-lines
import sys
# Check for minimum supported Python version before importing anything else
# this ensures that users get a clear error message if they try to run
# the script with an unsupported Python version.
#
# The minimum version of Python this bootstrap script can support is 3.10+
# This can be changed as needed for your project.
if sys.version_info < (3, 10):
major, minor = sys.version_info.major, sys.version_info.minor
print("Error: Python 3.10 or later is required to run this project. "
f"You are using Python {major}.{minor}.")
sys.exit(2)
import argparse
import os
import shutil
import stat
import subprocess
from functools import cache
from pathlib import Path
from typing import NamedTuple
from venv import create as create_venv
DEFAULT_DEBUG: bool = False
"""Enable debug output only if --debug is specified.
To enable debug output by default, set this to True
and then --no-debug can be used to disable it.
"""
DEFAULT_QUIET: bool = False
"""Suppress non-error output only if --quiet is specified.
To enable quiet output by default, set this to True
and then --verbose can be used to disable it.
"""
# Whether to remove the bootstrapvirtual environment directory on script exit.
# This is useful for cleaning up the bootstrap venv after installation
# if desired. Set to False to keep the venv for inspection or reuse.
#
# Removing it on exit is not the default behavior to avoid accidental loss
# of the created environment. But it can be enabled as needed.
# Default is False because most users will want to keep the venv.
REMOVE_BOOTSTRAP_VENV_ON_EXIT: bool = False
"""Whether to remove the virtual environment directory on script exit."""
VENV_DIR: str = ".venv-tools"
"""The name of the virtual environment directory to create in the repository root for the bootstrap."""
# The name of the virtual environment directory when activated. In this example,
# since we are using 'tox' to manage the development environment, we assume
# that 'tox' will create a 'venv' directory within its environment.
#
# This can be changed as needed for your project. If you are not using 'tox'
# or another tool such as 'poetry' that creates its own venv,
# you may want to set this to VENV_DIR or another appropriate name.
ACTIVATED_VENV_DIR: str = 'venv'
"""The name of the virtual environment directory when the project virtual environment is activated."""
class InstallSpec(NamedTuple):
"""Specification for modules required to be installed.
:param str name: The name of the module to install.
:param str version: An optional version specifier (e.g., ">=1.0.0").
:param str extras: An optional extras specifier (e.g., "[dev]").
"""
name: str
version: str = ''
extras: str = ''
def __str__(self):
return f"{self.name}{self.extras}{self.version or ' (latest)'}"
# --- Modules to install during bootstrap ---
BOOTSTRAP_MODULES: list[InstallSpec] = [
InstallSpec(name="uv", version=">=0.9.18"),
InstallSpec(name="tox", version=">=4.22.0"),
InstallSpec(name="tox-uv", version=">=1.13.1"),
]
# --- Tool usage instructions template ---
TOOL_USAGE_INSTRUCTIONS = """
You use 'tox' to run tasks that set up and manage the development environment,
run tests, linters, and build documentation:
Examples:
tox run -e lint # Run linters on the codebase
tox run -e docs # Build documentation
tox run -e py310 # Run the test suite using Python 3.10
tox run -e py314 # Run the test suite using Python 3.14
tox devenv -e dev # Start an interactive dev environment with Python 3.12
The list of available 'tox' environments can be found by running:
tox list
If you are not familiar with using 'tox' see https://tox.wiki/en/latest/
You use 'uv' to manage Python packages within the virtual environment and to
update pyproject dependencies:
Examples:
# Add a new package to the 'dev' dependency group
uv add --dev --group=dev 'package_name>=1.2.3'
# Add a new package to the default dependency group
uv add 'package_name>=1.2.3'
# Add a package to specified extras
uv add 'package_name[extra1,extra2]'
# install a package from PyPI to the virtual environment
uv pip install 'package_name>=1.2.3'
See https://docs.astral.sh/uv/ for more information on using 'uv'.
If you are using VSCode, you can select the Python interpreter from the
development virtual environment located in the 'venv' directory within the
repository root after activating the environment with 'tox devenv -e dev'.
Make sure to select the interpreter from the activated virtual environment
so that VSCode uses the correct packages installed in that environment.
If you can't find the interpreter in the list, you should look in VSCode's
Settings under "Python: Use Environments Extension" and enable that option to have VSCode
list virtual environments automatically. That should add the activated venv's interpreter
to the list of available interpreters under a name like 'python-typechecked/venv/bin/python'
(or 'python-typechecked\\venv\\Scripts\\python.exe' on Windows).
If you are using another IDE or editor, refer to its documentation for selecting
the Python interpreter from a virtual environment.
The 'tox' tests include running tests for multiple Python versions and interpreters.
The tests for 'pypy' and 'pypy3' interpreters require that 'rust' is installed on your system
so that the 'pyo3' package can be built.
If you do not have 'rust' installed, you can skip running tests for 'pypy' and 'pypy3'
by excluding those environments when running 'tox', e.g.:
tox run -e py310,py311,py312,py313,py314
"""
# --- Post-install instructions template ---
POST_INSTALL_MESSAGE = f"""
--- Bootstrap complete! ---
The development environment has been set up in the '{ACTIVATED_VENV_DIR}' directory,
and the project has been installed in editable mode.
To activate the project's development virtual environment, run:
{{activate}}
To deactivate the virtual environment, run:
deactivate
{TOOL_USAGE_INSTRUCTIONS}
"""
# --- Confirmation prompt message ---
CONFIRMATION_PROMPT_MESSAGE = f"""
This script will create a {VENV_DIR} directory in the root
of the current repository.
It will install required tools into it for development,
and install the project as an editable package into the
virtual environment.
No changes will be made to your system install of Python.
Continue? [y/n] """
# --- Global flags for output control ---
# These are defined here only for declaration purposes; they are actually set
# in main() after parsing command-line arguments.
# Changing these variables directly has no effect: Set DEFAULT_DEBUG and
# DEFAULT_QUIET instead to change the default behavior.
DEBUG: bool = False
QUIET: bool = False
# --- Version Control System (VCS) Hook Names ---
# Standard Git and Mercurial hook names. If you need custom hooks,
# you can modify these sets as needed. Only names included in these
# sets will be installed by the install_vcs_hooks() function.
GIT_HOOK_NAMES = {
"applypatch-msg", "commit-msg", "fsmonitor-watchman", "post-update",
"pre-applypatch", "pre-commit", "pre-merge-commit", "pre-push",
"pre-rebase", "pre-receive", "prepare-commit-msg", "update"
}
HG_HOOK_NAMES = {
"precommit", "commit", "prepush", "push", "preupdate", "update",
"prechangegroup", "changegroup", "pretag", "tag"
}
class VCS(NamedTuple):
"""Specification for version control systems.
:param str name: The name of the VCS (e.g., 'git', 'hg', 'none').
"""
name: str
repo_root: Path | None = None
def is_git(self) -> bool:
"""Returns True if the VCS is git."""
return self.name == "git"
def is_hg(self) -> bool:
"""Returns True if the VCS is Mercurial."""
return self.name == "hg"
def is_none(self) -> bool:
"""Returns True if no VCS is detected."""
return self.name == "none"
def __str__(self) -> str:
return self.name
DETECTED_VCS: VCS = VCS(name="none", repo_root=None)
"""Cache for the detected version control system.
The detected version control system in use (git, hg, or none)."""
def run_post_install_steps(python_exe: Path, root_path: Path, bin_dir: Path) -> None:
"""Runs any post-installation steps required after installing tools.
This function is called automatically after the core development tools are installed.
It is intended as a customization point for project-specific setup tasks, such as:
- Installing the current project in editable mode
- Setting up pre-commit hooks
- Installing packages from requirements.txt files
- Any other project-specific initialization
The default example implementation here runs 'tox devenv -e dev' to set up and activate
the development environment, and then installs the current project in editable mode
with 'uv pip install -e .'.
:param python_exe Path: The path to the Python executable within the venv.
:param root_path Path: The path to the root of the repository.
"""
_validate_path(python_exe, "python_exe", exists=True)
_validate_path(root_path, "root_path", exists=True)
# This assumes that 'tox' and 'uv' are installed in the bootstrap virtual environment
# and that either there is a pyproject.toml or tox.ini file configured for the project.
# The 'dev' environment should be defined in tox.ini or pyproject.toml.
# This has the effect of setting up the development environment and installing
# the project in editable mode.
# Alternatives include installing from requirements.txt files or other setup steps.
# run_command([...]) can be used to run any commands needed.
#Example implementation (assuming you are not deleting the venv right after):
# run_command([python_exe, "-m", "pip", "install", "-r", "requirements.txt"], cwd=root_path, check=True)
# Run 'tox devenv -e dev' to set up and activate the development environment
controlled_print("--> Running initial 'tox devenv -e dev' to setup and activate the development environment...")
run_command([python_exe, str(bin_dir / "tox"), "devenv", "-e", "dev"], cwd=root_path, check=True)
# Install the current project in editable mode using 'uv pip install -e .'
controlled_print("--> Installing the current project in editable mode within the development environment...")
run_command([bin_dir / "uv", "pip", "install", "-e", "."], cwd=root_path, check=True)
class FatalBootstrapError(Exception):
"""Exception raised for fatal errors during the bootstrap process."""
def __init__(self, message: str, error_code: int = 1):
super().__init__(message)
self.error_code = error_code
def _is_windows() -> bool:
"""Determines if the current platform is Windows."""
return sys.platform == "win32"
def _validate_string(value: str, name: str) -> None:
"""Validates that the input is a string.
:param value str: The value to validate.
:param name str: The name of the value (for error messages).
:raises TypeError: If validation fails.
"""
if not isinstance(value, str):
raise TypeError(f"{name} must be a string")
def _validate_string_list(lst: list[str], name: str) -> None:
"""Validates that the input is a list of strings.
:param lst list[str]: The list to validate.
:param name str: The name of the list (for error messages).
:raises TypeError: If validation fails.
"""
if not isinstance(lst, list):
raise TypeError(f"{name} must be a list")
if not all(isinstance(item, str) for item in lst):
raise TypeError(f"all items in {name} must be strings")
def _validate_module_list(modules: list[InstallSpec], name: str) -> None:
"""Validates that the input is a list of InstallSpec instances.
:param modules list[InstallSpec]: The list to validate.
:param name str: The name of the list (for error messages).
:raises TypeError: If validation fails.
"""
if not isinstance(modules, list):
raise TypeError(f"{name} must be a list")
for module in modules:
if not isinstance(module, InstallSpec):
raise TypeError(f"all items in {name} must be InstallSpec instances")
def _validate_command(lst: list[str | Path], name: str) -> None:
"""Validates that the input is a list of that starts with
either a string or Path, and contains only strings for all other items.
It must contain at least one item.
:param lst list[str | Path]: The list to validate.
:param name str: The name of the list (for error messages).
:raises TypeError: If validation fails.
"""
if not isinstance(lst, list):
raise TypeError(f"{name} must be a list")
if not lst:
raise ValueError(f"{name} must not be empty")
if not isinstance(lst[0], (str, Path)):
raise TypeError(f"the first item in {name} must be a string or Path")
if not all(isinstance(item, str) for item in lst[1:]):
raise TypeError(f"all items after the first in {name} must be strings")
def _validate_boolean(value: bool, name: str) -> None:
"""Validates that the input is a boolean.
:param value bool: The value to validate.
:param name str: The name of the value (for error messages).
:raises TypeError: If validation fails.
"""
if not isinstance(value, bool):
raise TypeError(f"{name} must be a boolean")
def _validate_kwarg_keys_are_strings(kwargs: dict, name: str) -> None:
"""Validates that all keys in the input dictionary are strings.
:param kwargs dict: The dictionary to validate.
:param name str: The name of the dictionary (for error messages).
:raises TypeError: If validation fails.
"""
if not isinstance(kwargs, dict):
raise TypeError(f"{name} must be a dictionary")
if not all(isinstance(k, str) for k in kwargs.keys()):
raise TypeError(f"all keys in {name} must be strings")
def _validate_path(path: Path, name: str, exists: bool = False) -> None:
"""Validates that the input is a Path instance.
Optionally checks that the path exists.
:param path Path: The path to validate.
:param name str: The name of the path (for error messages).
:param exists bool: Whether to check that the path exists.
:raises TypeError: If validation fails.
:raises FileNotFoundError: If exists is True and the path does not exist.
"""
if not isinstance(path, Path):
raise TypeError(f"{name} must be a Path instance")
if exists and not path.exists():
raise FileNotFoundError(f"{name} does not exist: {path}")
def run_command(command: list[str | Path], *,
check: bool = True,
cwd: str | Path | None = None,
**kwargs):
"""Helper to run a command and print its output.
If the command is not found, or returns a non-zero exit code,
prints an error message and exits the script.
:param command list[str | Path]: The command to run as a list.
:param check bool: Whether to raise an exception on non-zero exit code.
:param cwd str | Path | None: The working directory for the command.
:param kwargs: Additional keyword arguments to pass to subprocess.run().
"""
_validate_command(command, "command")
_validate_boolean(check, "check")
if cwd:
_validate_path(Path(cwd), "cwd", exists=True)
_validate_kwarg_keys_are_strings(kwargs, "kwargs")
try:
if DEBUG:
debug_kwargs = kwargs.copy()
if cwd:
debug_kwargs['cwd'] = cwd
print(f"DEBUG: Running {command} with kwargs: {debug_kwargs}")
# Suppress output if QUIET is True and not already overridden
if QUIET:
kwargs.setdefault('stdout', subprocess.DEVNULL)
kwargs.setdefault('stderr', subprocess.DEVNULL)
subprocess.run(command, check=check, cwd=cwd, **kwargs)
except FileNotFoundError as e:
print(f"Error: Command '{command[0]}' not found. Is it in your PATH?")
raise FatalBootstrapError(f"Command '{command[0]}' not found.", error_code=1) from e
except subprocess.CalledProcessError as e:
print(f"Error: Command {command} failed with exit code {e.returncode}")
raise FatalBootstrapError(f"Command {command} failed with exit code {e.returncode}",
error_code=e.returncode) from e
def controlled_print(message: str) -> None:
"""Prints a message if not in quiet mode."""
_validate_string(message, "message")
if not QUIET:
print(message)
def confirmation_prompt(message: str) -> bool:
"""Prompts the user for confirmation to proceed."""
try:
repo_root = get_repo_root()
controlled_print(f"Current working directory: {os.getcwd()}")
controlled_print(f"Repository root directory: {repo_root}")
choice = ''
while choice.lower().strip() not in ('y', 'yes', 'n', 'no'):
choice = input(message)
except KeyboardInterrupt:
controlled_print('')
return False
return choice.lower().strip() in ('', 'y', 'yes')
@cache
def get_repo_root() -> Path:
"""Finds the root directory of the repository and caches the result.
If not in a repository, prints an error message and exits.
It tries to use 'git rev-parse --show-toplevel' first, and falls back
to searching parent directories for a '.git' folder if the git command
is not found.
If a .git directory is not found, it looks for a Mercurial repository
by searching for a '.hg' folder instead.
"""
global DETECTED_VCS # pylint: disable=global-statement
try:
git_root_bytes = subprocess.check_output(
['git', 'rev-parse', '--show-toplevel'],
stderr=subprocess.PIPE
)
root_dir = git_root_bytes.decode('utf-8').strip()
DETECTED_VCS = VCS(name="git", repo_root=Path(root_dir))
return Path(root_dir)
except (FileNotFoundError, subprocess.CalledProcessError):
# Try Mercurial CLI
try:
hg_root_bytes = subprocess.check_output(
['hg', 'root'],
stderr=subprocess.PIPE
)
root_dir = hg_root_bytes.decode('utf-8').strip()
DETECTED_VCS = VCS(name="hg", repo_root=Path(root_dir))
return Path(root_dir)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
# Fallback to directory search...
current_dir = Path.cwd()
for parent in [current_dir] + list(current_dir.parents):
if (parent / ".git").is_dir():
DETECTED_VCS = VCS(name="git", repo_root=parent)
return parent
# Check for Mercurial repository instead
for parent in [current_dir] + list(current_dir.parents):
if (parent / ".hg").is_dir():
DETECTED_VCS = VCS(name="hg", repo_root=parent)
return parent
controlled_print("Error: No Git or Mercurial repository found in any parent directories.")
raise FatalBootstrapError("No repository found.", error_code=1) from e
def install_vcs_hooks(repo_root: Path, forced: bool) -> None:
"""Install pre-commit and pre-push hooks for git or hg if present.
It uses the 'hooks/' directory in the repository root
to find the hook scripts to install to the auto-detected VCS
metadata directory.
:param repo_root Path: The root directory of the repository.
:param forced bool: Whether to force overwrite of existing hooks.
"""
_validate_path(repo_root, "repo_root", exists=True)
if DETECTED_VCS.is_none():
controlled_print("No version control system detected; skipping VCS hook installation.")
return
hooks_dir = repo_root / "hooks"
if not hooks_dir.exists():
controlled_print("No hooks/ directory found; skipping VCS hook installation.")
return
if DETECTED_VCS.is_git():
_install_git_hooks(repo_root, forced)
elif DETECTED_VCS.is_hg():
_install_hg_hooks(repo_root, forced)
else:
controlled_print("Unsupported VCS for hook installation; skipping.")
def _is_valid_git_hook_name(name: str) -> bool:
"""Checks if the given name is a valid Git hook name.
:param name str: The hook name to check.
:return bool: True if valid, False otherwise.
"""
return name in GIT_HOOK_NAMES
def _is_valid_hg_hook_name(name: str) -> bool:
"""Checks if the given name is a valid Mercurial hook name.
:param name str: The hook name to check.
:return bool: True if valid, False otherwise.
"""
return name in HG_HOOK_NAMES
def _install_git_hooks(repo_root: Path, forced: bool) -> None:
"""Installs git hooks from the 'hooks/' directory in the repository root.
:param repo_root Path: The root directory of the repository.
:param forced bool: Whether to force overwrite existing hooks.
"""
_validate_path(repo_root, "repo_root", exists=True)
git_dir = repo_root / ".git"
hooks_dir = git_dir / "hooks"
source_hooks_dir = repo_root / "hooks"
if not source_hooks_dir.exists():
controlled_print("No hooks/ directory found; skipping Git hook installation.")
return
try:
for hook_file in source_hooks_dir.iterdir():
if hook_file.is_file():
hook_name = hook_file.name
if not _is_valid_git_hook_name(hook_name):
controlled_print(f"Skipping non-standard Git hook: {hook_name}")
continue
dest_hook = hooks_dir / hook_file.name
if dest_hook.exists():
if forced:
controlled_print(f"Overwriting existing git hook: {dest_hook}")
else:
controlled_print(f"Git hook already exists, skipping: {dest_hook}")
continue
try:
shutil.copy2(hook_file, dest_hook)
except Exception as e: # pylint: disable=broad-exception-caught
controlled_print(f"Error: Could not copy hook {hook_file} to {dest_hook}: {e}")
controlled_print("Skipping this hook.")
continue
try:
dest_hook.chmod(dest_hook.stat().st_mode | stat.S_IEXEC)
except Exception: # pylint: disable=broad-exception-caught
controlled_print(f"Warning: Could not set executable permission for {dest_hook}")
controlled_print(f"Installed git hook: {dest_hook}")
except Exception as e: # pylint: disable=broad-exception-caught
controlled_print(f"Error while installing Git hooks: {e}")
controlled_print("Skipping Git hooks installation. Some hooks may not be installed.")
def _install_hg_hooks(repo_root: Path, forced: bool) -> None:
"""Installs Mercurial hooks from the 'hooks/' directory in the repository root.
:param repo_root Path: The root directory of the repository.
:param forced bool: Whether to force overwrite existing hooks.
"""
_validate_path(repo_root, "repo_root", exists=True)
hg_dir = repo_root / ".hg"
hgrc_file = hg_dir / "hgrc"
source_hooks_dir = repo_root / "hooks"
relative_hooks_path = "../hooks"
if not source_hooks_dir.exists():
controlled_print("No hooks/ directory found; skipping Mercurial hook installation.")
return
try:
if not hgrc_file.exists():
controlled_print(f"No hgrc file found at {hgrc_file}; creating a new one.")
hgrc_file.touch()
except Exception as e: # pylint: disable=broad-exception-caught
controlled_print(f"Error: Could not create hgrc file at {hgrc_file}: {e}")
controlled_print("Skipping Mercurial hooks installation.")
return
try:
installed_hooks: set[str] = _already_installed_hg_hooks(repo_root)
hook_entries = []
hook_names = set()
for hook_file in source_hooks_dir.iterdir():
if hook_file.is_file():
hook_name = hook_file.name
if not _is_valid_hg_hook_name(hook_name):
controlled_print(f"Skipping non-standard Mercurial hook: {hook_name}")
continue
if hook_name in installed_hooks and not forced:
controlled_print(f"Mercurial hook already configured, skipping: {hook_name}")
continue
hook_entries.append(f"{hook_name} = {relative_hooks_path}/{hook_name}\n")
hook_names.add(hook_name)
except Exception as e: # pylint: disable=broad-exception-caught
controlled_print(f"Error while preparing Mercurial hooks: {e}")
controlled_print("Skipping Mercurial hooks installation.")
return
if not hook_entries:
controlled_print("No Mercurial hooks to install; skipping.")
return
try:
uniquifier = 1
new_hgrc_file = hgrc_file.with_name(hgrc_file.name + f"new_{uniquifier}")
while new_hgrc_file.exists():
uniquifier += 1
new_hgrc_file = hgrc_file.with_name(hgrc_file.name + f"new_{uniquifier}")
if hook_entries:
hgrc_content: list[str] = hgrc_file.read_text().splitlines(keepends=True)
found_hooks_section = False
with new_hgrc_file.open("w") as hgrc:
in_hooks_section = False
line: str
for line in hgrc_content:
# Find [hooks] section
stripped_line = line.strip()
if stripped_line.startswith("[hooks]"):
in_hooks_section = True
found_hooks_section = True
hgrc.write(line)
continue
# Process lines in [hooks] section
if in_hooks_section:
# end of [hooks] section
if stripped_line.startswith("[") and stripped_line.endswith("]"):
in_hooks_section = False
# Add any remaining hooks before leaving section
for name in hook_names:
hgrc.write(f"{name} = {relative_hooks_path}/{name}\n")
# A hook entry
elif '=' in line:
hook_name, _ = line.split('=', 1)
hook_name = hook_name.strip()
if hook_name in hook_names:
hook_names.remove(hook_name)
if forced:
controlled_print(f"Overwriting existing Mercurial hook: {hook_name}")
line = f"{hook_name} = {relative_hooks_path}/{hook_name}\n"
else:
controlled_print(f"Mercurial hook already configured, skipping: {hook_name}")
hgrc.write(line)
# If no [hooks] section was found, add it at the end
if not found_hooks_section:
hgrc.write("[hooks]\n")
hgrc.writelines(hook_entries)
# Replace original hgrc with new one (backup original)
backup_suffix = '.bak.'
backup_uniquifier = 1
backup_file = hgrc_file.with_name(hgrc_file.name + f"{backup_suffix}{backup_uniquifier}")
while backup_file.exists():
backup_uniquifier += 1
backup_file = hgrc_file.with_name(hgrc_file.name + f"{backup_suffix}{backup_uniquifier}")
shutil.copy2(hgrc_file, backup_file)
shutil.move(new_hgrc_file, hgrc_file)
except Exception as e: # pylint: disable=broad-exception-caught
controlled_print(f"Error while installing Mercurial hooks: {e}")
controlled_print("Skipping Mercurial hooks installation.")
return
controlled_print(f"Configured Mercurial hooks in {hgrc_file}")
def _already_installed_hg_hooks(repo_root: Path) -> set[str]:
"""Returns a set of already installed Mercurial hook names from hgrc.
:param repo_root Path: The root directory of the repository.
:return set[str]: A set of installed hook names.
"""
_validate_path(repo_root, "repo_root", exists=True)
hg_dir: Path = repo_root / ".hg"
hgrc_file: Path = hg_dir / "hgrc"
installed_hooks: set[str] = set()
if not hgrc_file.exists():
return installed_hooks
with hgrc_file.open("r") as hgrc:
in_hooks_section = False
for line in hgrc:
line = line.strip()
if line.startswith("[hooks]"):
in_hooks_section = True
continue
if in_hooks_section:
if line.startswith("[") and line.endswith("]"):
break # End of hooks section
if '=' in line:
hook_name = line.split('=')[0].strip()
installed_hooks.add(hook_name)
return installed_hooks
def path_to_venv_python(venv_dir: Path) -> Path:
"""Returns the path to the Python executable within the virtual environment.
:param venv_dir Path: The directory of the virtual environment.
:param is_windows bool: Whether the platform is Windows.
:return: The path to the Python executable.
"""
_validate_path(venv_dir, "venv_dir", exists=False)
is_windows = _is_windows()
bin_dir = venv_dir / ("Scripts" if is_windows else "bin")
python_exe = bin_dir / ("python.exe" if is_windows else "python")
return python_exe
@cache
def pip_module_is_available(python_exe: Path) -> bool:
"""Checks if 'pip' is available in the given Python executable.
:param python_exe Path: The path to the Python executable.
:return: True if 'pip' is available, False otherwise.
"""
_validate_path(python_exe, "python_exe", exists=True)
stdout = subprocess.PIPE if not QUIET else subprocess.DEVNULL
stderr = subprocess.PIPE if not QUIET else subprocess.DEVNULL
try:
if DEBUG:
controlled_print(f"DEBUG: Running '{python_exe} -m pip --version' to check "
"for pip availability")
subprocess.run(
[python_exe, "-m", "pip", "--version"],
check=True,
stdout=stdout,
stderr=stderr
)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False
def create_virtual_environment(venv_dir: Path, python_exe: Path) -> None:
"""
Creates a virtual environment at the specified directory.
If the directory already exists, it skips creation.
:param venv_dir Path: The directory to create the virtual environment in.
:param python_exe Path: The path to the Python executable within the venv.
"""
_validate_path(venv_dir, "venv_dir", exists=False)
_validate_path(python_exe, "python_exe", exists=False)
if not venv_dir.exists():
controlled_print(f"Creating temporary virtual environment in '{venv_dir}'...")
create_venv(venv_dir, with_pip=True)
controlled_print("---> Ensuring pip CLI script is installed in the virtual environment...")
run_command([python_exe, "-m", "ensurepip", "--upgrade"])
controlled_print("---> Upgrading pip in the virtual environment to latest version...")
if not pip_module_is_available(python_exe):
pip_path = venv_dir / "Scripts" / "pip.exe" if _is_windows() else venv_dir / "bin" / "pip"
if not pip_path.exists():
controlled_print("Error: 'pip' is not available in the virtual environment after ensurepip.")
controlled_print("Please check your Python installation.")
raise FatalBootstrapError("'pip' not available in virtual environment.", error_code=1)
run_command([pip_path, "install", "--upgrade", "pip"])
else:
run_command([
python_exe, "-m", "pip", "install", "--upgrade", "pip", "--require-virtualenv"])
else:
controlled_print(f"Virtual environment '{venv_dir}' already exists. Skipping creation.")
def remove_virtual_environment(venv_dir: Path | None = None, quiet: bool = False) -> None:
"""Removes the temporary virtual environment directory.
:param venv_dir Path: The directory of the virtual environment to remove.
"""
if venv_dir is None:
return
if not quiet:
controlled_print(f"Removing temporary virtual environment at '{venv_dir}'...")
if venv_dir.exists():
if sys.version_info >= (3, 12):
shutil.rmtree(venv_dir, onexc=_remove_readonly)
else: # error handler deprecated in 3.12+
shutil.rmtree(venv_dir, onerror=_remove_readonly) # pylint: disable=deprecated-argument
def _remove_readonly(func, path, _):
"Clear the readonly bit and reattempt the removal"
os.chmod(path, stat.S_IWRITE)
func(path)
def install_tools(python_exe: Path, modules: list[InstallSpec]) -> None:
"""Installs core development tools into the virtual environment.
If 'uv' is specified in the modules, it is bootstrapped with pip
and used to install all the other modules; otherwise, the installation
falls back to 'pip' for all modules.
:param python_exe Path: The path to the Python executable within the venv.
:param modules list[InstallSpec]: A list of InstallSpec objects to install.
"""
_validate_path(python_exe, "python_exe", exists=True)
_validate_module_list(modules, "modules")
if not modules:
return
controlled_print("Installing/updating core development tools...")
using_uv = any(mod.name == "uv" for mod in modules)
if using_uv:
install_with_uv(python_exe, modules)
else:
install_with_pip(python_exe, modules)
def install_with_uv(python_exe: Path, modules: list[InstallSpec]) -> None:
"""Installs 'uv' using pip, then uses 'uv' to install the specified modules.
:param python_exe Path: The path to the Python executable within the venv.
:param modules list[InstallSpec]: A list of InstallSpec objects to install.
"""
_validate_path(python_exe, "python_exe", exists=True)
_validate_module_list(modules, "modules")
uv_module: InstallSpec = [mod for mod in modules if mod.name == "uv"][0]
other_modules: list[InstallSpec] = [mod for mod in modules if mod.name != "uv"]
bootstrap_message = (
f"--> Bootstrapping 'uv' using 'pip': {uv_module}, "
f"{uv_module.version or 'latest'}")
install_with_pip(python_exe, [uv_module], message=bootstrap_message)
if not other_modules:
return
controlled_print("--> Installing remaining modules using 'uv pip'")
command = _build_install_command(
[python_exe, "-m", "uv", "pip"], other_modules
)
run_command(command)
def install_with_pip(python_exe: Path, modules: list[InstallSpec], message: str = '') -> None:
"""Installs the specified modules using 'pip'.
:param python_exe Path: The path to the Python executable within the venv.
:param modules: A list of InstallSpec objects to install.
:param message str: An optional message to print before installation.