Jonathan Maw pushed to branch jonathan/workspace-fragment-create at BuildStream / buildstream
Commits:
- 
28d1f639
by Jonathan Maw at 2018-11-28T14:02:21Z
- 
b24302c7
by Jonathan Maw at 2018-11-28T14:02:27Z
- 
bd8508c2
by Jonathan Maw at 2018-11-28T14:02:32Z
- 
f053b836
by Jonathan Maw at 2018-11-28T14:02:42Z
- 
41a40c79
by Jonathan Maw at 2018-11-28T14:03:00Z
- 
65b8cc97
by Jonathan Maw at 2018-11-28T14:03:05Z
- 
764dfb53
by Jonathan Maw at 2018-11-28T14:03:09Z
- 
b1d09142
by Jonathan Maw at 2018-11-28T14:03:13Z
- 
ed3b9631
by Jonathan Maw at 2018-11-28T14:03:18Z
- 
efa7ddce
by Jonathan Maw at 2018-11-28T14:03:22Z
- 
24054a2b
by Jonathan Maw at 2018-11-28T14:03:29Z
- 
f18bdbb2
by Jonathan Maw at 2018-11-28T14:03:34Z
10 changed files:
- NEWS
- buildstream/_context.py
- buildstream/_frontend/cli.py
- buildstream/_project.py
- buildstream/_stream.py
- buildstream/_workspaces.py
- buildstream/data/userconfig.yaml
- buildstream/utils.py
- tests/frontend/workspace.py
- tests/integration/shell.py
Changes:
| ... | ... | @@ -70,6 +70,9 @@ buildstream 1.3.1 | 
| 70 | 70 |    o Add sandbox API for command batching and use it for build, script, and
 | 
| 71 | 71 |      compose elements.
 | 
| 72 | 72 |  | 
| 73 | +  o Opening a workspace now creates a .bstproject.yaml file that allows buildstream
 | |
| 74 | +    commands to be run from a workspace that is not inside a project.
 | |
| 75 | + | |
| 73 | 76 |  | 
| 74 | 77 |  =================
 | 
| 75 | 78 |  buildstream 1.1.5
 | 
| ... | ... | @@ -32,7 +32,7 @@ from ._message import Message, MessageType | 
| 32 | 32 |  from ._profile import Topics, profile_start, profile_end
 | 
| 33 | 33 |  from ._artifactcache import ArtifactCache
 | 
| 34 | 34 |  from ._artifactcache.cascache import CASCache
 | 
| 35 | -from ._workspaces import Workspaces
 | |
| 35 | +from ._workspaces import Workspaces, WorkspaceProjectCache
 | |
| 36 | 36 |  from .plugin import _plugin_lookup
 | 
| 37 | 37 |  | 
| 38 | 38 |  | 
| ... | ... | @@ -122,6 +122,10 @@ class Context(): | 
| 122 | 122 |          # remove a workspace directory.
 | 
| 123 | 123 |          self.prompt_workspace_close_remove_dir = None
 | 
| 124 | 124 |  | 
| 125 | +        # Boolean, whether we double-check with the user that they meant to
 | |
| 126 | +        # close the workspace when they're using it to access the project.
 | |
| 127 | +        self.prompt_workspace_close_project_inaccessible = None
 | |
| 128 | + | |
| 125 | 129 |          # Boolean, whether we double-check with the user that they meant to do
 | 
| 126 | 130 |          # a hard reset of a workspace, potentially losing changes.
 | 
| 127 | 131 |          self.prompt_workspace_reset_hard = None
 | 
| ... | ... | @@ -140,6 +144,7 @@ class Context(): | 
| 140 | 144 |          self._projects = []
 | 
| 141 | 145 |          self._project_overrides = {}
 | 
| 142 | 146 |          self._workspaces = None
 | 
| 147 | +        self._workspace_project_cache = WorkspaceProjectCache()
 | |
| 143 | 148 |          self._log_handle = None
 | 
| 144 | 149 |          self._log_filename = None
 | 
| 145 | 150 |          self._cascache = None
 | 
| ... | ... | @@ -250,12 +255,15 @@ class Context(): | 
| 250 | 255 |              defaults, Mapping, 'prompt')
 | 
| 251 | 256 |          _yaml.node_validate(prompt, [
 | 
| 252 | 257 |              'auto-init', 'really-workspace-close-remove-dir',
 | 
| 258 | +            'really-workspace-close-project-inaccessible',
 | |
| 253 | 259 |              'really-workspace-reset-hard',
 | 
| 254 | 260 |          ])
 | 
| 255 | 261 |          self.prompt_auto_init = _node_get_option_str(
 | 
| 256 | 262 |              prompt, 'auto-init', ['ask', 'no']) == 'ask'
 | 
| 257 | 263 |          self.prompt_workspace_close_remove_dir = _node_get_option_str(
 | 
| 258 | 264 |              prompt, 'really-workspace-close-remove-dir', ['ask', 'yes']) == 'ask'
 | 
| 265 | +        self.prompt_workspace_close_project_inaccessible = _node_get_option_str(
 | |
| 266 | +            prompt, 'really-workspace-close-project-inaccessible', ['ask', 'yes']) == 'ask'
 | |
| 259 | 267 |          self.prompt_workspace_reset_hard = _node_get_option_str(
 | 
| 260 | 268 |              prompt, 'really-workspace-reset-hard', ['ask', 'yes']) == 'ask'
 | 
| 261 | 269 |  | 
| ... | ... | @@ -312,6 +320,16 @@ class Context(): | 
| 312 | 320 |      def get_workspaces(self):
 | 
| 313 | 321 |          return self._workspaces
 | 
| 314 | 322 |  | 
| 323 | +    # get_workspace_project_cache():
 | |
| 324 | +    #
 | |
| 325 | +    # Return the WorkspaceProjectCache object used for this BuildStream invocation
 | |
| 326 | +    #
 | |
| 327 | +    # Returns:
 | |
| 328 | +    #    (WorkspaceProjectCache): The WorkspaceProjectCache object
 | |
| 329 | +    #
 | |
| 330 | +    def get_workspace_project_cache(self):
 | |
| 331 | +        return self._workspace_project_cache
 | |
