1"""Implementation of packaging-related magic functions.
2"""
3#-----------------------------------------------------------------------------
4# Copyright (c) 2018 The IPython Development Team.
5#
6# Distributed under the terms of the Modified BSD License.
7#
8# The full license is in the file COPYING.txt, distributed with this software.
9#-----------------------------------------------------------------------------
10
11import functools
12import os
13import re
14import shlex
15import sys
16from pathlib import Path
17
18from IPython.core.magic import Magics, magics_class, line_magic
19
20
21def is_conda_environment(func):
22 @functools.wraps(func)
23 def wrapper(*args, **kwargs):
24 """Return True if the current Python executable is in a conda env"""
25 # TODO: does this need to change on windows?
26 if not Path(sys.prefix, "conda-meta", "history").exists():
27 raise ValueError(
28 "The python kernel does not appear to be a conda environment. "
29 "Please use ``%pip install`` instead."
30 )
31 return func(*args, **kwargs)
32
33 return wrapper
34
35
36def _get_conda_like_executable(command):
37 """Find the path to the given executable
38
39 Parameters
40 ----------
41
42 executable: string
43 Value should be: conda, mamba or micromamba
44 """
45 # Check for a environment variable bound to the base executable, both conda and mamba
46 # set these when activating an environment.
47 base_executable = "CONDA_EXE"
48 if "mamba" in command.lower():
49 base_executable = "MAMBA_EXE"
50 if base_executable in os.environ:
51 executable = Path(os.environ[base_executable])
52 if executable.is_file():
53 return str(executable.resolve())
54
55 # Check if there is a conda executable in the same directory as the Python executable.
56 # This is the case within conda's root environment.
57 executable = Path(sys.executable).parent / command
58 if executable.is_file():
59 return str(executable)
60
61 # Otherwise, attempt to extract the executable from conda history.
62 # This applies in any conda environment. Parsing this way is error prone because
63 # different versions of conda and mamba include differing cmd values such as
64 # `conda`, `conda-script.py`, or `path/to/conda`, here use the raw command provided.
65 history = Path(sys.prefix, "conda-meta", "history").read_text(encoding="utf-8")
66 match = re.search(
67 rf"^#\s*cmd:\s*(?P<command>.*{command})\s[create|install]",
68 history,
69 flags=re.MULTILINE,
70 )
71 if match:
72 return match.groupdict()["command"]
73
74 # Fallback: assume the executable is available on the system path.
75 return command
76
77
78CONDA_COMMANDS_REQUIRING_PREFIX = {
79 'install', 'list', 'remove', 'uninstall', 'update', 'upgrade',
80}
81CONDA_COMMANDS_REQUIRING_YES = {
82 'install', 'remove', 'uninstall', 'update', 'upgrade',
83}
84CONDA_ENV_FLAGS = {'-p', '--prefix', '-n', '--name'}
85CONDA_YES_FLAGS = {'-y', '--y'}
86
87
88@magics_class
89class PackagingMagics(Magics):
90 """Magics related to packaging & installation"""
91
92 @line_magic
93 def pip(self, line):
94 """Run the pip package manager within the current kernel.
95
96 Usage:
97 %pip install [pkgs]
98 """
99 python = sys.executable
100 if sys.platform == "win32":
101 python = '"' + python + '"'
102 else:
103 python = shlex.quote(python)
104
105 self.shell.system(" ".join([python, "-m", "pip", line]))
106
107 print("Note: you may need to restart the kernel to use updated packages.")
108
109 def _run_command(self, cmd, line):
110 args = shlex.split(line)
111 command = args[0] if len(args) > 0 else ""
112 args = args[1:] if len(args) > 1 else [""]
113
114 extra_args = []
115
116 # When the subprocess does not allow us to respond "yes" during the installation,
117 # we need to insert --yes in the argument list for some commands
118 stdin_disabled = getattr(self.shell, 'kernel', None) is not None
119 needs_yes = command in CONDA_COMMANDS_REQUIRING_YES
120 has_yes = set(args).intersection(CONDA_YES_FLAGS)
121 if stdin_disabled and needs_yes and not has_yes:
122 extra_args.append("--yes")
123
124 # Add --prefix to point conda installation to the current environment
125 needs_prefix = command in CONDA_COMMANDS_REQUIRING_PREFIX
126 has_prefix = set(args).intersection(CONDA_ENV_FLAGS)
127 if needs_prefix and not has_prefix:
128 extra_args.extend(["--prefix", sys.prefix])
129
130 self.shell.system(" ".join([cmd, command] + extra_args + args))
131 print("\nNote: you may need to restart the kernel to use updated packages.")
132
133 @line_magic
134 @is_conda_environment
135 def conda(self, line):
136 """Run the conda package manager within the current kernel.
137
138 Usage:
139 %conda install [pkgs]
140 """
141 conda = _get_conda_like_executable("conda")
142 self._run_command(conda, line)
143
144 @line_magic
145 @is_conda_environment
146 def mamba(self, line):
147 """Run the mamba package manager within the current kernel.
148
149 Usage:
150 %mamba install [pkgs]
151 """
152 mamba = _get_conda_like_executable("mamba")
153 self._run_command(mamba, line)
154
155 @line_magic
156 @is_conda_environment
157 def micromamba(self, line):
158 """Run the conda package manager within the current kernel.
159
160 Usage:
161 %micromamba install [pkgs]
162 """
163 micromamba = _get_conda_like_executable("micromamba")
164 self._run_command(micromamba, line)
165
166 @line_magic
167 def uv(self, line):
168 """Run the uv package manager within the current kernel.
169
170 Usage:
171 %uv pip install [pkgs]
172 """
173 python = sys.executable
174 if sys.platform == "win32":
175 python = '"' + python + '"'
176 else:
177 python = shlex.quote(python)
178
179 self.shell.system(" ".join([python, "-m", "uv", line]))
180
181 print("Note: you may need to restart the kernel to use updated packages.")