James Ennis pushed to branch jennis/refactor_artifact_log at BuildStream / buildstream
Commits:
- 
b6b5a638
by James Ennis at 2019-01-31T20:36:18Z
- 
6b0e2900
by James Ennis at 2019-01-31T20:36:18Z
- 
c42bb08e
by James Ennis at 2019-01-31T21:18:20Z
- 
bd4eca8f
by James Ennis at 2019-02-01T10:22:06Z
- 
dbe72e3c
by James Ennis at 2019-02-01T10:22:06Z
- 
61481582
by James Ennis at 2019-02-01T10:22:06Z
- 
446854fc
by James Ennis at 2019-02-01T10:22:06Z
- 
7b7d6fb0
by James Ennis at 2019-02-01T10:22:06Z
- 
9ac33817
by James Ennis at 2019-02-01T10:22:06Z
- 
464beaae
by James Ennis at 2019-02-01T10:22:06Z
- 
1960b25e
by James Ennis at 2019-02-01T10:22:06Z
- 
5c5807b8
by James Ennis at 2019-02-01T10:22:06Z
- 
98fc37cb
by James Ennis at 2019-02-01T10:22:06Z
9 changed files:
- buildstream/_artifactcache.py
- + buildstream/_artifactelement.py
- buildstream/_cas/cascache.py
- buildstream/_exceptions.py
- buildstream/_frontend/cli.py
- buildstream/_loader/metaelement.py
- buildstream/_project.py
- buildstream/_stream.py
- buildstream/element.py
Changes:
| ... | ... | @@ -513,11 +513,14 @@ class ArtifactCache(): | 
| 513 | 513 |      #
 | 
| 514 | 514 |      # List artifacts in this cache in LRU order.
 | 
| 515 | 515 |      #
 | 
| 516 | +    # Args:
 | |
| 517 | +    #     glob (str): An option glob _expression_ to be used to list artifacts satisfying the glob
 | |
| 518 | +    #
 | |
| 516 | 519 |      # Returns:
 | 
| 517 | 520 |      #     ([str]) - A list of artifact names as generated in LRU order
 | 
| 518 | 521 |      #
 | 
| 519 | -    def list_artifacts(self):
 | |
| 520 | -        return self.cas.list_refs()
 | |
| 522 | +    def list_artifacts(self, *, glob=None):
 | |
| 523 | +        return self.cas.list_refs(glob=glob)
 | |
| 521 | 524 |  | 
| 522 | 525 |      # remove():
 | 
| 523 | 526 |      #
 | 
| ... | ... | @@ -821,6 +824,20 @@ class ArtifactCache(): | 
| 821 | 824 |  | 
| 822 | 825 |          self.cas.link_ref(oldref, newref)
 | 
| 823 | 826 |  | 
| 827 | +    # get_artifact_logs():
 | |
| 828 | +    #
 | |
| 829 | +    # Get the logs of an existing artifact
 | |
| 830 | +    #
 | |
| 831 | +    # Args:
 | |
| 832 | +    #     ref (str): The ref of the artifact
 | |
| 833 | +    #
 | |
| 834 | +    # Returns:
 | |
| 835 | +    #     logsdir (CasBasedDirectory): A CasBasedDirectory containing the artifact's logs
 | |
| 836 | +    #
 | |
| 837 | +    def get_artifact_logs(self, ref):
 | |
| 838 | +        descend = ["logs"]
 | |
| 839 | +        return self.cas.get_toplevel_dir(ref, descend)
 | |
| 840 | + | |
| 824 | 841 |      ################################################
 | 
| 825 | 842 |      #               Local Private Methods          #
 | 
| 826 | 843 |      ################################################
 | 
| 1 | +#
 | |
| 2 | +#  Copyright (C) 2019 Bloomberg Finance LP
 | |
| 3 | +#
 | |
| 4 | +#  This program is free software; you can redistribute it and/or
 | |
| 5 | +#  modify it under the terms of the GNU Lesser General Public
 | |
| 6 | +#  License as published by the Free Software Foundation; either
 | |
| 7 | +#  version 2 of the License, or (at your option) any later version.
 | |
| 8 | +#
 | |
| 9 | +#  This library is distributed in the hope that it will be useful,
 | |
| 10 | +#  but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| 11 | +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the GNU
 | |
| 12 | +#  Lesser General Public License for more details.
 | |
| 13 | +#
 | |
| 14 | +#  You should have received a copy of the GNU Lesser General Public
 | |
| 15 | +#  License along with this library. If not, see <http://www.gnu.org/licenses/>.
 | |
| 16 | +#
 | |
| 17 | +#  Authors:
 | |
| 18 | +#        James Ennis <james ennis codethink co uk>
 | |
| 19 | +from . import Element
 | |
| 20 | +from ._exceptions import ArtifactElementError
 | |
| 21 | +from ._loader.metaelement import MetaElement
 | |
| 22 | + | |
| 23 | + | |
| 24 | +# ArtifactElement()
 | |
| 25 | +#
 | |
| 26 | +# Object to be used for directly processing an artifact
 | |
| 27 | +#
 | |
| 28 | +# Args:
 | |
| 29 | +#    context (Context): The Context object
 | |
| 30 | +#    ref (str): The artifact ref
 | |
| 31 | +#
 | |
| 32 | +class ArtifactElement(Element):
 | |
| 33 | +    def __init__(self, context, ref):
 | |
| 34 | +        self._ref = ref
 | |
| 35 | + | |
| 36 | +        # Ensure that the provided ref matches the form of an artifact
 | |
