pw_ide: Create Python symlinks

Provide commands for creating symlinks to the Python virtual environment
and associated bin directory. The location can vary depending on the
name of the environment directory, and the Python venv directory
structure is different on Windows compared to other platforms. This
allows us to provide stable references to the venv to IDEs regardless of
those variations.

Change-Id: I738e9729b695982b2d6a6fe3ae73923efa54d891
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/110256
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Chad Norvell <chadnorvell@google.com>
diff --git a/pw_ide/py/BUILD.gn b/pw_ide/py/BUILD.gn
index b9ab35c..2f7d093 100644
--- a/pw_ide/py/BUILD.gn
+++ b/pw_ide/py/BUILD.gn
@@ -28,6 +28,7 @@
     "pw_ide/commands.py",
     "pw_ide/cpp.py",
     "pw_ide/exceptions.py",
+    "pw_ide/python.py",
     "pw_ide/settings.py",
     "pw_ide/symlinks.py",
   ]
diff --git a/pw_ide/py/pw_ide/__main__.py b/pw_ide/py/pw_ide/__main__.py
index a7a8b82..f15efbf 100644
--- a/pw_ide/py/pw_ide/__main__.py
+++ b/pw_ide/py/pw_ide/__main__.py
@@ -18,7 +18,7 @@
 import sys
 from typing import (Any, Callable, cast, Dict, Generic, NoReturn, Optional,
                     TypeVar, Union)
-from pw_ide.commands import cmd_cpp
+from pw_ide.commands import cmd_cpp, cmd_python
 
 # TODO(chadnorvell): Move this docstring-as-argparse-docs functionality
 # to pw_cli.
@@ -160,6 +160,18 @@
                             'compilation database format.')
     parser_cpp.set_defaults(func=cmd_cpp)
 
+    parser_python = subcommand_parser.add_parser(
+        'python',
+        description=_docstring_summary(cmd_python.__doc__),
+        help=_reflow_docstring(cmd_python.__doc__))
+    parser_python.add_argument('-v',
+                               '--virtual-env',
+                               dest='should_get_venv_path',
+                               action='store_true',
+                               help='Return the path to the Pigweed Python '
+                               'virtual environment.')
+    parser_python.set_defaults(func=cmd_python)
+
     args = parser_root.parse_args()
     return args
 
diff --git a/pw_ide/py/pw_ide/commands.py b/pw_ide/py/pw_ide/commands.py
index 17b55c6..1b15086 100644
--- a/pw_ide/py/pw_ide/commands.py
+++ b/pw_ide/py/pw_ide/commands.py
@@ -14,6 +14,7 @@
 """pw_ide CLI command implementations."""
 
 from pathlib import Path
+import platform
 import sys
 from typing import Optional
 
@@ -22,7 +23,10 @@
                         write_compilation_databases)
 
 from pw_ide.exceptions import (BadCompDbException, InvalidTargetException,
-                               MissingCompDbException)
+                               MissingCompDbException,
+                               UnsupportedPlatformException)
+
+from pw_ide.python import get_python_venv_path
 
 from pw_ide.settings import IdeSettings
 
@@ -41,6 +45,16 @@
     print('')
 
 
+def _print_python_venv_path() -> None:
+    print('Python virtual environment path: ' f'{get_python_venv_path()}\n')
+
+
+def _print_unsupported_platform_error(msg: str = 'run') -> None:
+    system = platform.system()
+    system = 'None' if system == '' else system
+    print(f'Failed to {msg} on this unsupported platform: {system}\n')
+
+
 def cmd_cpp(
     should_list_targets: bool,
     target_to_set: Optional[str],
@@ -110,3 +124,14 @@
 
     if default:
         _print_current_target(settings)
+
+
+def cmd_python(should_get_venv_path: bool) -> None:
+    """Configure Python IDE support for Pigweed projects."""
+
+    if should_get_venv_path:
+        try:
+            _print_python_venv_path()
+        except UnsupportedPlatformException:
+            _print_unsupported_platform_error(
+                'find Python virtual environment')
diff --git a/pw_ide/py/pw_ide/python.py b/pw_ide/py/pw_ide/python.py
new file mode 100644
index 0000000..121a02b
--- /dev/null
+++ b/pw_ide/py/pw_ide/python.py
@@ -0,0 +1,83 @@
+# Copyright 2022 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Configure Python IDE support for Pigweed projects."""
+
+from collections import defaultdict
+import os
+from pathlib import Path
+import platform
+from typing import Dict, NamedTuple
+
+from pw_ide.exceptions import UnsupportedPlatformException
+from pw_ide.symlinks import set_symlink
+
+PYTHON_SYMLINK_NAME = 'python'
+PYTHON_BIN_DIR_SYMLINK_NAME = 'python-bin'
+
+PYTHON_VENV_PATH = Path(
+    os.path.expandvars('$_PW_ACTUAL_ENVIRONMENT_ROOT')) / 'pigweed-venv'
+
+PW_PROJECT_ROOT = Path(os.path.expandvars('$PW_PROJECT_ROOT'))
+
+
+class PythonPaths(NamedTuple):
+    """Holds the name of platform-specific Python env paths.
+
+    The directory layout of Python virtual environments varies among
+    platforms. This class holds the data needed to find the right paths
+    for a specific platform.
+    """
+    # Name of the binaries directory
+    bin_dir: str = 'bin'
+    # Name of the interpreter executable
+    interpreter: str = 'python3'
+
+
+# When given a platform (e.g. the output of platform.system()), this dict gives
+# the platform-specific virtualenv path names.
+PYTHON_PATHS: Dict[str, PythonPaths] = defaultdict(PythonPaths)
+PYTHON_PATHS['Windows'] = PythonPaths(bin_dir='Scripts',
+                                      interpreter='pythonw.exe')
+
+
+def get_python_venv_path(system: str = platform.system()) -> Path:
+    """Return the path to the Python virtual environment interpreter."""
+    if system == '':
+        raise UnsupportedPlatformException()
+
+    (bin_dir, interpreter) = PYTHON_PATHS[system]
+    abs_path = Path(PYTHON_VENV_PATH) / bin_dir / interpreter
+    return abs_path.relative_to(PW_PROJECT_ROOT)
+
+
+def create_python_symlink(
+    working_dir: Path, system: str = platform.system()) -> None:
+    """Create symlinks to the Python virtual environment.
+
+    The location of the virtual environment varies depending on platform and
+    environment directory. This provides a stable reference for IDE features.
+    """
+    if system == '':
+        raise UnsupportedPlatformException()
+
+    (bin_dir, interpreter) = PYTHON_PATHS[system]
+
+    python_venv_bin_path = Path(PYTHON_VENV_PATH) / bin_dir
+    python_venv_interpreter_path = python_venv_bin_path / interpreter
+
+    interpreter_symlink_path = working_dir / PYTHON_SYMLINK_NAME
+    set_symlink(python_venv_interpreter_path, interpreter_symlink_path)
+
+    bin_dir_symlink_path = working_dir / PYTHON_BIN_DIR_SYMLINK_NAME
+    set_symlink(python_venv_bin_path, bin_dir_symlink_path)