• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14# pylint: disable=line-too-long
15"""Pigweed shell activation script.
16
17Aside from importing it, this script can be used in three ways:
18
191. Activate the Pigweed environment in your current shell (i.e., modify your
20   interactive shell's environment with Pigweed environment variables).
21
22   Using bash (assuming a global Python 3 is in $PATH):
23       source <(python3 ./pw_ide/activate.py -s bash)
24
25   Using bash (using the environment Python):
26       source <({environment}/pigweed-venv/bin/python ./pw_ide/activate.py -s bash)
27
282. Run a shell command or executable in an activated shell (i.e. apply a
29   modified environment to a subprocess without affecting your current
30   interactive shell).
31
32    Example (assuming a global Python 3 is in $PATH):
33        python3 ./pw_ide/activate.py -x 'pw ide cpp --list'
34
35    Example (using the environment Python):
36        {environment}/pigweed-venv/bin/python ./pw_ide/activate.py -x 'pw ide cpp --list'
37
38    Example (using the environment Python on Windows):
39        {environment}/pigweed-venv/Scripts/pythonw.exe ./pw_ide/activate.py -x 'pw ide cpp --list'
40
413. Produce a JSON representation of the Pigweed activated environment (-O) or
42   the diff against your current environment that produces an activated
43   environment (-o). See the help for more detailed information on the options
44   available.
45
46    Example (assuming a global Python 3 is in $PATH):
47        python3 ./pw_ide/activate.py -o
48
49    Example (using the environment Python):
50        {environment}/pigweed-venv/bin/python ./pw_ide/activate.py -o
51
52    Example (using the environment Python on Windows):
53        {environment}/pigweed-venv/Scripts/pythonw.exe ./pw_ide/activate.py -o
54"""
55# pylint: enable=line-too-long
56
57from abc import abstractmethod, ABC
58import argparse
59from collections import defaultdict
60from inspect import cleandoc
61import json
62import os
63from pathlib import Path
64import shlex
65import subprocess
66import sys
67from typing import cast, Dict, Optional
68
69# This expects this file to be in the Python module. If it ever moves
70# (e.g. to the root of the repository), this will need to change.
71_PW_PROJECT_PATH = Path(
72    os.environ.get(
73        'PW_PROJECT_ROOT', os.environ.get('PW_ROOT', Path(__file__).parents[3])
74    )
75)
76
77
78def assumed_environment_root() -> Optional[Path]:
79    """Infer the path to the Pigweed environment directory.
80
81    First we look at the environment variable that should contain the path if
82    we're operating in an activated Pigweed environment. If we don't find the
83    path there, we check a few known locations. If we don't find an environment
84    directory in any of those locations, we return None.
85    """
86    actual_environment_root = os.environ.get('_PW_ACTUAL_ENVIRONMENT_ROOT')
87    if (
88        actual_environment_root is not None
89        and (root_path := Path(actual_environment_root)).exists()
90    ):
91        return root_path.absolute()
92
93    default_environment = _PW_PROJECT_PATH / 'environment'
94    if default_environment.exists():
95        return default_environment.absolute()
96
97    default_dot_environment = _PW_PROJECT_PATH / '.environment'
98    if default_dot_environment.exists():
99        return default_dot_environment.absolute()
100
101    return None
102
103
104# We're looking for the `actions.json` file that allows us to activate the
105# Pigweed environment. That file is located in the Pigweed environment
106# directory, so if we found an environment directory, this variable will
107# have the path to `actions.json`. If it doesn't find an environment directory
108# (e.g., this isn't being executed in the context of a Pigweed project), this
109# will be None. Note that this is the "default" config file path because
110# callers of functions that need this path can provide their own paths to an
111# `actions.json` file.
112_DEFAULT_CONFIG_FILE_PATH = (
113    None
114    if assumed_environment_root() is None
115    else cast(Path, assumed_environment_root()) / 'actions.json'
116)
117
118
119def _sanitize_path(
120    path: str, project_root_prefix: str, user_home_prefix: str
121) -> str:
122    """Given a path, return a sanitized path.
123
124    By default, environment variable paths are usually absolute. If we want
125    those paths to work across multiple systems, we need to sanitize them. This
126    takes a string that may be a path, and if it is indeed a path, it returns
127    the sanitized path, which is relative to either the repository root or the
128    user's home directory. If it's not a path, it just returns the input.
129
130    You can provide the strings that should be substituted for the project root
131    and the user's home directory. This may be useful for applications that have
132    their own way of representing those directories.
133
134    Note that this is intended to work on Pigweed environment variables, which
135    should all be relative to either of those two locations. Paths that aren't
136    (e.g. the path to a system binary) won't really be sanitized.
137    """
138    # Return the argument if it's not actually a path.
139    # This strategy relies on the fact that env_setup outputs absolute paths for
140    # all path env vars. So if we get a variable that's not an absolute path, it
141    # must not be a path at all.
142    if not Path(path).is_absolute():
143        return path
144
145    project_root = _PW_PROJECT_PATH.resolve()
146    user_home = Path.home().resolve()
147    resolved_path = Path(path).resolve()
148
149    # TODO(b/248257406) Remove once we drop support for Python 3.8.
150    def is_relative_to(path: Path, other: Path) -> bool:
151        try:
152            path.relative_to(other)
153            return True
154        except ValueError:
155            return False
156
157    if is_relative_to(resolved_path, project_root):
158        return f'{project_root_prefix}/' + str(
159            resolved_path.relative_to(project_root)
160        )
161
162    if is_relative_to(resolved_path, user_home):
163        return f'{user_home_prefix}/' + str(
164            resolved_path.relative_to(user_home)
165        )
166
167    # Path is not in the project root or user home, so just return it as is.
168    return path
169
170
171class ShellModifier(ABC):
172    """Abstract class for shell modifiers.
173
174    A shell modifier provides an interface for modifying the environment
175    variables in various shells. You can pass in a current environment state
176    as a dictionary during instantiation and modify it and/or modify shell state
177    through other side effects.
178    """
179
180    separator = ':'
181    comment = '# '
182
183    def __init__(
184        self,
185        env: Optional[Dict[str, str]] = None,
186        env_only: bool = False,
187        path_var: str = '$PATH',
188        project_root: str = '.',
189        user_home: str = '~',
190    ):
191        # This will contain only the modifications to the environment, with
192        # no elements of the existing environment aside from variables included
193        # here. In that sense, it's like a diff against the existing
194        # environment, or a structured form of the shell modification side
195        # effects.
196        default_env_mod = {'PATH': path_var}
197        self.env_mod = default_env_mod.copy()
198
199        # This is seeded with the existing environment, and then is modified.
200        # So it contains the complete new environment after modifications.
201        # If no existing environment is provided, this is identical to env_mod.
202        env = env if env is not None else default_env_mod.copy()
203        self.env: Dict[str, str] = defaultdict(str, env)
204
205        # Will contain the side effects, i.e. commands executed in the shell to
206        # modify its environment.
207        self.side_effects = ''
208
209        # Set this to not do any side effects, but just modify the environment
210        # stored in this class.
211        self.env_only = env_only
212
213        self.project_root = project_root
214        self.user_home = user_home
215
216    def do_effect(self, effect: str):
217        """Add to the commands that will affect the shell's environment.
218
219        This is a no-op if the shell modifier is set to only store shell
220        modification data rather than doing the side effects.
221        """
222        if not self.env_only:
223            self.side_effects += f'{effect}\n'
224
225    def modify_env(
226        self,
227        config_file_path: Optional[Path] = _DEFAULT_CONFIG_FILE_PATH,
228        sanitize: bool = False,
229    ) -> 'ShellModifier':
230        """Modify the current shell state per the actions.json file provided."""
231        json_file_options = {}
232
233        if config_file_path is None:
234            raise RuntimeError(
235                'This must be run from a bootstrapped Pigweed directory!'
236            )
237
238        with config_file_path.open('r') as json_file:
239            json_file_options = json.loads(json_file.read())
240
241        root = self.project_root
242        home = self.user_home
243
244        # Set env vars
245        for var_name, value in json_file_options.get('set', dict()).items():
246            if value is not None:
247                value = _sanitize_path(value, root, home) if sanitize else value
248                self.set_variable(var_name, value)
249
250        # Prepend & append env vars
251        for var_name, mode_changes in json_file_options.get(
252            'modify', dict()
253        ).items():
254            for mode_name, values in mode_changes.items():
255                if mode_name in ['prepend', 'append']:
256                    modify_variable = self.prepend_variable
257
258                    if mode_name == 'append':
259                        modify_variable = self.append_variable
260
261                    for value in values:
262                        value = (
263                            _sanitize_path(value, root, home)
264                            if sanitize
265                            else value
266                        )
267                        modify_variable(var_name, value)
268
269        return self
270
271    @abstractmethod
272    def set_variable(self, var_name: str, value: str) -> None:
273        pass
274
275    @abstractmethod
276    def prepend_variable(self, var_name: str, value: str) -> None:
277        pass
278
279    @abstractmethod
280    def append_variable(self, var_name: str, value: str) -> None:
281        pass
282
283
284class BashShellModifier(ShellModifier):
285    """Shell modifier for bash."""
286
287    def set_variable(self, var_name: str, value: str):
288        self.env[var_name] = value
289        self.env_mod[var_name] = value
290        quoted_value = shlex.quote(value)
291        self.do_effect(f'export {var_name}={quoted_value}')
292
293    def prepend_variable(self, var_name: str, value: str) -> None:
294        self.env[var_name] = f'{value}{self.separator}{self.env[var_name]}'
295        self.env_mod[
296            var_name
297        ] = f'{value}{self.separator}{self.env_mod[var_name]}'
298        quoted_value = shlex.quote(value)
299        self.do_effect(
300            f'export {var_name}={quoted_value}{self.separator}${var_name}'
301        )
302
303    def append_variable(self, var_name: str, value: str) -> None:
304        self.env[var_name] = f'{self.env[var_name]}{self.separator}{value}'
305        self.env_mod[
306            var_name
307        ] = f'{self.env_mod[var_name]}{self.separator}{value}'
308        quoted_value = shlex.quote(value)
309        self.do_effect(
310            f'export {var_name}=${var_name}{self.separator}{quoted_value}'
311        )
312
313
314def _build_argument_parser() -> argparse.ArgumentParser:
315    """Set up `argparse`."""
316    doc = __doc__
317
318    try:
319        env_root = assumed_environment_root()
320    except RuntimeError:
321        env_root = None
322
323    # Substitute in the actual environment path in the help text, if we can
324    # find it. If not, leave the placeholder text.
325    if env_root is not None:
326        doc = doc.replace(
327            '{environment}', str(env_root.relative_to(Path.cwd()))
328        )
329
330    parser = argparse.ArgumentParser(
331        formatter_class=argparse.RawDescriptionHelpFormatter,
332        description=doc,
333    )
334
335    default_config_file_path = None
336
337    if _DEFAULT_CONFIG_FILE_PATH is not None:
338        default_config_file_path = _DEFAULT_CONFIG_FILE_PATH.relative_to(
339            Path.cwd()
340        )
341
342    parser.add_argument(
343        '-c',
344        '--config-file',
345        default=_DEFAULT_CONFIG_FILE_PATH,
346        type=Path,
347        help='Path to actions.json config file, which defines '
348        'the modifications to the shell environment '
349        'needed to activate Pigweed. '
350        f'Default: {default_config_file_path}',
351    )
352
353    default_shell = Path(os.environ['SHELL']).name
354    parser.add_argument(
355        '-s',
356        '--shell-mode',
357        default=default_shell,
358        help='Which shell is being used. ' f'Default: {default_shell}',
359    )
360
361    parser.add_argument(
362        '-o',
363        '--out',
364        action='store_true',
365        help='Write only the modifications to the environment ' 'out to JSON.',
366    )
367
368    parser.add_argument(
369        '-O',
370        '--out-all',
371        action='store_true',
372        help='Write the complete modified environment to ' 'JSON.',
373    )
374
375    parser.add_argument(
376        '-n',
377        '--sanitize',
378        action='store_true',
379        help='Sanitize paths that are relative to the repo '
380        'root or user home directory so that they are portable '
381        'to other workstations.',
382    )
383
384    parser.add_argument(
385        '--path-var',
386        default='$PATH',
387        help='The string to substitute for the existing $PATH. Default: $PATH',
388    )
389
390    parser.add_argument(
391        '--project-root',
392        default='.',
393        help='The string to substitute for the project root when sanitizing '
394        'paths. Default: .',
395    )
396
397    parser.add_argument(
398        '--user-home',
399        default='~',
400        help='The string to substitute for the user\'s home when sanitizing '
401        'paths. Default: ~',
402    )
403
404    parser.add_argument(
405        '-x',
406        '--exec',
407        help='A command to execute in the activated shell.',
408        metavar='COMMAND',
409    )
410
411    return parser
412
413
414def main() -> int:
415    """The main CLI script."""
416    args, _unused_extra_args = _build_argument_parser().parse_known_args()
417    env = os.environ.copy()
418    config_file_path = args.config_file
419
420    if not config_file_path.exists():
421        sys.stderr.write(f'File not found! {config_file_path}')
422        sys.stderr.write(
423            'This must be run from a bootstrapped Pigweed ' 'project directory.'
424        )
425        sys.exit(1)
426
427    # If we're executing a command in a subprocess, don't modify the current
428    # shell's state. Instead, apply the modified state to the subprocess.
429    env_only = args.exec is not None
430
431    # Assume bash by default.
432    shell_modifier = BashShellModifier
433
434    # TODO(chadnorvell): if args.shell_mode == 'zsh', 'ksh', 'fish'...
435    try:
436        modified_env = shell_modifier(
437            env=env,
438            env_only=env_only,
439            path_var=args.path_var,
440            project_root=args.project_root,
441            user_home=args.user_home,
442        ).modify_env(config_file_path, args.sanitize)
443    except (FileNotFoundError, json.JSONDecodeError):
444        sys.stderr.write(
445            'Unable to read file: {}\n'
446            'Please run this in bash or zsh:\n'
447            '  . ./bootstrap.sh\n'.format(str(config_file_path))
448        )
449
450        sys.exit(1)
451
452    if args.out_all:
453        print(json.dumps(modified_env.env, sort_keys=True, indent=2))
454        return 0
455
456    if args.out:
457        print(json.dumps(modified_env.env_mod, sort_keys=True, indent=2))
458        return 0
459
460    if args.exec is not None:
461        # We're executing a command in a subprocess with the modified env.
462        return subprocess.run(
463            args.exec, env=modified_env.env, shell=True
464        ).returncode
465
466    # If we got here, we're trying to modify the current shell's env.
467    print(modified_env.side_effects)
468
469    # Let's warn the user if the output is going to stdout instead of being
470    # executed by the shell.
471    python_path = Path(sys.executable).relative_to(os.getcwd())
472    c = shell_modifier.comment  # pylint: disable=invalid-name
473    print(
474        cleandoc(
475            f"""
476        {c}
477        {c}Can you see these commands? If so, you probably wanted to
478        {c}source this script instead of running it. Try this instead:
479        {c}
480        {c}    . <({str(python_path)} {' '.join(sys.argv)})
481        {c}
482        {c}Run this script with `-h` for more help."""
483        )
484    )
485    return 0
486
487
488if __name__ == '__main__':
489    sys.exit(main())
490