| 37 | +        try:
 | |
| 38 | +            _, element, key = ref.split('/', 2)
 | |
| 39 | +        except ValueError:
 | |
| 40 | +            raise ArtifactElementError("Artifact: {} is not of the expected format".format(ref))
 | |
| 41 | +        if len(key) != 64:
 | |
| 42 | +            raise ArtifactElementError("Artifact: {} is not of the expected format".format(ref))
 | |
| 43 | + | |
| 44 | +        self._key = key
 | |
| 45 | + | |
| 46 | +        project = context.get_toplevel_project()
 | |
| 47 | +        meta = MetaElement(project, element)  # NOTE element has no .bst suffix
 | |
| 48 | +        plugin_conf = None
 | |
| 49 | + | |
| 50 | +        super().__init__(context, project, meta, plugin_conf)
 | |
| 51 | + | |
| 52 | +    # Override Element.get_artifact_name()
 | |
| 53 | +    def get_artifact_name(self, key=None):
 | |
| 54 | +        return self._ref
 | |
| 55 | + | |
| 56 | +    # Dummy configure method
 | |
| 57 | +    def configure(self, node):
 | |
| 58 | +        pass
 | |
| 59 | + | |
| 60 | +    # Dummy preflight method
 | |
| 61 | +    def preflight(self):
 | |
| 62 | +        pass
 | |
| 63 | + | |
| 64 | +    # Override Element._calculate_cache_key
 | |
| 65 | +    def _calculate_cache_key(self, dependencies=None):
 | |
| 66 | +        return self._key | 
| ... | ... | @@ -24,6 +24,7 @@ import stat | 
| 24 | 24 |  import errno
 | 
| 25 | 25 |  import uuid
 | 
| 26 | 26 |  import contextlib
 | 
| 27 | +from fnmatch import fnmatch
 | |
| 27 | 28 |  | 
| 28 | 29 |  import grpc
 | 
| 29 | 30 |  | 
| ... | ... | @@ -32,6 +33,7 @@ from .._protos.buildstream.v2 import buildstream_pb2 | 
| 32 | 33 |  | 
| 33 | 34 |  from .. import utils
 | 
| 34 | 35 |  from .._exceptions import CASCacheError
 | 
| 36 | +from ..storage._casbaseddirectory import CasBasedDirectory
 | |
| 35 | 37 |  | 
| 36 | 38 |  from .casremote import BlobNotFound, _CASBatchRead, _CASBatchUpdate
 | 
| 37 | 39 |  | 
| ... | ... | @@ -472,22 +474,35 @@ class CASCache(): | 
| 472 | 474 |      #
 | 
| 473 | 475 |      # List refs in Least Recently Modified (LRM) order.
 | 
| 474 | 476 |      #
 | 
| 477 | +    # Args:
 | |
| 478 | +    #     glob (str) - An optional glob _expression_ to be used to list refs satisfying the glob
 | |
| 479 | +    #
 | |
| 475 | 480 |      # Returns:
 | 
| 476 | 481 |      #     (list) - A list of refs in LRM order
 | 
| 477 | 482 |      #
 | 
| 478 | -    def list_refs(self):
 | |
| 483 | +    def list_refs(self, *, glob=None):
 | |
| 479 | 484 |          # string of: /path/to/repo/refs/heads
 | 
| 480 | 485 |          ref_heads = os.path.join(self.casdir, 'refs', 'heads')
 | 
| 486 | +        path = ref_heads
 | |
| 487 | + | |
| 488 | +        if glob is not None:
 | |
| 489 | +            globdir = os.path.dirname(glob)
 | |
| 490 | +            if not any(c in "*?[" for c in globdir):
 | |
| 491 | +                # path prefix contains no globbing characters so
 | |
| 492 | +                # append the glob to optimise the os.walk()
 | |
| 493 | +                path = os.path.join(ref_heads, globdir)
 | |
| 481 | 494 |  | 
| 482 | 495 |          refs = []
 | 
| 483 | 496 |          mtimes = []
 | 
| 484 | 497 |  | 
| 485 | -        for root, _, files in os.walk(ref_heads):
 | |
| 498 | +        for root, _, files in os.walk(path):
 | |
| 486 | 499 |              for filename in files:
 | 
| 487 | 500 |                  ref_path = os.path.join(root, filename)
 | 
| 488 | -                refs.append(os.path.relpath(ref_path, ref_heads))
 | |
| 489 | -                # Obtain the mtime (the time a file was last modified)
 | |
| 490 | -                mtimes.append(os.path.getmtime(ref_path))
 | |
| 501 | +                relative_path = os.path.relpath(ref_path, ref_heads)  # Relative to refs head
 | |
| 502 | +                if not glob or fnmatch(relative_path, glob):
 | |
| 503 | +                    refs.append(relative_path)
 | |
| 504 | +                    # Obtain the mtime (the time a file was last modified)
 | |
| 505 | +                    mtimes.append(os.path.getmtime(ref_path))
 | |
| 491 | 506 |  | 
| 492 | 507 |          # NOTE: Sorted will sort from earliest to latest, thus the
 | 
| 493 | 508 |          # first ref of this list will be the file modified earliest.
 | 
| ... | ... | @@ -587,6 +602,22 @@ class CASCache(): | 
| 587 | 602 |          reachable = set()
 | 
| 588 | 603 |          self._reachable_refs_dir(reachable, tree, update_mtime=True)
 | 
| 589 | 604 |  | 
| 605 | +    # get_toplevel_dir()
 | |
