Skip to content

Commit

Permalink
pw_ide: Support different workspace root
Browse files Browse the repository at this point in the history
Ensures that IDE functionality works even when your workspace root
is different from your Pigweed project root.

Change-Id: I9da76a9f7404044c1a818adf9ab2622116a6bb53
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/217220
Reviewed-by: Alan Rosenthal <[email protected]>
Lint: Lint 🤖 <[email protected]>
Commit-Queue: Chad Norvell <[email protected]>
Reviewed-by: Keir Mierle <[email protected]>
  • Loading branch information
chadnorvell authored and CQ Bot Account committed Aug 14, 2024
1 parent c0a2958 commit 5b3ea9c
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 32 deletions.
1 change: 1 addition & 0 deletions pw_ide/guide/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ files have the same schema, in which these options can be configured:
.. autoproperty:: pw_ide.settings.PigweedIdeSettings.cascade_targets
.. autoproperty:: pw_ide.settings.PigweedIdeSettings.sync
.. autoproperty:: pw_ide.settings.PigweedIdeSettings.clangd_additional_query_drivers
.. autoproperty:: pw_ide.settings.PigweedIdeSettings.workspace_root

When to provide additional configuration to support your use cases
==================================================================
Expand Down
1 change: 1 addition & 0 deletions pw_ide/py/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pw_python_package("py") {
"pw_ide/vscode.py",
]
tests = [
"activate_tests.py",
"commands_test.py",
"cpp_test.py",
"cpp_test_fake_env.py",
Expand Down
72 changes: 72 additions & 0 deletions pw_ide/py/activate_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2024 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.
"""Tests for pw_ide.activate"""

import unittest
from pw_ide.activate import (
find_pigweed_json_above,
find_pigweed_json_below,
pigweed_root,
)

from test_cases import TempDirTestCase


class TestFindPigweedJson(TempDirTestCase):
"""Test functions that find pigweed.json"""

def test_find_pigweed_json_above_1_level(self):
self.touch_temp_file("pigweed.json")
nested_dir = self.temp_dir_path / "nested"
nested_dir.mkdir()
presumed_root = find_pigweed_json_above(nested_dir)
self.assertEqual(presumed_root, self.temp_dir_path)

def test_find_pigweed_json_above_2_levels(self):
self.touch_temp_file("pigweed.json")
nested_dir = self.temp_dir_path / "nested" / "again"
nested_dir.mkdir(parents=True)
presumed_root = find_pigweed_json_above(nested_dir)
self.assertEqual(presumed_root, self.temp_dir_path)

def test_find_pigweed_json_below_1_level(self):
nested_dir = self.temp_dir_path / "nested"
nested_dir.mkdir()
self.touch_temp_file(nested_dir / "pigweed.json")
presumed_root = find_pigweed_json_below(self.temp_dir_path)
self.assertEqual(presumed_root, nested_dir)

def test_find_pigweed_json_below_2_level(self):
nested_dir = self.temp_dir_path / "nested" / "again"
nested_dir.mkdir(parents=True)
self.touch_temp_file(nested_dir / "pigweed.json")
presumed_root = find_pigweed_json_below(self.temp_dir_path)
self.assertEqual(presumed_root, nested_dir)

def test_pigweed_json_above_is_preferred(self):
self.touch_temp_file("pigweed.json")

workspace_dir = self.temp_dir_path / "workspace"
workspace_dir.mkdir()

pigweed_dir = workspace_dir / "pigweed"
pigweed_dir.mkdir()
self.touch_temp_file(pigweed_dir / "pigweed.json")

presumed_root, _ = pigweed_root(self.temp_dir_path, False)
self.assertEqual(presumed_root, self.temp_dir_path)


if __name__ == '__main__':
unittest.main()
102 changes: 95 additions & 7 deletions pw_ide/py/pw_ide/activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,99 @@
import argparse
from collections import defaultdict
from inspect import cleandoc
from functools import cache
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
from typing import cast
from typing import cast, Tuple

_PW_PROJECT_PATH = Path(
os.environ.get('PW_PROJECT_ROOT', os.environ.get('PW_ROOT', os.getcwd()))
)

def find_pigweed_json_above(working_dir=Path(os.getcwd())) -> Path | None:
"""Find the path to pigweed.json by searching this directory and above.
This starts looking in the current working directory, then recursively in
each directory above the current working directory, until it finds a
pigweed.json file or reaches the file system root. So invoking this anywhere
within a Pigweed project directory should work.
"""

if (pigweed_json := (working_dir / "pigweed.json")).exists():
return pigweed_json.parent

# Recursively search in directories above this one.
# This condition will be false when we reach the root of the file system.
if working_dir.parent != working_dir:
return find_pigweed_json_above(working_dir.parent)

return None


def find_pigweed_json_below(working_dir=Path(os.getcwd())) -> Path | None:
"""Find the path to pigweed.json by searching below this directory.
This will return the one nearest subdirectory that contains a pigweeed.json
file.
"""

# We only want the first result (there could be many other spurious
# pigweed.json files in environment directories), but we can't directly
# index a generator, and getting a list by running the generator to
# completion is pointlessly slow.
for path in working_dir.rglob('pigweed.json'):
dir_path = path.parent
# There's a source file for pigweed.json that we want to omit.
if dir_path.parent.name != "cipd_setup":
return dir_path

return None


@cache
def pigweed_root(
working_dir=Path(os.getcwd()), use_env_vars=True
) -> Tuple[Path, bool]:
"""Find the Pigweed root within the project.
The presence of a pigweed.json file is the sentinel for the Pigweed root.
The heuristic is to first search in the current directory or above ("are
we inside of a Pigweed directory?"), and failing that, to search in the
directories below ("does this project contain a Pigweed directory?").
This returns two values: the Pigweed root directory, and a boolean
indicating that the directory path is "validated". A validated path came
from a source of truth, like the location of a pigweed.json file or an
environment variable. An unvalidated path may just be a last resort guess.
Note that this logic presumes that there's only one Pigweed project
directory. In a hypothetical project setup that contained multiple Pigweed
projects, this would continue to work when invoked inside of one of those
Pigweed directories, but would have inconsistent results when invoked
in a parent directory.
"""
# These are shortcuts that make this faster if we happen to be running in
# an activated environment. This can be disabled, e.g., in tests.
if use_env_vars:
if (pw_root := os.environ.get('PW_ROOT')) is not None:
return Path(pw_root), True

if (pw_project_root := os.environ.get('PW_PROJECT_ROOT')) is not None:
return Path(pw_project_root), True

# If we're not in an activated environment (which is typical for this
# module), search for the pigweed.json sentinel.
if (root_above := find_pigweed_json_above(working_dir)) is not None:
return root_above, True

if (root_below := find_pigweed_json_below(working_dir)) is not None:
return root_below, True

return Path(os.getcwd()), False


@cache
def assumed_environment_root() -> Path | None:
"""Infer the path to the Pigweed environment directory.
Expand All @@ -82,17 +162,23 @@ def assumed_environment_root() -> Path | None:
directory in any of those locations, we return None.
"""
actual_environment_root = os.environ.get('_PW_ACTUAL_ENVIRONMENT_ROOT')

if (
actual_environment_root is not None
and (root_path := Path(actual_environment_root)).exists()
):
return root_path.absolute()

default_environment = _PW_PROJECT_PATH / 'environment'
root, root_is_validated = pigweed_root()

if not root_is_validated:
return None

default_environment = root / 'environment'
if default_environment.exists():
return default_environment.absolute()

default_dot_environment = _PW_PROJECT_PATH / '.environment'
default_dot_environment = root / '.environment'
if default_dot_environment.exists():
return default_dot_environment.absolute()

Expand Down Expand Up @@ -140,7 +226,9 @@ def _sanitize_path(
if not Path(path).is_absolute():
return path

project_root = _PW_PROJECT_PATH.resolve()
root, _ = pigweed_root()

project_root = root.resolve()
user_home = Path.home().resolve()
resolved_path = Path(path).resolve()

Expand Down
10 changes: 5 additions & 5 deletions pw_ide/py/pw_ide/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from pw_cli.env import pigweed_environment
from pw_cli.status_reporter import LoggingStatusReporter, StatusReporter

from pw_ide.activate import pigweed_root

from pw_ide.cpp import (
COMPDB_FILE_NAME,
ClangdSettings,
Expand All @@ -50,7 +52,6 @@
SupportedEditor,
)

from pw_ide import vscode
from pw_ide.vscode import (
build_extension as build_vscode_extension,
VscSettingsManager,
Expand Down Expand Up @@ -124,7 +125,9 @@ def cmd_sync(

for command in pw_ide_settings.sync:
_LOG.debug("Running: %s", command)
subprocess.run(shlex.split(command), cwd=Path(env.PW_PROJECT_ROOT))
subprocess.run(
shlex.split(command), cwd=pigweed_root(use_env_vars=False)[0]
)

if pw_ide_settings.editor_enabled('vscode'):
cmd_vscode()
Expand Down Expand Up @@ -226,9 +229,6 @@ def cmd_vscode(
)
sys.exit(1)

if not vscode.DEFAULT_SETTINGS_PATH.exists():
vscode.DEFAULT_SETTINGS_PATH.mkdir()

vsc_manager = VscSettingsManager(pw_ide_settings)

if include is None and exclude is None:
Expand Down
32 changes: 26 additions & 6 deletions pw_ide/py/pw_ide/editors.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,14 @@ def all_files(cls) -> Generator['SettingsLevel', None, None]:
EditorSettingsTypesWithDefaults = dict[_SettingsTypeT, DefaultSettingsCallback]


def undefined_default_settings_dir(
_pw_ide_settings: PigweedIdeSettings,
) -> Path:
"""Raise an error if a subclass doesn't define default_settings_dir."""

raise NotImplementedError()


class EditorSettingsManager(Generic[_SettingsTypeT]):
"""Manages all settings for a particular editor.
Expand All @@ -547,10 +555,15 @@ class EditorSettingsManager(Generic[_SettingsTypeT]):
}

# These must be overridden in child classes.
default_settings_dir: Path = None # type: ignore
file_format: _StructuredFileFormat = _StructuredFileFormat()
types_with_defaults: EditorSettingsTypesWithDefaults[_SettingsTypeT] = {}

# The settings directory can be defined as a static path, or as a lambda
# that takes an instance of `PigweedIdeSettings` as an argument.
default_settings_dir: Path | Callable[
[PigweedIdeSettings], Path
] = undefined_default_settings_dir

def __init__(
self,
pw_ide_settings: PigweedIdeSettings,
Expand All @@ -577,11 +590,18 @@ def __init__(
# `default_settings_dir`, and that value is used the vast majority of
# the time. But you can inject an alternative directory in the
# constructor if needed (e.g. for tests).
self._settings_dir = (
settings_dir
if settings_dir is not None
else self.__class__.default_settings_dir
)
if settings_dir is not None:
self._settings_dir = settings_dir
else:
if isinstance(self.__class__.default_settings_dir, Path):
self._settings_dir = self.__class__.default_settings_dir
else:
self._settings_dir = self.__class__.default_settings_dir(
pw_ide_settings
)

if not self._settings_dir.exists():
self._settings_dir.mkdir()

# The backing file format should normally be defined by the class
# attribute ``file_format``, but can be overridden in the constructor.
Expand Down
Loading

0 comments on commit 5b3ea9c

Please sign in to comment.