| 332 | + | |
| 315 | 333 |      # get_overrides():
 | 
| 316 | 334 |      #
 | 
| 317 | 335 |      # Fetch the override dictionary for the active project. This returns
 | 
| ... | ... | @@ -59,18 +59,9 @@ def complete_target(args, incomplete): | 
| 59 | 59 |      :return: all the possible user-specified completions for the param
 | 
| 60 | 60 |      """
 | 
| 61 | 61 |  | 
| 62 | +    from .. import utils
 | |
| 62 | 63 |      project_conf = 'project.conf'
 | 
| 63 | 64 |  | 
| 64 | -    def ensure_project_dir(directory):
 | |
| 65 | -        directory = os.path.abspath(directory)
 | |
| 66 | -        while not os.path.isfile(os.path.join(directory, project_conf)):
 | |
| 67 | -            parent_dir = os.path.dirname(directory)
 | |
| 68 | -            if directory == parent_dir:
 | |
| 69 | -                break
 | |
| 70 | -            directory = parent_dir
 | |
| 71 | - | |
| 72 | -        return directory
 | |
| 73 | - | |
| 74 | 65 |      # First resolve the directory, in case there is an
 | 
| 75 | 66 |      # active --directory/-C option
 | 
| 76 | 67 |      #
 | 
| ... | ... | @@ -89,7 +80,7 @@ def complete_target(args, incomplete): | 
| 89 | 80 |      else:
 | 
| 90 | 81 |          # Check if this directory or any of its parent directories
 | 
| 91 | 82 |          # contain a project config file
 | 
| 92 | -        base_directory = ensure_project_dir(base_directory)
 | |
| 83 | +        base_directory = utils._search_upward_for_file(base_directory, project_conf)
 | |
| 93 | 84 |  | 
| 94 | 85 |      # Now parse the project.conf just to find the element path,
 | 
| 95 | 86 |      # this is unfortunately a bit heavy.
 | 
| ... | ... | @@ -756,11 +747,18 @@ def workspace_close(app, remove_dir, all_, elements): | 
| 756 | 747 |  | 
| 757 | 748 |          elements = app.stream.redirect_element_names(elements)
 | 
| 758 | 749 |  | 
| 759 | -        # Check that the workspaces in question exist
 | |
| 750 | +        # Check that the workspaces in question exist, and that it's safe to
 | |
| 751 | +        # remove them.
 | |
| 760 | 752 |          nonexisting = []
 | 
| 761 | 753 |          for element_name in elements:
 | 
| 762 | 754 |              if not app.stream.workspace_exists(element_name):
 | 
| 763 | 755 |                  nonexisting.append(element_name)
 | 
| 756 | +            if (app.stream.workspace_is_required(element_name) and app.interactive and
 | |
| 757 | +                    app.context.prompt_workspace_close_project_inaccessible):
 | |
| 758 | +                click.echo("Removing '{}' will prevent you from running buildstream commands".format(element_name))
 | |
| 759 | +                if not click.confirm('Are you sure you want to close this workspace?'):
 | |
| 760 | +                    click.echo('Aborting', err=True)
 | |
| 761 | +                    sys.exit(-1)
 | |
| 764 | 762 |          if nonexisting:
 | 
| 765 | 763 |              raise AppError("Workspace does not exist", detail="\n".join(nonexisting))
 | 
| 766 | 764 |  | 
| ... | ... | @@ -95,8 +95,10 @@ class Project(): | 
| 95 | 95 |          # The project name
 | 
| 96 | 96 |          self.name = None
 | 
| 97 | 97 |  | 
| 98 | -        # The project directory
 | |
| 99 | -        self.directory = self._ensure_project_dir(directory)
 | |
| 98 | +        self._context = context  # The invocation Context, a private member
 | |
| 99 | + | |
| 100 | +        # The project directory, and whether the project was found from an external workspace
 | |
| 101 | +        self.directory, self._required_workspace_element = self._find_project_dir(directory)
 | |
| 100 | 102 |  | 
| 101 | 103 |          # Absolute path to where elements are loaded from within the project
 | 
| 102 | 104 |          self.element_path = None
 | 
| ... | ... | @@ -117,7 +119,6 @@ class Project(): | 
| 117 | 119 |          #
 | 
| 118 | 120 |          # Private Members
 | 
| 119 | 121 |          #
 | 
| 120 | -        self._context = context  # The invocation Context
 | |
| 121 | 122 |  | 
| 122 | 123 |          self._default_mirror = default_mirror    # The name of the preferred mirror.
 | 
| 123 | 124 |  | 
| ... | ... | @@ -371,6 +372,14 @@ class Project(): | 
| 371 | 372 |  | 
| 372 | 373 |          self._load_second_pass()
 | 
| 373 | 374 |  | 
| 375 | +    # required_workspace_element()
 | |
| 376 | +    #
 | |
| 377 | +    # Returns the element whose workspace is required to load this project,
 | |
| 378 | +    # if any.
 | |
| 379 | +    #
 | |
| 380 | +    def required_workspace_element(self):
 | |
| 381 | +        return self._required_workspace_element
 | |
| 382 | + | |
| 374 | 383 |      # cleanup()
 | 
| 375 | 384 |      #
 | 
| 376 | 385 |      # Cleans up resources used loading elements
 | 
| ... | ... | @@ -650,7 +659,7 @@ class Project(): | 
| 650 | 659 |          # Source url aliases
 | 
| 651 | 660 |          output._aliases = _yaml.node_get(config, Mapping, 'aliases', default_value={})
 | 
| 652 | 661 |  | 
| 653 | -    # _ensure_project_dir()
 | |
| 662 | +    # _find_project_dir()
 | |
| 654 | 663 |      #
 | 
| 655 | 664 |      # Returns path of the project directory, if a configuration file is found
 | 
| 656 | 665 |      # in given directory or any of its parent directories.
 | 
| ... | ... | @@ -661,18 +670,26 @@ class Project(): | 
| 661 | 670 |      # Raises:
 | 
| 662 | 671 |      #    LoadError if project.conf is not found
 | 
| 663 | 672 |      #
 | 
| 664 | -    def _ensure_project_dir(self, directory):
 | |
| 665 | -        directory = os.path.abspath(directory)
 | |
| 666 | -        while not os.path.isfile(os.path.join(directory, _PROJECT_CONF_FILE)):
 | |
| 667 | -            parent_dir = os.path.dirname(directory)
 | |
| 668 | -            if directory == parent_dir:
 | |
| 673 | +    # Returns:
 | |
| 674 | +    #    (str) - the directory that contains the project, and
 | |
| 675 | +    #    (str) - the name of the element required to find the project, or an empty string
 | |
| 676 | +    #
 | |
| 677 | +    def _find_project_dir(self, directory):
 | |
| 678 | +        workspace_element = ""
 | |
| 679 | +        project_directory = utils._search_upward_for_file(directory, _PROJECT_CONF_FILE)
 | |
| 680 | +        if not project_directory:
 | |
| 681 | +            workspace_project_cache = self._context.get_workspace_project_cache()
 | |
| 682 | +            workspace_project = workspace_project_cache.get(directory)
 | |
| 683 | +            if workspace_project:
 | |
| 684 | +                project_directory = workspace_project.get_default_path()
 | |
| 685 | +                workspace_element = workspace_project.get_default_element()
 | |
| 686 | +            else:
 | |
| 669 | 687 |                  raise LoadError(
 | 
| 670 | 688 |                      LoadErrorReason.MISSING_PROJECT_CONF,
 | 
| 671 | 689 |                      '{} not found in current directory or any of its parent directories'
 | 
| 672 | 690 |                      .format(_PROJECT_CONF_FILE))
 | 
| 673 | -            directory = parent_dir
 | |
| 674 | 691 |  | 
| 675 | -        return directory
 | |
| 692 | +        return project_directory, workspace_element
 | |
| 676 | 693 |  | 
| 677 | 694 |      def _load_plugin_factories(self, config, output):
 | 
| 678 | 695 |          plugin_source_origins = []   # Origins of custom sources
 | 
| ... | ... | @@ -28,7 +28,7 @@ import tarfile | 
| 28 | 28 |  from contextlib import contextmanager
 | 
| 29 | 29 |  from tempfile import TemporaryDirectory
 | 
| 30 | 30 |  | 
| 31 | -from ._exceptions import StreamError, ImplError, BstError, set_last_task_error
 | |
| 31 | +from ._exceptions import StreamError, ImplError, BstError, set_last_task_error, LoadError, LoadErrorReason
 | |
| 32 | 32 |  from ._message import Message, MessageType
 | 
| 33 | 33 |  from ._scheduler import Scheduler, SchedStatus, TrackQueue, FetchQueue, BuildQueue, PullQueue, PushQueue
 | 
| 34 | 34 |  from ._pipeline import Pipeline, PipelineSelection
 | 
| ... | ... | @@ -550,6 +550,8 @@ class Stream(): | 
| 550 | 550 |          # So far this function has tried to catch as many issues as possible with out making any changes
 | 
| 551 | 551 |          # Now it dose the bits that can not be made atomic.
 | 
| 552 | 552 |          targetGenerator = zip(elements, expanded_directories)
 | 
| 553 | +        workspace_project_cache = self._context.get_workspace_project_cache()
 | |
| 554 | +        project = self._context.get_toplevel_project()
 | |
| 553 | 555 |          for target, directory in targetGenerator:
 | 
| 554 | 556 |              self._message(MessageType.INFO, "Creating workspace for element {}"
 | 
| 555 | 557 |                            .format(target.name))
 | 
| ... | ... | @@ -574,6 +576,10 @@ class Stream(): | 
| 574 | 576 |                  with target.timed_activity("Staging sources to {}".format(directory)):
 | 
| 575 | 577 |                      target._open_workspace()
 | 
| 576 | 578 |  | 
| 579 | +            workspace_project = workspace_project_cache.add(directory, project.directory,
 | |
| 580 | +                                                            target._get_full_name())
 | |
| 581 | +            workspace_project.write()
 | |
| 582 | + | |
| 577 | 583 |              # Saving the workspace once it is set up means that if the next workspace fails to be created before
 | 
| 578 | 584 |              # the configuration gets saved. The successfully created workspace still gets saved.
 | 
| 579 | 585 |              workspaces.save_config()
 | 
| ... | ... | @@ -601,6 +607,16 @@ class Stream(): | 
| 601 | 607 |                  except OSError as e:
 | 
| 602 | 608 |                      raise StreamError("Could not remove  '{}': {}"
 | 
| 603 | 609 |                                        .format(workspace.get_absolute_path(), e)) from e
 | 
| 610 | +        else:
 | |
| 611 | +            workspace_project_cache = self._context.get_workspace_project_cache()
 | |
| 612 | +            try:
 | |
| 613 | +                workspace_project_cache.remove(workspace.get_absolute_path())
 | |
| 614 | +            except LoadError as e:
 | |
| 615 | +                # We might be closing a workspace with a deleted directory
 | |
| 616 | +                if e.reason == LoadErrorReason.MISSING_FILE:
 | |
| 617 | +                    pass
 | |
| 618 | +                else:
 | |
| 619 | +                    raise
 | |
| 604 | 620 |  | 
| 605 | 621 |          # Delete the workspace and save the configuration
 | 
| 606 | 622 |          workspaces.delete_workspace(element_name)
 | 
| ... | ... | @@ -644,6 +660,8 @@ class Stream(): | 
| 644 | 660 |          for element in elements:
 | 
| 645 | 661 |              workspace = workspaces.get_workspace(element._get_full_name())
 | 
| 646 | 662 |              workspace_path = workspace.get_absolute_path()
 | 
| 663 | +            workspace_project_cache = self._context.get_workspace_project_cache()
 | |
| 664 | +            workspace_project = workspace_project_cache.get(workspace_path)
 | |
| 647 | 665 |              if soft:
 | 
| 648 | 666 |                  workspace.prepared = False
 | 
| 649 | 667 |                  self._message(MessageType.INFO, "Reset workspace state for {} at: {}"
 | 
| ... | ... | @@ -664,6 +682,8 @@ class Stream(): | 
| 664 | 682 |              with element.timed_activity("Staging sources to {}".format(workspace_path)):
 | 
| 665 | 683 |                  element._open_workspace()
 | 
| 666 | 684 |  | 
| 685 | +            workspace_project.write()
 | |
| 686 | + | |
| 667 | 687 |              self._message(MessageType.INFO,
 | 
| 668 | 688 |                            "Reset workspace for {} at: {}".format(element.name,
 | 
| 669 | 689 |                                                                   workspace_path))
 | 
| ... | ... | @@ -694,6 +714,20 @@ class Stream(): | 
| 694 | 714 |  | 
| 695 | 715 |          return False
 | 
| 696 | 716 |  | 
| 717 | +    # workspace_is_required()
 | |
| 718 | +    #
 | |
| 719 | +    # Checks whether the workspace belonging to element_name is required to
 | |
| 720 | +    # load the project
 | |
| 721 | +    #
 | |
| 722 | +    # Args:
 | |
| 723 | +    #    element_name (str): The element whose workspace may be required
 | |
| 724 | +    #
 | |
| 725 | +    # Returns:
 | |
| 726 | +    #    (bool): True if the workspace is required
 | |
| 727 | +    def workspace_is_required(self, element_name):
 | |
| 728 | +        required_elm = self._project.required_workspace_element()
 | |
| 729 | +        return required_elm == element_name
 | |
| 730 | + | |
| 697 | 731 |      # workspace_list
 | 
| 698 | 732 |      #
 | 
| 699 | 733 |      # Serializes the workspaces and dumps them in YAML to stdout.
 | 
| ... | ... | @@ -25,6 +25,211 @@ from ._exceptions import LoadError, LoadErrorReason | 
| 25 | 25 |  | 
| 26 | 26 |  | 
| 27 | 27 |  BST_WORKSPACE_FORMAT_VERSION = 3
 | 
| 28 | +BST_WORKSPACE_PROJECT_FORMAT_VERSION = 1
 | |
| 29 | +WORKSPACE_PROJECT_FILE = ".bstproject.yaml"
 | |
| 30 | + | |
| 31 | + | |
| 32 | +# WorkspaceProject()
 | |
| 33 | +#
 | |
| 34 | +# An object to contain various helper functions and data required for
 | |
| 35 | +# referring from a workspace back to buildstream.
 | |
| 36 | +#
 | |
| 37 | +# Args:
 | |
| 38 | +#    directory (str): The directory that the workspace exists in
 | |
| 39 | +#    project_path (str): The project path used to refer back
 | |
| 40 | +#                        to buildstream projects.
 | |
| 41 | +#    element_name (str): The name of the element used to create this workspace.
 | |
| 42 | +class WorkspaceProject():
 | |
| 43 | +    def __init__(self, directory, project_path="", element_name=""):
 | |
| 44 | +        self._projects = []
 | |
| 45 | +        self._directory = directory
 | |
| 46 | + | |
| 47 | +        assert (project_path and element_name) or (not project_path and not element_name)
 | |
| 48 | +        if project_path:
 | |
| 49 | +            self._add_project(project_path, element_name)
 | |
| 50 | + | |
| 51 | +    # get_default_path()
 | |
| 52 | +    #
 | |
| 53 | +    # Retrieves the default path to a project.
 | |
| 54 | +    #
 | |
| 55 | +    # Returns:
 | |
| 56 | +    #    (str): The path to a project
 | |
| 57 | +    def get_default_path(self):
 | |
| 58 | +        return self._projects[0]['project-path']
 | |
| 59 | + | |
| 60 | +    # get_default_element()
 | |
| 61 | +    #
 | |
| 62 | +    # Retrieves the name of the element that owns this workspace.
 | |
| 63 | +    #
 | |
| 64 | +    # Returns:
 | |
| 65 | +    #    (str): The name of an element
 | |
| 66 | +    def get_default_element(self):
 | |
| 67 | +        return self._projects[0]['element-name']
 | |
| 68 | + | |
| 69 | +    # to_dict()
 | |
| 70 | +    #
 | |
| 71 | +    # Turn the members data into a dict for serialization purposes
 | |
| 72 | +    #
 | |
| 73 | +    # Returns:
 | |
| 74 | +    #    (dict): A dict representation of the WorkspaceProject
 | |
| 75 | +    #
 | |
| 76 | +    def to_dict(self):
 | |
| 77 | +        ret = {
 | |
| 78 | +            'projects': self._projects,
 | |
| 79 | +            'format-version': BST_WORKSPACE_PROJECT_FORMAT_VERSION,
 | |
| 80 | +        }
 | |
| 81 | +        return ret
 | |
| 82 | + | |
| 83 | +    # from_dict()
 | |
| 84 | +    #
 | |
| 85 | +    # Loads a new WorkspaceProject from a simple dictionary
 | |
| 86 | +    #
 | |
| 87 | +    # Args:
 | |
| 88 | +    #    directory (str): The directory that the workspace exists in
 | |
| 89 | +    #    dictionary (dict): The dict to generate a WorkspaceProject from
 | |
| 90 | +    #
 | |
| 91 | +    # Returns:
 | |
| 92 | +    #   (WorkspaceProject): A newly instantiated WorkspaceProject
 | |
| 93 | +    @classmethod
 | |
| 94 | +    def from_dict(cls, directory, dictionary):
 | |
| 95 | +        # Only know how to handle one format-version at the moment.
 | |
| 96 | +        format_version = int(dictionary['format-version'])
 | |
| 97 | +        assert format_version == BST_WORKSPACE_PROJECT_FORMAT_VERSION, \
 | |
| 98 | +            "Format version {} not found in {}".format(BST_WORKSPACE_PROJECT_FORMAT_VERSION, dictionary)
 | |
| 99 | + | |
| 100 | +        workspace_project = cls(directory)
 | |
| 101 | +        for item in dictionary['projects']:
 | |
| 102 | +            workspace_project._add_project(item['project-path'], item['element-name'])
 | |
| 103 | + | |
| 104 | +        return workspace_project
 | |
| 105 | + | |
| 106 | +    # load()
 | |
| 107 | +    #
 | |
| 108 | +    # Loads the WorkspaceProject for a given directory. This directory may be a
 | |
| 109 | +    # subdirectory of the workspace's directory.
 | |
| 110 | +    #
 | |
| 111 | +    # Args:
 | |
| 112 | +    #    directory (str): The directory
 | |
| 113 | +    # Returns:
 | |
| 114 | +    #    (WorkspaceProject): The created WorkspaceProject, if in a workspace, or
 | |
| 115 | +    #    (NoneType): None, if the directory is not inside a workspace.
 | |
| 116 | +    @classmethod
 | |
| 117 | +    def load(cls, directory):
 | |
| 118 | +        project_dir = cls.search_for_dir(directory)
 | |
| 119 | +        if project_dir:
 | |
| 120 | +            workspace_file = os.path.join(project_dir, WORKSPACE_PROJECT_FILE)
 | |
| 121 | +            data_dict = _yaml.load(workspace_file)
 | |
| 122 | +            return cls.from_dict(project_dir, data_dict)
 | |
| 123 | +        else:
 | |
| 124 | +            return None
 | |
| 125 | + | |
| 126 | +    # write()
 | |
| 127 | +    #
 | |
| 128 | +    # Writes the WorkspaceProject to disk
 | |
| 129 | +    def write(self):
 | |
| 130 | +        os.makedirs(self._directory, exist_ok=True)
 | |
| 131 | +        _yaml.dump(self.to_dict(), self._get_filename())
 | |
| 132 | + | |
| 133 | +    # search_for_dir()
 | |
| 134 | +    #
 | |
| 135 | +    # Returns the directory that contains the workspace local project file,
 | |
| 136 | +    # searching upwards from search_dir.
 | |
| 137 | +    @staticmethod
 | |
| 138 | +    def search_for_dir(search_dir):
 | |
| 139 | +        return utils._search_upward_for_file(search_dir, WORKSPACE_PROJECT_FILE)
 | |
| 140 | + | |
| 141 | +    def _get_filename(self):
 | |
| 142 | +        return os.path.join(self._directory, WORKSPACE_PROJECT_FILE)
 | |
| 143 | + | |
| 144 | +    def _add_project(self, project_path, element_name):
 | |
| 145 | +        assert (project_path and element_name)
 | |
| 146 | +        self._projects.append({'project-path': project_path, 'element-name': element_name})
 | |
| 147 | + | |
| 148 | + | |
| 149 | +# WorkspaceProjectCache()
 | |
| 150 | +#
 | |
| 151 | +# A class to manage workspace project data for multiple workspaces.
 | |
| 152 | +#
 | |
| 153 | +class WorkspaceProjectCache():
 | |
| 154 | +    def __init__(self):
 | |
| 155 | +        self._projects = {}  # Mapping of a workspace directory to its WorkspaceProject
 | |
| 156 | + | |
| 157 | +    # get()
 | |
| 158 | +    #
 | |
| 159 | +    # Returns a WorkspaceProject for a given directory, retrieving from the cache if
 | |
| 160 | +    # present, and searching the filesystem for the file and loading it if not.
 | |
| 161 | +    #
 | |
| 162 | +    # Args:
 | |
| 163 | +    #    directory (str): The directory to search for a WorkspaceProject.
 | |
| 164 | +    #
 | |
| 165 | +    # Returns:
 | |
| 166 | +    #    (WorkspaceProject): The WorkspaceProject that was found for that directory.
 | |
| 167 | +    #    or      (NoneType): None, if no WorkspaceProject can be found.
 | |
| 168 | +    #
 | |
| 169 | +    def get(self, directory):
 | |
| 170 | +        try:
 | |
| 171 | +            workspace_project = self._projects[directory]
 | |
| 172 | +        except KeyError:
 | |
| 173 | +            found_dir = WorkspaceProject.search_for_dir(directory)
 | |
| 174 | +            if found_dir:
 | |
| 175 | +                try:
 | |
| 176 | +                    workspace_project = self._projects[found_dir]
 | |
| 177 | +                except KeyError:
 | |
| 178 | +                    workspace_project = WorkspaceProject.load(found_dir)
 | |
| 179 | +                    self._projects[found_dir] = workspace_project
 | |
| 180 | +            else:
 | |
| 181 | +                workspace_project = None
 | |
| 182 | + | |
| 183 | +        return workspace_project
 | |
| 184 | + | |
| 185 | +    # add()
 | |
| 186 | +    #
 | |
| 187 | +    # Adds the project path and element name to the WorkspaceProject that exists
 | |
| 188 | +    # for that directory
 | |
| 189 | +    #
 | |
| 190 | +    # Args:
 | |
| 191 | +    #    directory (str): The directory to search for a WorkspaceProject.
 | |
| 192 | +    #    project_path (str): The path to the project that refers to this workspace
 | |
| 193 | +    #    element_name (str): The element in the project that was refers to this workspace
 | |
| 194 | +    #
 | |
| 195 | +    # Returns:
 | |
| 196 | +    #    (WorkspaceProject): The WorkspaceProject that was found for that directory.
 | |
| 197 | +    #
 | |
| 198 | +    def add(self, directory, project_path='', element_name=''):
 | |
| 199 | +        workspace_project = self.get(directory)
 | |
| 200 | +        if not workspace_project:
 | |
| 201 | +            workspace_project = WorkspaceProject(directory)
 | |
| 202 | +            self._projects[directory] = workspace_project
 | |
| 203 | +        if project_path:
 | |
| 204 | +            workspace_project._add_project(project_path, element_name)
 | |
| 205 | +        return workspace_project
 | |
| 206 | + | |
| 207 | +    # remove()
 | |
| 208 | +    #
 | |
| 209 | +    # Removes the project path and element name from the WorkspaceProject that exists
 | |
| 210 | +    # for that directory.
 | |
| 211 | +    #
 | |
| 212 | +    # NOTE: This currently just deletes the file, but with support for multiple
 | |
| 213 | +    # projects opening the same workspace, this will involve decreasing the count
 | |
| 214 | +    # and deleting the file if there are no more projects.
 | |
| 215 | +    #
 | |
| 216 | +    # Args:
 | |
| 217 | +    #    directory (str): The directory to search for a WorkspaceProject.
 | |
| 218 | +    #    project_path (str): **UNUSED** The path to the project that refers to this workspace
 | |
| 219 | +    #    element_name (str): **UNUSED** The element in the project that was refers to this workspace
 | |
| 220 | +    #
 | |
| 221 | +    def remove(self, directory, project_path='', element_name=''):
 | |
| 222 | +        # NOTE: project_path and element_name will only be used when I implement
 | |
| 223 | +        #       multiple owners of a workspace
 | |
| 224 | +        workspace_project = self.get(directory)
 | |
| 225 | +        if not workspace_project:
 | |
| 226 | +            raise LoadError(LoadErrorReason.MISSING_FILE,
 | |
| 227 | +                            "Failed to find a {} file to remove".format(WORKSPACE_PROJECT_FILE))
 | |
| 228 | +        path = workspace_project._get_filename()
 | |
| 229 | +        try:
 | |
| 230 | +            os.unlink(path)
 | |
| 231 | +        except FileNotFoundError:
 | |
| 232 | +            pass
 | |
| 28 | 233 |  | 
| 29 | 234 |  | 
| 30 | 235 |  # Workspace()
 | 
| ... | ... | @@ -174,10 +379,15 @@ class Workspace(): | 
| 174 | 379 |          if recalculate or self._key is None:
 | 
| 175 | 380 |              fullpath = self.get_absolute_path()
 | 
| 176 | 381 |  | 
| 382 | +            excluded_files = (WORKSPACE_PROJECT_FILE,)
 | |
| 383 | + | |
| 177 | 384 |              # Get a list of tuples of the the project relative paths and fullpaths
 | 
| 178 | 385 |              if os.path.isdir(fullpath):
 | 
| 179 | 386 |                  filelist = utils.list_relative_paths(fullpath)
 | 
| 180 | -                filelist = [(relpath, os.path.join(fullpath, relpath)) for relpath in filelist]
 | |
| 387 | +                filelist = [
 | |
| 388 | +                    (relpath, os.path.join(fullpath, relpath)) for relpath in filelist
 | |
| 389 | +                    if relpath not in excluded_files
 | |
| 390 | +                ]
 | |
| 181 | 391 |              else:
 | 
| 182 | 392 |                  filelist = [(self.get_absolute_path(), fullpath)]
 | 
| 183 | 393 |  | 
| ... | ... | @@ -128,6 +128,14 @@ prompt: | 
| 128 | 128 |    #
 | 
| 129 | 129 |    really-workspace-close-remove-dir: ask
 | 
| 130 | 130 |  | 
| 131 | +  # Whether to really proceed with 'bst workspace close' when doing so would
 | |
| 132 | +  # stop them from running bst commands in this workspace.
 | |
| 133 | +  #
 | |
| 134 | +  #  ask - Ask the user if they are sure.
 | |
| 135 | +  #  yes - Always close, without asking.
 | |
| 136 | +  #
 | |
| 137 | +  really-workspace-close-project-inaccessible: ask
 | |
| 138 | + | |
| 131 | 139 |    # Whether to really proceed with 'bst workspace reset' doing a hard reset of
 | 
| 132 | 140 |    # a workspace, potentially losing changes.
 | 
| 133 | 141 |    #
 | 
| ... | ... | @@ -1242,3 +1242,17 @@ def _deduplicate(iterable, key=None): | 
| 1242 | 1242 |  def _get_link_mtime(path):
 | 
| 1243 | 1243 |      path_stat = os.lstat(path)
 | 
| 1244 | 1244 |      return path_stat.st_mtime
 | 
| 1245 | + | |
| 1246 | + | |
| 1247 | +# Returns the first directory to contain filename, or an empty string if
 | |
| 1248 | +# none found
 | |
| 1249 | +#
 | |
| 1250 | +def _search_upward_for_file(directory, filename):
 | |
| 1251 | +    directory = os.path.abspath(directory)
 | |
| 1252 | +    while not os.path.isfile(os.path.join(directory, filename)):
 | |
| 1253 | +        parent_dir = os.path.dirname(directory)
 | |
| 1254 | +        if directory == parent_dir:
 | |
| 1255 | +            return ""
 | |
| 1256 | +        directory = parent_dir
 | |
| 1257 | + | |
| 1258 | +    return directory | 
| ... | ... | @@ -31,6 +31,7 @@ import shutil | 
| 31 | 31 |  import subprocess
 | 
| 32 | 32 |  from ruamel.yaml.comments import CommentedSet
 | 
| 33 | 33 |  from tests.testutils import cli, create_repo, ALL_REPO_KINDS, wait_for_cache_granularity
 | 
| 34 | +from tests.testutils import create_artifact_share
 | |
| 34 | 35 |  | 
| 35 | 36 |  from buildstream import _yaml
 | 
| 36 | 37 |  from buildstream._exceptions import ErrorDomain, LoadError, LoadErrorReason
 | 
| ... | ... | @@ -615,9 +616,12 @@ def test_list(cli, tmpdir, datafiles): | 
| 615 | 616 |  @pytest.mark.datafiles(DATA_DIR)
 | 
| 616 | 617 |  @pytest.mark.parametrize("kind", repo_kinds)
 | 
| 617 | 618 |  @pytest.mark.parametrize("strict", [("strict"), ("non-strict")])
 | 
| 618 | -def test_build(cli, tmpdir, datafiles, kind, strict):
 | |
| 619 | +@pytest.mark.parametrize("call_from", [("project"), ("workspace")])
 | |
| 620 | +def test_build(cli, tmpdir_factory, datafiles, kind, strict, call_from):
 | |
| 621 | +    tmpdir = tmpdir_factory.mktemp('')
 | |
| 619 | 622 |      element_name, project, workspace = open_workspace(cli, tmpdir, datafiles, kind, False)
 | 
| 620 | 623 |      checkout = os.path.join(str(tmpdir), 'checkout')
 | 
| 624 | +    args_pre = ['-C', workspace] if call_from == "workspace" else []
 | |
| 621 | 625 |  | 
| 622 | 626 |      # Modify workspace
 | 
| 623 | 627 |      shutil.rmtree(os.path.join(workspace, 'usr', 'bin'))
 | 
| ... | ... | @@ -640,15 +644,14 @@ def test_build(cli, tmpdir, datafiles, kind, strict): | 
| 640 | 644 |      # Build modified workspace
 | 
| 641 | 645 |      assert cli.get_element_state(project, element_name) == 'buildable'
 | 
| 642 | 646 |      assert cli.get_element_key(project, element_name) == "{:?<64}".format('')
 | 
| 643 | -    result = cli.run(project=project, args=['build', element_name])
 | |
| 647 | +    result = cli.run(project=project, args=args_pre + ['build', element_name])
 | |
| 644 | 648 |      result.assert_success()
 | 
| 645 | 649 |      assert cli.get_element_state(project, element_name) == 'cached'
 | 
| 646 | 650 |      assert cli.get_element_key(project, element_name) != "{:?<64}".format('')
 | 
| 647 | 651 |  | 
| 648 | 652 |      # Checkout the result
 | 
| 649 | -    result = cli.run(project=project, args=[
 | |
| 650 | -        'checkout', element_name, checkout
 | |
| 651 | -    ])
 | |
| 653 | +    result = cli.run(project=project,
 | |
| 654 | +                     args=args_pre + ['checkout', element_name, checkout])
 | |
| 652 | 655 |      result.assert_success()
 | 
| 653 | 656 |  | 
| 654 | 657 |      # Check that the pony.conf from the modified workspace exists
 | 
| ... | ... | @@ -1055,3 +1058,131 @@ def test_multiple_failed_builds(cli, tmpdir, datafiles): | 
| 1055 | 1058 |          result = cli.run(project=project, args=["build", element_name])
 | 
| 1056 | 1059 |          assert "BUG" not in result.stderr
 | 
| 1057 | 1060 |          assert cli.get_element_state(project, element_name) != "cached"
 | 
| 1061 | + | |
| 1062 | + | |
| 1063 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1064 | +def test_external_fetch(cli, datafiles, tmpdir_factory):
 | |
| 1065 | +    # Fetching from a workspace outside a project doesn't fail horribly
 | |
| 1066 | +    tmpdir = tmpdir_factory.mktemp('')
 | |
| 1067 | +    element_name, project, workspace = open_workspace(cli, tmpdir, datafiles, "git", False)
 | |
| 1068 | + | |
| 1069 | +    result = cli.run(project=project, args=['-C', workspace, 'fetch', element_name])
 | |
| 1070 | +    result.assert_success()
 | |
| 1071 | + | |
| 1072 | +    # We already fetched it by opening the workspace, but we're also checking
 | |
| 1073 | +    # `bst show` works here
 | |
| 1074 | +    assert cli.get_element_state(project, element_name) == 'buildable'
 | |
| 1075 | + | |
| 1076 | + | |
| 1077 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1078 | +def test_external_push_pull(cli, datafiles, tmpdir_factory):
 | |
| 1079 | +    # Pushing and pulling to/from an artifact cache works from an external workspace
 | |
| 1080 | +    tmpdir = tmpdir_factory.mktemp('')
 | |
| 1081 | +    element_name, project, workspace = open_workspace(cli, tmpdir, datafiles, "git", False)
 | |
| 1082 | + | |
| 1083 | +    with create_artifact_share(os.path.join(str(tmpdir), 'artifactshare')) as share:
 | |
| 1084 | +        result = cli.run(project=project, args=['-C', workspace, 'build', element_name])
 | |
| 1085 | +        result.assert_success()
 | |
| 1086 | + | |
| 1087 | +        cli.configure({
 | |
| 1088 | +            'artifacts': {'url': share.repo, 'push': True}
 | |
| 1089 | +        })
 | |
| 1090 | + | |
| 1091 | +        result = cli.run(project=project, args=['-C', workspace, 'push', element_name])
 | |
| 1092 | +        result.assert_success()
 | |
| 1093 | + | |
| 1094 | +        result = cli.run(project=project, args=['-C', workspace, 'pull', '--deps', 'all', element_name])
 | |
| 1095 | +        result.assert_success()
 | |
| 1096 | + | |
| 1097 | + | |
| 1098 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1099 | +def test_external_track(cli, datafiles, tmpdir_factory):
 | |
| 1100 | +    # Tracking does not get horribly confused
 | |
| 1101 | +    tmpdir = tmpdir_factory.mktemp('')
 | |
| 1102 | +    element_name, project, workspace = open_workspace(cli, tmpdir, datafiles, "git", True)
 | |
| 1103 | + | |
| 1104 | +    # The workspace is necessarily already tracked, so we only care that
 | |
| 1105 | +    # there's no weird errors.
 | |
| 1106 | +    result = cli.run(project=project, args=['-C', workspace, 'track', element_name])
 | |
| 1107 | +    result.assert_success()
 | |
| 1108 | + | |
| 1109 | + | |
| 1110 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1111 | +def test_external_open_other(cli, datafiles, tmpdir_factory):
 | |
| 1112 | +    # >From inside an external workspace, open another workspace
 | |
| 1113 | +    tmpdir1 = tmpdir_factory.mktemp('')
 | |
| 1114 | +    tmpdir2 = tmpdir_factory.mktemp('')
 | |
| 1115 | +    # Making use of the assumption that it's the same project in both invocations of open_workspace
 | |
| 1116 | +    alpha_element, project, alpha_workspace = open_workspace(cli, tmpdir1, datafiles, "git", False, suffix="-alpha")
 | |
| 1117 | +    beta_element, _, beta_workspace = open_workspace(cli, tmpdir2, datafiles, "git", False, suffix="-beta")
 | |
| 1118 | + | |
| 1119 | +    # Closing the other element first, because I'm too lazy to create an
 | |
| 1120 | +    # element without opening it
 | |
| 1121 | +    result = cli.run(project=project, args=['workspace', 'close', beta_element])
 | |
| 1122 | +    result.assert_success()
 | |
| 1123 | + | |
| 1124 | +    result = cli.run(project=project, args=[
 | |
| 1125 | +        '-C', alpha_workspace, 'workspace', 'open', '--force', '--directory', beta_workspace, beta_element
 | |
| 1126 | +    ])
 | |
| 1127 | +    result.assert_success()
 | |
| 1128 | + | |
| 1129 | + | |
| 1130 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1131 | +def test_external_close_other(cli, datafiles, tmpdir_factory):
 | |
| 1132 | +    # >From inside an external workspace, close the other workspace
 | |
| 1133 | +    tmpdir1 = tmpdir_factory.mktemp('')
 | |
| 1134 | +    tmpdir2 = tmpdir_factory.mktemp('')
 | |
| 1135 | +    # Making use of the assumption that it's the same project in both invocations of open_workspace
 | |
| 1136 | +    alpha_element, project, alpha_workspace = open_workspace(cli, tmpdir1, datafiles, "git", False, suffix="-alpha")
 | |
| 1137 | +    beta_element, _, beta_workspace = open_workspace(cli, tmpdir2, datafiles, "git", False, suffix="-beta")
 | |
| 1138 | + | |
| 1139 | +    result = cli.run(project=project, args=['-C', alpha_workspace, 'workspace', 'close', beta_element])
 | |
| 1140 | +    result.assert_success()
 | |
| 1141 | + | |
| 1142 | + | |
| 1143 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1144 | +def test_external_close_self(cli, datafiles, tmpdir_factory):
 | |
| 1145 | +    # >From inside an external workspace, close it
 | |
| 1146 | +    tmpdir1 = tmpdir_factory.mktemp('')
 | |
| 1147 | +    tmpdir2 = tmpdir_factory.mktemp('')
 | |
| 1148 | +    # Making use of the assumption that it's the same project in both invocations of open_workspace
 | |
| 1149 | +    alpha_element, project, alpha_workspace = open_workspace(cli, tmpdir1, datafiles, "git", False, suffix="-alpha")
 | |
| 1150 | +    beta_element, _, beta_workspace = open_workspace(cli, tmpdir2, datafiles, "git", False, suffix="-beta")
 | |
| 1151 | + | |
| 1152 | +    result = cli.run(project=project, args=['-C', alpha_workspace, 'workspace', 'close', alpha_element])
 | |
| 1153 | +    result.assert_success()
 | |
| 1154 | + | |
| 1155 | + | |
| 1156 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1157 | +def test_external_reset_other(cli, datafiles, tmpdir_factory):
 | |
| 1158 | +    tmpdir1 = tmpdir_factory.mktemp('')
 | |
| 1159 | +    tmpdir2 = tmpdir_factory.mktemp('')
 | |
| 1160 | +    # Making use of the assumption that it's the same project in both invocations of open_workspace
 | |
| 1161 | +    alpha_element, project, alpha_workspace = open_workspace(cli, tmpdir1, datafiles, "git", False, suffix="-alpha")
 | |
| 1162 | +    beta_element, _, beta_workspace = open_workspace(cli, tmpdir2, datafiles, "git", False, suffix="-beta")
 | |
| 1163 | + | |
| 1164 | +    result = cli.run(project=project, args=['-C', alpha_workspace, 'workspace', 'reset', beta_element])
 | |
| 1165 | +    result.assert_success()
 | |
| 1166 | + | |
| 1167 | + | |
| 1168 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1169 | +def test_external_reset_self(cli, datafiles, tmpdir):
 | |
| 1170 | +    element, project, workspace = open_workspace(cli, tmpdir, datafiles, "git", False)
 | |
| 1171 | + | |
| 1172 | +    # Command succeeds
 | |
| 1173 | +    result = cli.run(project=project, args=['-C', workspace, 'workspace', 'reset', element])
 | |
| 1174 | +    result.assert_success()
 | |
| 1175 | + | |
| 1176 | +    # Successive commands still work (i.e. .bstproject.yaml hasn't been deleted)
 | |
| 1177 | +    result = cli.run(project=project, args=['-C', workspace, 'workspace', 'list'])
 | |
| 1178 | +    result.assert_success()
 | |
| 1179 | + | |
| 1180 | + | |
| 1181 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 1182 | +def test_external_list(cli, datafiles, tmpdir_factory):
 | |
| 1183 | +    tmpdir = tmpdir_factory.mktemp('')
 | |
| 1184 | +    # Making use of the assumption that it's the same project in both invocations of open_workspace
 | |
| 1185 | +    element, project, workspace = open_workspace(cli, tmpdir, datafiles, "git", False)
 | |
| 1186 | + | |
| 1187 | +    result = cli.run(project=project, args=['-C', workspace, 'workspace', 'list'])
 | |
| 1188 | +    result.assert_success() | 
| ... | ... | @@ -353,3 +353,29 @@ def test_integration_devices(cli, tmpdir, datafiles): | 
| 353 | 353 |  | 
| 354 | 354 |      result = execute_shell(cli, project, ["true"], element=element_name)
 | 
| 355 | 355 |      assert result.exit_code == 0
 | 
| 356 | + | |
| 357 | + | |
| 358 | +# Test that a shell can be opened from an external workspace
 | |
| 359 | +@pytest.mark.datafiles(DATA_DIR)
 | |
| 360 | +@pytest.mark.parametrize("build_shell", [("build"), ("nobuild")])
 | |
| 361 | +@pytest.mark.skipif(IS_LINUX and not HAVE_BWRAP, reason='Only available with bubblewrap on Linux')
 | |
| 362 | +def test_integration_external_workspace(cli, tmpdir_factory, datafiles, build_shell):
 | |
| 363 | +    tmpdir = tmpdir_factory.mktemp("")
 | |
| 364 | +    project = os.path.join(datafiles.dirname, datafiles.basename)
 | |
| 365 | +    element_name = 'autotools/amhello.bst'
 | |
| 366 | +    workspace_dir = os.path.join(str(tmpdir), 'workspace')
 | |
| 367 | + | |
| 368 | +    result = cli.run(project=project, args=[
 | |
| 369 | +        'workspace', 'open', '--directory', workspace_dir, element_name
 | |
| 370 | +    ])
 | |
| 371 | +    result.assert_success()
 | |
| 372 | + | |
| 373 | +    result = cli.run(project=project, args=['-C', workspace_dir, 'build', element_name])
 | |
| 374 | +    result.assert_success()
 | |
| 375 | + | |
| 376 | +    command = ['shell']
 | |
| 377 | +    if build_shell == 'build':
 | |
| 378 | +        command.append('--build')
 | |
| 379 | +    command.extend([element_name, '--', 'true'])
 | |
| 380 | +    result = cli.run(project=project, cwd=workspace_dir, args=command)
 | |
| 381 | +    result.assert_success() | 