| 606 | +    #
 | |
| 607 | +    # Return a CasBasedDirectory object of the specified sub_directories
 | |
| 608 | +    #
 | |
| 609 | +    # Args:
 | |
| 610 | +    #     ref (str): The artifact ref
 | |
| 611 | +    #     descend (list): A list of strings of artifact subdirectories
 | |
| 612 | +    #
 | |
| 613 | +    # Returns:
 | |
| 614 | +    #     (CasBasedDirectory): The CasBasedDirectory object
 | |
| 615 | +    #
 | |
| 616 | +    def get_toplevel_dir(self, ref, descend):
 | |
| 617 | +        cache_id = self.resolve_ref(ref, update_mtime=True)
 | |
| 618 | +        vdir = CasBasedDirectory(self, cache_id).descend(descend)
 | |
| 619 | +        return vdir
 | |
| 620 | + | |
| 590 | 621 |      ################################################
 | 
| 591 | 622 |      #             Local Private Methods            #
 | 
| 592 | 623 |      ################################################
 | 
| ... | ... | @@ -344,3 +344,12 @@ class AppError(BstError): | 
| 344 | 344 |  #
 | 
| 345 | 345 |  class SkipJob(Exception):
 | 
| 346 | 346 |      pass
 | 
| 347 | + | |
| 348 | + | |
| 349 | +# ArtifactElementError
 | |
| 350 | +#
 | |
| 351 | +# Raised when errors are encountered by artifact elements
 | |
| 352 | +#
 | |
| 353 | +class ArtifactElementError(BstError):
 | |
| 354 | +    def __init__(self, message, *, detail=None, reason=None, temporary=False):
 | |
| 355 | +        super().__init__(message, detail=detail, domain=ErrorDomain.ELEMENT, reason=reason, temporary=True) | 
| 1 | 1 |  import os
 | 
| 2 | 2 |  import sys
 | 
| 3 | 3 |  from contextlib import ExitStack
 | 
| 4 | -from fnmatch import fnmatch
 | |
| 5 | 4 |  from functools import partial
 | 
| 6 | 5 |  from tempfile import TemporaryDirectory
 | 
| 7 | 6 |  | 
| ... | ... | @@ -895,38 +894,6 @@ def workspace_list(app): | 
| 895 | 894 |  #############################################################
 | 
| 896 | 895 |  #                     Artifact Commands                     #
 | 
| 897 | 896 |  #############################################################
 | 
| 898 | -def _classify_artifacts(names, cas, project_directory):
 | |
| 899 | -    element_targets = []
 | |
| 900 | -    artifact_refs = []
 | |
| 901 | -    element_globs = []
 | |
| 902 | -    artifact_globs = []
 | |
| 903 | - | |
| 904 | -    for name in names:
 | |
| 905 | -        if name.endswith('.bst'):
 | |
| 906 | -            if any(c in "*?[" for c in name):
 | |
| 907 | -                element_globs.append(name)
 | |
| 908 | -            else:
 | |
| 909 | -                element_targets.append(name)
 | |
| 910 | -        else:
 | |
| 911 | -            if any(c in "*?[" for c in name):
 | |
| 912 | -                artifact_globs.append(name)
 | |
| 913 | -            else:
 | |
| 914 | -                artifact_refs.append(name)
 | |
| 915 | - | |
| 916 | -    if element_globs:
 | |
| 917 | -        for dirpath, _, filenames in os.walk(project_directory):
 | |
| 918 | -            for filename in filenames:
 | |
| 919 | -                element_path = os.path.join(dirpath, filename).lstrip(project_directory).lstrip('/')
 | |
| 920 | -                if any(fnmatch(element_path, glob) for glob in element_globs):
 | |
| 921 | -                    element_targets.append(element_path)
 | |
| 922 | - | |
| 923 | -    if artifact_globs:
 | |
| 924 | -        artifact_refs.extend(ref for ref in cas.list_refs()
 | |
| 925 | -                             if any(fnmatch(ref, glob) for glob in artifact_globs))
 | |
| 926 | - | |
| 927 | -    return element_targets, artifact_refs
 | |
| 928 | - | |
| 929 | - | |
| 930 | 897 |  @cli.group(short_help="Manipulate cached artifacts")
 | 
| 931 | 898 |  def artifact():
 | 
| 932 | 899 |      """Manipulate cached artifacts"""
 | 
| ... | ... | @@ -1105,53 +1072,30 @@ def artifact_push(app, elements, deps, remote): | 
| 1105 | 1072 |  @click.pass_obj
 | 
| 1106 | 1073 |  def artifact_log(app, artifacts):
 | 
| 1107 | 1074 |      """Show logs of all artifacts"""
 | 
| 1108 | -    from .._exceptions import CASError
 | |
| 1109 | -    from .._message import MessageType
 | |
| 1110 | -    from .._pipeline import PipelineSelection
 | |
| 1111 | -    from ..storage._casbaseddirectory import CasBasedDirectory
 | |
| 1112 | - | |
| 1113 | -    with ExitStack() as stack:
 | |
| 1114 | -        stack.enter_context(app.initialized())
 | |
| 1115 | -        cache = app.context.artifactcache
 | |
| 1075 | +    # Guess the element if we're in a workspace
 | |
| 1076 | +    if not artifacts:
 | |
| 1077 | +        guessed_target = app.context.guess_element()
 | |
| 1078 | +        if guessed_target:
 | |
| 1079 | +            artifacts = [guessed_target]
 | |
