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