| 1116 | 1080 |  | 
| 1117 | -        elements, artifacts = _classify_artifacts(artifacts, cache.cas,
 | |
| 1118 | -                                                  app.project.directory)
 | |
| 1119 | - | |
| 1120 | -        vdirs = []
 | |
| 1121 | -        extractdirs = []
 | |
| 1122 | -        if artifacts:
 | |
| 1123 | -            for ref in artifacts:
 | |
| 1124 | -                try:
 | |
| 1125 | -                    cache_id = cache.cas.resolve_ref(ref, update_mtime=True)
 | |
| 1126 | -                    vdir = CasBasedDirectory(cache.cas, cache_id)
 | |
| 1127 | -                    vdirs.append(vdir)
 | |
| 1128 | -                except CASError as e:
 | |
| 1129 | -                    app._message(MessageType.WARN, "Artifact {} is not cached".format(ref), detail=str(e))
 | |
| 1130 | -                    continue
 | |
| 1131 | -        if elements:
 | |
| 1132 | -            elements = app.stream.load_selection(elements, selection=PipelineSelection.NONE)
 | |
| 1133 | -            for element in elements:
 | |
| 1134 | -                if not element._cached():
 | |
| 1135 | -                    app._message(MessageType.WARN, "Element {} is not cached".format(element))
 | |
| 1136 | -                    continue
 | |
| 1137 | -                ref = cache.get_artifact_fullname(element, element._get_cache_key())
 | |
| 1138 | -                cache_id = cache.cas.resolve_ref(ref, update_mtime=True)
 | |
| 1139 | -                vdir = CasBasedDirectory(cache.cas, cache_id)
 | |
| 1140 | -                vdirs.append(vdir)
 | |
| 1141 | - | |
| 1142 | -        for vdir in vdirs:
 | |
| 1143 | -            # NOTE: If reading the logs feels unresponsive, here would be a good place to provide progress information.
 | |
| 1144 | -            logsdir = vdir.descend(["logs"])
 | |
| 1145 | -            td = stack.enter_context(TemporaryDirectory())
 | |
| 1146 | -            logsdir.export_files(td, can_link=True)
 | |
| 1147 | -            extractdirs.append(td)
 | |
| 1148 | - | |
| 1149 | -        for extractdir in extractdirs:
 | |
| 1150 | -            for log in (os.path.join(extractdir, log) for log in os.listdir(extractdir)):
 | |
| 1151 | -                # NOTE: Should click gain the ability to pass files to the pager this can be optimised.
 | |
| 1152 | -                with open(log) as f:
 | |
| 1153 | -                    data = f.read()
 | |
| 1154 | -                    click.echo_via_pager(data)
 | |
| 1081 | +    with app.initialized():
 | |
| 1082 | +        logsdirs = app.stream.artifact_log(artifacts)
 | |
| 1083 | + | |
| 1084 | +        with ExitStack() as stack:
 | |
| 1085 | +            extractdirs = []
 | |
| 1086 | +            for logsdir in logsdirs:
 | |
| 1087 | +                # NOTE: If reading the logs feels unresponsive, here would be a good place
 | |
| 1088 | +                # to provide progress information.
 | |
| 1089 | +                td = stack.enter_context(TemporaryDirectory())
 | |
| 1090 | +                logsdir.export_files(td, can_link=True)
 | |
| 1091 | +                extractdirs.append(td)
 | |
| 1092 | + | |
| 1093 | +            for extractdir in extractdirs:
 | |
| 1094 | +                for log in (os.path.join(extractdir, log) for log in os.listdir(extractdir)):
 | |
| 1095 | +                    # NOTE: Should click gain the ability to pass files to the pager this can be optimised.
 | |
| 1096 | +                    with open(log) as f:
 | |
| 1097 | +                        data = f.read()
 | |
| 1098 | +                        click.echo_via_pager(data)
 | |
| 1155 | 1099 |  | 
| 1156 | 1100 |  | 
| 1157 | 1101 |  ##################################################################
 | 
| ... | ... | @@ -38,20 +38,20 @@ class MetaElement(): | 
| 38 | 38 |      #    sandbox: Configuration specific to the sandbox environment
 | 
| 39 | 39 |      #    first_pass: The element is to be loaded with first pass configuration (junction)
 | 
| 40 | 40 |      #
 | 
| 41 | -    def __init__(self, project, name, kind, provenance, sources, config,
 | |
| 42 | -                 variables, environment, env_nocache, public, sandbox,
 | |
| 43 | -                 first_pass):
 | |
| 41 | +    def __init__(self, project, name, kind=None, provenance=None, sources=None, config=None,
 | |
| 42 | +                 variables=None, environment=None, env_nocache=None, public=None,
 | |
| 43 | +                 sandbox=None, first_pass=False):
 | |
| 44 | 44 |          self.project = project
 | 
| 45 | 45 |          self.name = name
 | 
| 46 | 46 |          self.kind = kind
 | 
| 47 | 47 |          self.provenance = provenance
 | 
| 48 | 48 |          self.sources = sources
 | 
| 49 | -        self.config = config
 | |
| 50 | -        self.variables = variables
 | |
| 51 | -        self.environment = environment
 | |
| 52 | -        self.env_nocache = env_nocache
 | |
| 53 | -        self.public = public
 | |
| 54 | -        self.sandbox = sandbox
 | |
| 49 | +        self.config = config or {}
 | |
| 50 | +        self.variables = variables or {}
 | |
| 51 | +        self.environment = environment or {}
 | |
| 52 | +        self.env_nocache = env_nocache or []
 | |
| 53 | +        self.public = public or {}
 | |
| 54 | +        self.sandbox = sandbox or {}
 | |
| 55 | 55 |          self.build_dependencies = []
 | 
| 56 | 56 |          self.dependencies = []
 | 
| 57 | 57 |          self.first_pass = first_pass | 
| ... | ... | @@ -26,6 +26,7 @@ from . import utils | 
| 26 | 26 |  from . import _cachekey
 | 
| 27 | 27 |  from . import _site
 | 
| 28 | 28 |  from . import _yaml
 | 
| 29 | +from ._artifactelement import ArtifactElement
 | |
| 29 | 30 |  from ._profile import Topics, profile_start, profile_end
 | 
| 30 | 31 |  from ._exceptions import LoadError, LoadErrorReason
 | 
| 31 | 32 |  from ._options import OptionPool
 | 
| ... | ... | @@ -255,6 +256,19 @@ class Project(): | 
| 255 | 256 |          else:
 | 
| 256 | 257 |              return self.config.element_factory.create(self._context, self, meta)
 | 
| 257 | 258 |  | 
| 259 | +    # create_artifact_element()
 | |
| 260 | +    #
 | |
| 261 | +    # Instantiate and return an ArtifactElement
 | |
| 262 | +    #
 | |
| 263 | +    # Args:
 | |
| 264 | +    #    ref (str): A string of the artifact ref
 | |
| 265 | +    #
 | |
| 266 | +    # Returns:
 | |
| 267 | +    #    (ArtifactElement): A newly created ArtifactElement object of the appropriate kind
 | |
| 268 | +    #
 | |
| 269 | +    def create_artifact_element(self, ref):
 | |
| 270 | +        return ArtifactElement(self._context, ref)
 | |
| 271 | + | |
| 258 | 272 |      # create_source()
 | 
| 259 | 273 |      #
 | 
| 260 | 274 |      # Instantiate and return a Source
 | 
| ... | ... | @@ -27,8 +27,9 @@ import shutil | 
| 27 | 27 |  import tarfile
 | 
| 28 | 28 |  import tempfile
 | 
| 29 | 29 |  from contextlib import contextmanager, suppress
 | 
| 30 | +from fnmatch import fnmatch
 | |
| 30 | 31 |  | 
| 31 | -from ._exceptions import StreamError, ImplError, BstError, set_last_task_error
 | |
| 32 | +from ._exceptions import StreamError, ImplError, BstError, ArtifactElementError, set_last_task_error
 | |
| 32 | 33 |  from ._message import Message, MessageType
 | 
| 33 | 34 |  from ._scheduler import Scheduler, SchedStatus, TrackQueue, FetchQueue, BuildQueue, PullQueue, PushQueue
 | 
| 34 | 35 |  from ._pipeline import Pipeline, PipelineSelection
 | 
| ... | ... | @@ -108,19 +109,21 @@ class Stream(): | 
| 108 | 109 |      def load_selection(self, targets, *,
 | 
| 109 | 110 |                         selection=PipelineSelection.NONE,
 | 
| 110 | 111 |                         except_targets=(),
 | 
| 111 | -                       use_artifact_config=False):
 | |
| 112 | +                       use_artifact_config=False,
 | |
| 113 | +                       load_refs=False):
 | |
| 112 | 114 |  | 
| 113 | 115 |          profile_start(Topics.LOAD_SELECTION, "_".join(t.replace(os.sep, '-') for t in targets))
 | 
| 114 | 116 |  | 
| 115 | -        elements, _ = self._load(targets, (),
 | |
| 116 | -                                 selection=selection,
 | |
| 117 | -                                 except_targets=except_targets,
 | |
| 118 | -                                 fetch_subprojects=False,
 | |
| 119 | -                                 use_artifact_config=use_artifact_config)
 | |
| 117 | +        target_objects, _ = self._load(targets, (),
 | |
| 118 | +                                       selection=selection,
 | |
| 119 | +                                       except_targets=except_targets,
 | |
| 120 | +                                       fetch_subprojects=False,
 | |
| 121 | +                                       use_artifact_config=use_artifact_config,
 | |
| 122 | +                                       load_refs=load_refs)
 | |
| 120 | 123 |  | 
| 121 | 124 |          profile_end(Topics.LOAD_SELECTION, "_".join(t.replace(os.sep, '-') for t in targets))
 | 
| 122 | 125 |  | 
| 123 | -        return elements
 | |
| 126 | +        return target_objects
 | |
| 124 | 127 |  | 
| 125 | 128 |      # shell()
 | 
| 126 | 129 |      #
 | 
| ... | ... | @@ -481,6 +484,31 @@ class Stream(): | 
| 481 | 484 |              raise StreamError("Error while staging dependencies into a sandbox"
 | 
| 482 | 485 |                                ": '{}'".format(e), detail=e.detail, reason=e.reason) from e
 | 
| 483 | 486 |  | 
| 487 | +    # artifact_log()
 | |
| 488 | +    #
 | |
| 489 | +    # Show the full log of an artifact
 | |
| 490 | +    #
 | |
| 491 | +    # Args:
 | |
| 492 | +    #    targets (str): Targets to view the logs of
 | |
| 493 | +    #
 | |
| 494 | +    # Returns:
 | |
| 495 | +    #    logsdir (list): A list of CasBasedDirectory objects containing artifact logs
 | |
| 496 | +    #
 | |
| 497 | +    def artifact_log(self, targets):
 | |
| 498 | +        # Return list of Element and/or ArtifactElement objects
 | |
| 499 | +        target_objects = self.load_selection(targets, selection=PipelineSelection.NONE, load_refs=True)
 | |
| 500 | + | |
| 501 | +        logsdirs = []
 | |
| 502 | +        for obj in target_objects:
 | |
| 503 | +            ref = obj.get_artifact_name()
 | |
| 504 | +            if not obj._cached():
 | |
| 505 | +                self._message(MessageType.WARN, "{} is not cached".format(ref))
 | |
| 506 | +                continue
 | |
| 507 | + | |
| 508 | +            logsdirs.append(self._artifacts.get_artifact_logs(ref))
 | |
| 509 | + | |
| 510 | +        return logsdirs
 | |
| 511 | + | |
| 484 | 512 |      # source_checkout()
 | 
| 485 | 513 |      #
 | 
| 486 | 514 |      # Checkout sources of the target element to the specified location
 | 
| ... | ... | @@ -912,25 +940,35 @@ class Stream(): | 
| 912 | 940 |                use_artifact_config=False,
 | 
| 913 | 941 |                artifact_remote_url=None,
 | 
| 914 | 942 |                fetch_subprojects=False,
 | 
| 915 | -              dynamic_plan=False):
 | |
| 943 | +              dynamic_plan=False,
 | |
| 944 | +              load_refs=False):
 | |
| 945 | + | |
| 946 | +        # Classify element and artifact strings
 | |
| 947 | +        target_elements, target_artifacts = self._classify_artifacts(targets)
 | |
| 948 | + | |
| 949 | +        if target_artifacts and not load_refs:
 | |
| 950 | +            detail = ''.join(target_artifacts)
 | |
| 951 | +            raise ArtifactElementError("Cannot perform this operation with artifact refs:", detail=detail)
 | |
| 916 | 952 |  | 
| 917 | 953 |          # Load rewritable if we have any tracking selection to make
 | 
| 918 | 954 |          rewritable = False
 | 
| 919 | 955 |          if track_targets:
 | 
| 920 | 956 |              rewritable = True
 | 
| 921 | 957 |  | 
| 922 | -        # Load all targets
 | |
| 958 | +        # Load all target elements
 | |
| 923 | 959 |          elements, except_elements, track_elements, track_except_elements = \
 | 
| 924 | -            self._pipeline.load([targets, except_targets, track_targets, track_except_targets],
 | |
| 960 | +            self._pipeline.load([target_elements, except_targets, track_targets, track_except_targets],
 | |
| 925 | 961 |                                  rewritable=rewritable,
 | 
| 926 | 962 |                                  fetch_subprojects=fetch_subprojects)
 | 
| 927 | 963 |  | 
| 964 | +        artifacts = self._load_refs(target_artifacts)
 | |
| 965 | + | |
| 928 | 966 |          # Optionally filter out junction elements
 | 
| 929 | 967 |          if ignore_junction_targets:
 | 
| 930 | 968 |              elements = [e for e in elements if e.get_kind() != 'junction']
 | 
| 931 | 969 |  | 
| 932 | 970 |          # Hold on to the targets
 | 
| 933 | -        self.targets = elements
 | |
| 971 | +        self.targets = elements + artifacts
 | |
| 934 | 972 |  | 
| 935 | 973 |          # Here we should raise an error if the track_elements targets
 | 
| 936 | 974 |          # are not dependencies of the primary targets, this is not
 | 
| ... | ... | @@ -987,9 +1025,9 @@ class Stream(): | 
| 987 | 1025 |  | 
| 988 | 1026 |          # Now move on to loading primary selection.
 | 
| 989 | 1027 |          #
 | 
| 990 | -        self._pipeline.resolve_elements(elements)
 | |
| 991 | -        selected = self._pipeline.get_selection(elements, selection, silent=False)
 | |
| 992 | -        selected = self._pipeline.except_elements(elements,
 | |
| 1028 | +        self._pipeline.resolve_elements(self.targets)
 | |
| 1029 | +        selected = self._pipeline.get_selection(self.targets, selection, silent=False)
 | |
| 1030 | +        selected = self._pipeline.except_elements(self.targets,
 | |
| 993 | 1031 |                                                    selected,
 | 
| 994 | 1032 |                                                    except_elements)
 | 
| 995 | 1033 |  | 
| ... | ... | @@ -1013,6 +1051,20 @@ class Stream(): | 
| 1013 | 1051 |  | 
| 1014 | 1052 |          return selected, track_selected
 | 
| 1015 | 1053 |  | 
| 1054 | +    # _load_refs()
 | |
| 1055 | +    #
 | |
| 1056 | +    #  Create and resolve ArtifactElement objects
 | |
| 1057 | +    #
 | |
| 1058 | +    def _load_refs(self, refs):
 | |
| 1059 | +        artifact_elements = []
 | |
| 1060 | +        for ref in refs:
 | |
| 1061 | +            artifact_element = self._project.create_artifact_element(ref)
 | |
| 1062 | +            artifact_elements.append(artifact_element)
 | |
| 1063 | + | |
| 1064 | +        self._pipeline.resolve_elements(artifact_elements)
 | |
| 1065 | + | |
| 1066 | +        return artifact_elements
 | |
| 1067 | + | |
| 1016 | 1068 |      # _message()
 | 
| 1017 | 1069 |      #
 | 
| 1018 | 1070 |      # Local message propagator
 | 
| ... | ... | @@ -1321,3 +1373,59 @@ class Stream(): | 
| 1321 | 1373 |                  required_list.append(element)
 | 
| 1322 | 1374 |  | 
| 1323 | 1375 |          return required_list
 | 
| 1376 | + | |
| 1377 | +    # _classify_artifacts()
 | |
| 1378 | +    #
 | |
| 1379 | +    # Split up a list of targets into element names and artifact refs
 | |
| 1380 | +    #
 | |
| 1381 | +    # Args:
 | |
| 1382 | +    #    targets (list): A list of targets
 | |
| 1383 | +    #
 | |
| 1384 | +    # Returns:
 | |
| 1385 | +    #    (list): element names present in the targets
 | |
| 1386 | +    #    (list): artifact refs present in the targets
 | |
| 1387 | +    #
 | |
| 1388 | +    def _classify_artifacts(self, targets):
 | |
| 1389 | +        element_targets = []
 | |
| 1390 | +        artifact_refs = []
 | |
| 1391 | +        element_globs = []
 | |
| 1392 | +        artifact_globs = []
 | |
| 1393 | + | |
| 1394 | +        for target in targets:
 | |
| 1395 | +            if target.endswith('.bst'):
 | |
| 1396 | +                if any(c in "*?[" for c in target):
 | |
| 1397 | +                    element_globs.append(target)
 | |
| 1398 | +                else:
 | |
| 1399 | +                    element_targets.append(target)
 | |
| 1400 | +            else:
 | |
| 1401 | +                if any(c in "*?[" for c in target):
 | |
| 1402 | +                    artifact_globs.append(target)
 | |
| 1403 | +                else:
 | |
| 1404 | +                    try:
 | |
| 1405 | +                        ref = target.split('/', 2)
 | |
| 1406 | +                        key = ref[2]
 | |
| 1407 | +                    except IndexError:
 | |
| 1408 | +                        element_targets.append(target)
 | |
| 1409 | +                        continue
 | |
| 1410 | +                    if not len(key) == 64:
 | |
| 1411 | +                        element_targets.append(target)
 | |
| 1412 | +                        continue
 | |
| 1413 | +                    artifact_refs.append(target)
 | |
| 1414 | + | |
| 1415 | +        if element_globs:
 | |
| 1416 | +            for dirpath, _, filenames in os.walk(self._project.element_path):
 | |
| 1417 | +                for filename in filenames:
 | |
| 1418 | +                    element_path = os.path.join(dirpath, filename)
 | |
| 1419 | +                    length = len(self._project.element_path) + 1
 | |
| 1420 | +                    element_path = element_path[length:]  # Strip out the element_path
 | |
| 1421 | + | |
| 1422 | +                    if any(fnmatch(element_path, glob) for glob in element_globs):
 | |
| 1423 | +                        element_targets.append(element_path)
 | |
| 1424 | + | |
| 1425 | +        if artifact_globs:
 | |
| 1426 | +            for glob in artifact_globs:
 | |
| 1427 | +                artifact_refs = self._artifacts.list_artifacts(glob=glob)
 | |
| 1428 | +            if not artifact_refs:
 | |
| 1429 | +                self._message(MessageType.WARN, "No artifacts found for globs: {}".format(', '.join(artifact_globs)))
 | |
| 1430 | + | |
| 1431 | +        return element_targets, artifact_refs | 
| ... | ... | @@ -1150,7 +1150,7 @@ class Element(Plugin): | 
| 1150 | 1150 |                      e.name for e in self.dependencies(Scope.BUILD, recurse=False)
 | 
| 1151 | 1151 |                  ]
 | 
| 1152 | 1152 |  | 
| 1153 | -            self.__weak_cache_key = self.__calculate_cache_key(dependencies)
 | |
| 1153 | +            self.__weak_cache_key = self._calculate_cache_key(dependencies)
 | |
| 1154 | 1154 |  | 
| 1155 | 1155 |              if self.__weak_cache_key is None:
 | 
| 1156 | 1156 |                  # Weak cache key could not be calculated yet
 | 
| ... | ... | @@ -1179,8 +1179,7 @@ class Element(Plugin): | 
| 1179 | 1179 |              dependencies = [
 | 
| 1180 | 1180 |                  e.__strict_cache_key for e in self.dependencies(Scope.BUILD)
 | 
| 1181 | 1181 |              ]
 | 
| 1182 | -            self.__strict_cache_key = self.__calculate_cache_key(dependencies)
 | |
| 1183 | - | |
| 1182 | +            self.__strict_cache_key = self._calculate_cache_key(dependencies)
 | |
| 1184 | 1183 |              if self.__strict_cache_key is None:
 | 
| 1185 | 1184 |                  # Strict cache key could not be calculated yet
 | 
| 1186 | 1185 |                  return
 | 
| ... | ... | @@ -1222,7 +1221,7 @@ class Element(Plugin): | 
| 1222 | 1221 |                  dependencies = [
 | 
| 1223 | 1222 |                      e._get_cache_key() for e in self.dependencies(Scope.BUILD)
 | 
| 1224 | 1223 |                  ]
 | 
| 1225 | -                self.__cache_key = self.__calculate_cache_key(dependencies)
 | |
| 1224 | +                self.__cache_key = self._calculate_cache_key(dependencies)
 | |
| 1226 | 1225 |  | 
| 1227 | 1226 |              if self.__cache_key is None:
 | 
| 1228 | 1227 |                  # Strong cache key could not be calculated yet
 | 
| ... | ... | @@ -2064,41 +2063,7 @@ class Element(Plugin): | 
| 2064 | 2063 |                  source._fetch(previous_sources)
 | 
| 2065 | 2064 |              previous_sources.append(source)
 | 
| 2066 | 2065 |  | 
| 2067 | -    #############################################################
 | |
| 2068 | -    #                   Private Local Methods                   #
 | |
| 2069 | -    #############################################################
 | |
| 2070 | - | |
| 2071 | -    # __update_source_state()
 | |
| 2072 | -    #
 | |
| 2073 | -    # Updates source consistency state
 | |
| 2074 | -    #
 | |
| 2075 | -    def __update_source_state(self):
 | |
| 2076 | - | |
| 2077 | -        # Cannot resolve source state until tracked
 | |
| 2078 | -        if self.__tracking_scheduled:
 | |
| 2079 | -            return
 | |
| 2080 | - | |
| 2081 | -        self.__consistency = Consistency.CACHED
 | |
| 2082 | -        workspace = self._get_workspace()
 | |
| 2083 | - | |
| 2084 | -        # Special case for workspaces
 | |
| 2085 | -        if workspace:
 | |
| 2086 | - | |
| 2087 | -            # A workspace is considered inconsistent in the case
 | |
| 2088 | -            # that its directory went missing
 | |
| 2089 | -            #
 | |
| 2090 | -            fullpath = workspace.get_absolute_path()
 | |
| 2091 | -            if not os.path.exists(fullpath):
 | |
| 2092 | -                self.__consistency = Consistency.INCONSISTENT
 | |
| 2093 | -        else:
 | |
| 2094 | - | |
| 2095 | -            # Determine overall consistency of the element
 | |
| 2096 | -            for source in self.__sources:
 | |
| 2097 | -                source._update_state()
 | |
| 2098 | -                source_consistency = source._get_consistency()
 | |
| 2099 | -                self.__consistency = min(self.__consistency, source_consistency)
 | |
| 2100 | - | |
| 2101 | -    # __calculate_cache_key():
 | |
| 2066 | +    # _calculate_cache_key():
 | |
| 2102 | 2067 |      #
 | 
| 2103 | 2068 |      # Calculates the cache key
 | 
| 2104 | 2069 |      #
 | 
| ... | ... | @@ -2107,7 +2072,7 @@ class Element(Plugin): | 
| 2107 | 2072 |      #
 | 
| 2108 | 2073 |      # None is returned if information for the cache key is missing.
 | 
| 2109 | 2074 |      #
 | 
| 2110 | -    def __calculate_cache_key(self, dependencies):
 | |
| 2075 | +    def _calculate_cache_key(self, dependencies):
 | |
| 2111 | 2076 |          # No cache keys for dependencies which have no cache keys
 | 
| 2112 | 2077 |          if None in dependencies:
 | 
| 2113 | 2078 |              return None
 | 
| ... | ... | @@ -2146,6 +2111,40 @@ class Element(Plugin): | 
| 2146 | 2111 |  | 
| 2147 | 2112 |          return _cachekey.generate_key(cache_key_dict)
 | 
| 2148 | 2113 |  | 
| 2114 | +    #############################################################
 | |
| 2115 | +    #                   Private Local Methods                   #
 | |
| 2116 | +    #############################################################
 | |
| 2117 | + | |
| 2118 | +    # __update_source_state()
 | |
| 2119 | +    #
 | |
| 2120 | +    # Updates source consistency state
 | |
| 2121 | +    #
 | |
| 2122 | +    def __update_source_state(self):
 | |
| 2123 | + | |
| 2124 | +        # Cannot resolve source state until tracked
 | |
| 2125 | +        if self.__tracking_scheduled:
 | |
| 2126 | +            return
 | |
| 2127 | + | |
| 2128 | +        self.__consistency = Consistency.CACHED
 | |
| 2129 | +        workspace = self._get_workspace()
 | |
| 2130 | + | |
| 2131 | +        # Special case for workspaces
 | |
| 2132 | +        if workspace:
 | |
| 2133 | + | |
| 2134 | +            # A workspace is considered inconsistent in the case
 | |
| 2135 | +            # that its directory went missing
 | |
| 2136 | +            #
 | |
| 2137 | +            fullpath = workspace.get_absolute_path()
 | |
| 2138 | +            if not os.path.exists(fullpath):
 | |
| 2139 | +                self.__consistency = Consistency.INCONSISTENT
 | |
| 2140 | +        else:
 | |
| 2141 | + | |
| 2142 | +            # Determine overall consistency of the element
 | |
| 2143 | +            for source in self.__sources:
 | |
| 2144 | +                source._update_state()
 | |
| 2145 | +                source_consistency = source._get_consistency()
 | |
| 2146 | +                self.__consistency = min(self.__consistency, source_consistency)
 | |
| 2147 | + | |
| 2149 | 2148 |      # __can_build_incrementally()
 | 
| 2150 | 2149 |      #
 | 
| 2151 | 2150 |      # Check if the element can be built incrementally, this
 | 
| ... | ... | @@ -2329,6 +2328,8 @@ class Element(Plugin): | 
| 2329 | 2328 |          defaults['public'] = element_public
 | 
| 2330 | 2329 |  | 
| 2331 | 2330 |      def __init_defaults(self, plugin_conf):
 | 
| 2331 | +        if plugin_conf is None:
 | |
| 2332 | +            return
 | |
| 2332 | 2333 |  | 
| 2333 | 2334 |          # Defaults are loaded once per class and then reused
 | 
| 2334 | 2335 |          #
 | 
