Repository: BobBuildTool/bob

Issue:
incompatible variants
Feature request to allow incompatible variants.

This problem was also discussed here: https://github.com/BobBuildTool/bob/issues/255 already.

> Hallo Jan,
> 
> [...]
> 
> An deinem Beispiel mit VARIANT a und b als Environment-Variable.
> Dort ist doch am Ende nicht 100% sicher, dass diese kollidieren.
> Wenn jetzt z.B. other-recipe eine shared lib „exportiert“ dann könnte
> VARIANT ja einen unterschiedlichen Pfad definieren, in denen die
> Artifacts abgelegt werden, oder?
> 
> Ich denke immer noch, dass es möglich sein sollte, dass man das erzwingen 
> (per setup in config.yaml?) kann.
>
>
> ```
>>> root/lib-a/lib2
>    PACKAGE   dev/dist/lib2/1/workspace
> a
>
>>> root/lib-a
>    BUILD     dev/build/lib-a/1/workspace
>    PACKAGE   dev/dist/lib-a/1/workspace
>
>>> root/lib-b/lib2
>    PACKAGE   dev/dist/lib2/2/workspace
> b
>
>>> root/lib-b
>    BUILD     dev/build/lib-b/1/workspace
>    PACKAGE   dev/dist/lib-b/1/workspace
>
>>> root
>    BUILD     dev/build/root/1/workspace
>    PACKAGE   dev/dist/root/1/workspace
> ```
>
> Aus Sicht der Rezepte ist ja bis da hin alles eindeutig. Alle Varianten 
> haben ihre eigenen Verzeichnisse.
>
> Problem ist ja eigentlich „NUR“, dass die „lib2“ mit genau diesem Namen in 
> die Listen (z.B. BOB_DEP_PATHS)
>
> eingetragen wird. HIER kommt es zur Kollision. Aber bob erkennt ja alles 
> schon korrekt, dass es sicht um Varianten handelt etc.
>
> Müsste man den Mechanismus nicht einfach dahin gehend erweitern, dass wir im 
> Falle einer Variante einen PREFIX verpassen…
>
> 1/lib2 oder lib-a/lib2 oder so 😊
>
> Natürlich müsste sich der Rezepte-Schreiber drum kümmern, dass eventuelle 
> Ergebnisse im dist sich dann ggf. beim zusammenkopieren nicht 
> überschreiben…
>
> [...]
> 
> 
> Mit freundlichen Grüßen / Kind regards
> 
> Martin Haase



> Serus Martin,
> 
> im Prinzip hast du recht. Das ließe sich schon machen. Ich hatte da auch
> schon mehrfach darüber nachgedacht aber das hat auch ein paar Nachteile:
> 
> * Man kann nicht mehr einfach vom Paket-Namen auf das Rezept und anders
>   herum schließen. Das ist durch multiPackages nicht immer eindeutig
>   aber einfach zu finden. Wenn man jetzt unterschiedliche Varianten
>   eines Rezepts unter verschiedenen Namen einbindet wird es
>   verwirrender.
> 
> * Was passiert wenn eine dieser "umbenannten" Abhängigkeiten per
>   provideDeps nach oben gereicht wird? Dann wird es schon sehr
>   unübersichtlich herauszufinden wo das Paket jetzt her kam.
> 
> * Die Plugin-API würde brechen, da man Recipe.getPackageName() entfernen
>   müsste.
> 
> Das sind jetzt alles keine KO-Kriterien aber es hat mich bisher davon
> abgehalten das zu implementieren. Außerdem gab es bis jetzt immer einen
> anderen Weg das zu lösen. 
> 
> Wenn ihr das Feature aber braucht kann man darüber nachdenken. Man muss
> es ja nicht nehmen...
> 
> Ciao,
> Jan

My last question here is, what means that for the bob user, that the module-name e.g. "lib2" is more complex like "<variant>/lib2" (e.g. in BOB_DEP_PATHS).
Where do we use this name on the public-interface (e.g. via calling the bob tool)?

BR.

Hints from issue comments:
@jkloetzke: I think the feature will just be about renaming a dependency:

```yaml
depends:
    - name: foo::bar
      rename: something-else
```

In this case `BOB_DEP_PATHS` will have a `something-else` entry.@mahaase: sounds perfect, IF 

```
depends:
    - name: foo::bar
      rename: something-${VAR}
```

would be possible. :)@jkloetzke: Using variable substitution is a different issue IMHO. Currently

```yaml
depends:
    -"${VAR}"
```

isn't supported either. But it could be possible.@mahaase: fine for me, I added another ticket! :+1: @mahaase: how can I help, to force this ticket? :)

Solution patch:
diff --git a/doc/manual/configuration.rst b/doc/manual/configuration.rst
index 7f9dc90..b99e294 100644
--- a/doc/manual/configuration.rst
+++ b/doc/manual/configuration.rst
@@ -19,6 +19,12 @@ be included from recipes and other classes to factor out common stuff. Files
 that do not have the '.yaml' extension are ignored when parsing the recipes and
 classes directories.
 
+In case there needs to be a choice between multiple recipes that provide
+interchangeable packages, aliases can be defined that resolve to other recipes.
+The optional ``aliases`` directory holds YAML files that declare alias names.
+The naming structure is the same as in the ``recipes`` and ``classes``
+directory. This is usually known as virtual packages in other build systems.
+
 There are two additional configuration files: ``config.yaml`` and
 ``default.yaml``. The former contains static configuration options while the
 latter holds some options that can have a default value and might be overridden
@@ -28,6 +34,8 @@ the following:
 .. code-block:: none
 
     .
+    ├── aliases
+    │   └── kernel.yaml
     ├── classes
     │   └── make.yaml
     ├── config.yaml
@@ -1220,15 +1228,13 @@ settings from the current entry. See the following example for both formats::
     depends:
         - foo
         - bar
-        -
-            name: toolchain
-            use: [tools, environment]
-            forward: True
-        -
-            if: "${FOOBAR}"
-            depends:
-                - baz
-                - qux
+        - name: toolchain
+          use: [tools, environment]
+          forward: True
+        - if: "${FOOBAR}"
+          depends:
+              - baz
+              - qux
 
 In the first and second case only the package is named, meaning the build
 result of recipe *foo* resp. *bar* is fed as ``$2`` and ``$3`` to the build
@@ -1260,6 +1266,9 @@ The following settings are supported:
 | name        | String          | The name of the required recipe.                    |
 |             |                 | String substitution is applied to this setting.     |
 +-------------+-----------------+-----------------------------------------------------+
+| alias       | String          | Alias name of the dependency. Declares alternate    |
+|             |                 | name for this dependency.                           |
++-------------+-----------------+-----------------------------------------------------+
 | depends     | List of         | A list of dependencies inheriting the settings of   |
 |             | Dependencies    | this entry.                                         |
 +-------------+-----------------+-----------------------------------------------------+
@@ -1343,6 +1352,32 @@ The following settings are supported:
 |             |                 | Default: ``true``                                   |
 +-------------+-----------------+-----------------------------------------------------+
 
+Each package in the dependency list must have a unique name. By default, the
+name of the required recipe is used. This ensures that each dependency is named
+only once. Also, provided dependencies from dependencies are merged based on
+the package name (see :ref:`configuration-recipes-providedeps`).
+
+Sometimes it is necessary to depend on the same recipe more than once because
+multiple variants of the same recipe are required. In this case, alias names
+can be used to give each dependency a unique name::
+
+    depends:
+        - name: some::package
+          alias: some::package-alpha
+          environment:
+              VARIANT: alpha
+        - name: some::package
+          alias: some::package-beta
+          environment:
+              VARIANT: beta
+
+This example assumes that the result of ``some::package`` depends on the
+content of ``VARIANT``. The dependencies will be known as
+``some::package-alpha`` and ``some::package-beta``, thus satisfying the unique
+name requirement.  Both packages are based on the identical recipe
+(``some::package``) but are built differently because of the varying
+``VARIANT`` value.
+
 .. _configuration-recipes-env:
 
 environment
@@ -1581,6 +1616,8 @@ All metaEnvironment variables are subject to :ref:`string substitution
 <configuration-principle-subst>`, unless the :ref:`policies-substituteMetaEnv`
 policy is configured for the old behaviour.
 
+.. _configuration-recipes-multipackage:
+
 multiPackage
 ~~~~~~~~~~~~
 
@@ -1905,6 +1942,49 @@ installation and will be used from there. This reduces the job workspace size
 considerably at the expense of having artifacts outside of Jenkins's regular
 control.
 
+.. _configuration-aliases:
+
+Alias definitions
+-----------------
+
+An alias package can be defined by placing an appropriately named YAML file in
+the ``aliases`` directory. The naming convention is identical to the
+``recipes`` directory. That is, the alias name is derived from the file name
+after removing the ``.yaml`` suffix. Subdirectories can be used and the path
+separator is replaced by ``::``.
+
+Examples:
+
+.. code-block:: none
+
+    aliases/foo.yaml
+    aliases/bar/baz.yaml
+
+This will create two alias packages: ``foo`` and ``bar::baz``. Each YAML file
+contains either a single string, e.g.::
+
+    "target::package"
+
+or defines multiple aliases with the help of a ``multiPackage`` dictionary::
+
+    multiPackage:
+        "":  ${CONFIG_SELECT_LIBEGL:-libs::mesa3d}
+        dev: ${CONFIG_SELECT_LIBEGL:-libs::mesa3d}-dev
+        tgt: ${CONFIG_SELECT_LIBEGL:-libs::mesa3d}-tgt
+
+In contrast to recipe or class :ref:`configuration-recipes-multipackage`
+definitions, aliases do not support nesting of ``multiPackage``. Like recipes,
+the key in the ``multiPackage`` is appended to the base package name. Suppose
+the above YAML file is stored as ``aliases/libs/libegl.yaml``, this will define
+the ``libs::libegl``, ``libs::libegl-dev`` and ``libs::libegl-tgt`` aliases. If
+``CONFIG_SELECT_LIBEGL`` is unset, the alias targets will be provided by
+``libs::mesa3d``. Otherwise the variable will hold the alias target package
+name.
+
+The alias string is subject to :ref:`configuration-principle-subst`. This
+enables aliases to select dynamically between different targets based on the
+position in the dependency graph.
+
 .. _configuration-config:
 
 Project configuration (config.yaml)
diff --git a/pym/bob/builder.py b/pym/bob/builder.py
index 02eb9f6..6285d62 100644
--- a/pym/bob/builder.py
+++ b/pym/bob/builder.py
@@ -328,7 +328,7 @@ cd {ROOT}
         if step.isCheckoutStep():
             base = step.getPackage().getRecipe().getName()
         else:
-            base = step.getPackage().getName()
+            base = step.getPackage().getRecipe().getPackageName()
         return os.path.join("work", base.replace('::', os.sep), step.getLabel())
 
     @staticmethod
@@ -351,7 +351,7 @@ cd {ROOT}
         if step.isCheckoutStep():
             base = step.getPackage().getRecipe().getName()
         else:
-            base = step.getPackage().getName()
+            base = step.getPackage().getRecipe().getPackageName()
         return os.path.join("dev", step.getLabel(), base.replace('::', os.sep))
 
     @staticmethod
diff --git a/pym/bob/cmds/jenkins/jenkins.py b/pym/bob/cmds/jenkins/jenkins.py
index c112293..1fa29c5 100644
--- a/pym/bob/cmds/jenkins/jenkins.py
+++ b/pym/bob/cmds/jenkins/jenkins.py
@@ -710,7 +710,7 @@ def _genJenkinsJobs(step, jobs, nameCalculator, upload, download, seenPackages,
     return jj
 
 def jenkinsNameFormatter(step, props):
-    return step.getPackage().getName().replace('::', "/") + "/" + step.getLabel()
+    return step.getPackage().getRecipe().getPackageName().replace('::', "/") + "/" + step.getLabel()
 
 def jenkinsNamePersister(jenkins, wrapFmt, uuid):
 
diff --git a/pym/bob/input.py b/pym/bob/input.py
index aeb6516..b9072fc 100644
--- a/pym/bob/input.py
+++ b/pym/bob/input.py
@@ -953,6 +953,10 @@ class CoreStep(CoreItem):
             h.update(providedSandbox.resultId)
         else:
             h.update(b'\x00' * 20)
+        # Add package name if aliased
+        pkgName = self.corePackage.packageName
+        if pkgName is not None:
+            h.update(pkgName.encode('utf8'))
 
         return h.digest()
 
@@ -1466,10 +1470,11 @@ corePackageInternal = CorePackageInternal()
 class CorePackage:
     __slots__ = ("recipe", "internalRef", "directDepSteps", "indirectDepSteps",
         "states", "tools", "sandbox", "checkoutStep", "buildStep", "packageStep",
-        "pkgId", "metaEnv")
+        "pkgId", "metaEnv", "packageName")
 
     def __init__(self, recipe, tools, diffTools, sandbox, diffSandbox,
-                 directDepSteps, indirectDepSteps, states, pkgId, metaEnv):
+                 directDepSteps, indirectDepSteps, states, pkgId, metaEnv,
+                 packageName):
         self.recipe = recipe
         self.tools = tools
         self.sandbox = sandbox
@@ -1479,6 +1484,7 @@ class CorePackage:
         self.states = states
         self.pkgId = pkgId
         self.metaEnv = metaEnv
+        self.packageName = packageName
 
     def refDeref(self, stack, inputTools, inputSandbox, pathsConfig):
         tools, sandbox = self.internalRef.refDeref(stack, inputTools, inputSandbox, pathsConfig)
@@ -1514,7 +1520,10 @@ class CorePackage:
 
     def getName(self):
         """Name of the package"""
-        return self.recipe.getPackageName()
+        if self.packageName is None:
+            return self.recipe.getPackageName()
+        else:
+            return self.packageName
 
     def getMetaEnv(self):
         return self.metaEnv
@@ -1523,6 +1532,9 @@ class CorePackage:
     def jobServer(self):
         return self.recipe.jobServer()
 
+    def isAlias(self):
+        return self.packageName is not None
+
 class Package(object):
     """Representation of a package that was created from a recipe.
 
@@ -1571,7 +1583,7 @@ class Package(object):
 
     def getName(self):
         """Name of the package"""
-        return self.getRecipe().getPackageName()
+        return self.__corePackage.getName()
 
     def getMetaEnv(self):
         """meta variables of package"""
@@ -1672,6 +1684,17 @@ class Package(object):
         """Returns True if the packages is relocatable."""
         return self.__corePackage.recipe.isRelocatable()
 
+    def isAlias(self):
+        """Returns True if the package name is an alias.
+
+        Usually, the package name is based on the recipe name with some
+        optional suffix due to (possibly nested) multiPackage(s). In case of
+        global or per-dependency alias names, any other name may be used,
+        though. In this case, ``isAlias()`` returns true and ``getName()`` does
+        not equal ``getRecipe().getPackageName()`` as it normally does.
+        """
+        return self.__corePackages.isAlias()
+
 
 # FIXME: implement this on our own without the Template class. How to do proper
 # escaping?
@@ -1801,6 +1824,7 @@ class VarDefineValidator:
 
 RECIPE_NAME_SCHEMA = schema.Regex(r'^[0-9A-Za-z_.+-]+$')
 MULTIPACKAGE_NAME_SCHEMA = schema.Regex(r'^[0-9A-Za-z_.+-]*$')
+ALIAS_NAME_RE = re.compile(r"[0-9A-Za-z_.+-]+(::[0-9A-Za-z_.+-]+)*")
 
 class UniquePackageList:
     def __init__(self, stack, errorHandler):
@@ -1849,6 +1873,29 @@ class DepTracker:
             return True
 
 
+class PackageStack:
+    __slots__ = ('__recipeSet', '__nameStack')
+
+    def __init__(self):
+        self.__recipeSet = set()
+        self.__nameStack = []
+
+    def __contains__(self, recipe):
+        return recipe in self.__recipeSet
+
+    def intersects(self, packages):
+        return not self.__recipeSet.isdisjoint(packages)
+
+    def push(self, recipe, name):
+        ret = object.__new__(PackageStack)
+        ret.__recipeSet = self.__recipeSet | set([recipe])
+        ret.__nameStack = self.__nameStack + [name]
+        return ret
+
+    def getNameStack(self):
+        return self.__nameStack
+
+
 class VerbatimProvideDepsResolver:
     def __init__(self, pattern):
         self.pattern = pattern
@@ -1893,7 +1940,12 @@ class Recipe(object):
     """
 
     class Dependency(object):
-        def __init__(self, recipe, env, fwd, use, cond, tools, checkoutDep, inherit):
+        __slots__ = ('recipe', 'envOverride', 'provideGlobal', 'inherit',
+                     'use', 'useEnv', 'useTools', 'useBuildResult', 'useDeps',
+                     'useSandbox', 'condition', 'toolOverride', 'checkoutDep',
+                     'alias')
+
+        def __init__(self, recipe, env, fwd, use, cond, tools, checkoutDep, inherit, alias):
             self.recipe = recipe
             self.envOverride = env
             self.provideGlobal = fwd
@@ -1907,11 +1959,13 @@ class Recipe(object):
             self.condition = cond
             self.toolOverride = tools
             self.checkoutDep = checkoutDep
+            self.alias = alias
 
         @staticmethod
         def __parseEntry(dep, env, fwd, use, cond, tools, checkoutDep, inherit):
             if isinstance(dep, str):
-                return [ Recipe.Dependency(dep, env, fwd, use, cond, tools, checkoutDep, inherit) ]
+                return [ Recipe.Dependency(dep, env, fwd, use, cond, tools, checkoutDep,
+                                           inherit, None) ]
             else:
                 envOverride = dep.get("environment")
                 if envOverride:
@@ -1932,7 +1986,8 @@ class Recipe(object):
                 if name:
                     if "depends" in dep:
                         raise ParseError("A dependency must not use 'name' and 'depends' at the same time!")
-                    return [ Recipe.Dependency(name, env, fwd, use, cond, tools, checkoutDep, inherit) ]
+                    return [ Recipe.Dependency(name, env, fwd, use, cond, tools,
+                                               checkoutDep, inherit, dep.get("alias")) ]
                 dependencies = dep.get("depends")
                 if dependencies is None:
                     raise ParseError("Either 'name' or 'depends' required for dependencies!")
@@ -2286,11 +2341,17 @@ class Recipe(object):
         return self.__properties
 
     def prepare(self, inputEnv, sandboxEnabled, inputStates, inputSandbox=None,
-                inputTools=Env(), stack=[]):
+                inputTools=Env(), inputStack=PackageStack(), packageName=None):
+        # Cycle detection is based on the recipe package name. Any alias names
+        # are ignored.
+        if self.__packageName in inputStack:
+            raise ParseError("Recipes are cyclic (1st package in cylce)")
+        stack = inputStack.push(self.__packageName,
+                                self.__packageName if packageName is None else packageName)
         # already calculated?
         for m in self.__corePackagesByMatch:
-            if m.matches(inputEnv.detach(), inputTools.detach(), inputStates, inputSandbox):
-                if set(stack) & m.subTreePackages:
+            if m.matches(inputEnv.detach(), inputTools.detach(), inputStates, inputSandbox, packageName):
+                if stack.intersects(m.subTreePackages):
                     raise ParseError("Recipes are cyclic")
                 m.touch(inputEnv, inputTools)
                 if DEBUG['pkgck']:
@@ -2326,7 +2387,7 @@ class Recipe(object):
         subTreePackages = set()
         directPackages = []
         indirectPackages = []
-        provideDeps = UniquePackageList(stack, self.__raiseIncompatibleProvided)
+        provideDeps = UniquePackageList(stack.getNameStack(), self.__raiseIncompatibleProvided)
         maybeProvideDeps = []
         checkoutDeps = []
         results = []
@@ -2349,8 +2410,15 @@ class Recipe(object):
             # provideDeps to name a disabled dependency. But in case the
             # substiturion fails, the error is silently ignored.
             try:
-                recipe = env.substitute(dep.recipe, "dependency::"+dep.recipe)
-                resolvedDeps.append(recipe)
+                recipeName = env.substitute(dep.recipe, "dependency::"+dep.recipe)
+                if dep.alias is None:
+                    aliasName = None
+                    realName = recipeName
+                else:
+                    realName = aliasName = env.substitute(dep.alias, "alias::"+dep.alias)
+                    if not ALIAS_NAME_RE.fullmatch(aliasName):
+                        raise ParseError(f"Invalid dependency alias: {aliasName}")
+                resolvedDeps.append(realName)
             except ParseError:
                 if skip: continue
                 raise
@@ -2377,7 +2445,7 @@ class Recipe(object):
                         k : depTools[v] for k,v in dep.toolOverride.items() })
                 except KeyError as e:
                     raise ParseError("Cannot remap unkown tool '{}' for dependency '{}'!"
-                        .format(e.args[0], recipe))
+                        .format(e.args[0], realName))
                 thisDepDiffTools = thisDepDiffTools.copy()
                 thisDepDiffTools.update({
                     k : depDiffTools.get(v, v)
@@ -2385,17 +2453,14 @@ class Recipe(object):
 
             if dep.envOverride:
                 thisDepEnv = thisDepEnv.derive(
-                    { key : env.substitute(value, "depends["+recipe+"].environment["+key+"]")
+                    { key : env.substitute(value, "depends["+realName+"].environment["+key+"]")
                       for key, value in dep.envOverride.items() })
 
-            r = self.__recipeSet.getRecipe(recipe)
+            r = self.__recipeSet.getRecipe(recipeName)
             try:
-                if r.__packageName in stack:
-                    raise ParseError("Recipes are cyclic (1st package in cylce)")
-                depStack = stack + [r.__packageName]
                 p, s = r.prepare(thisDepEnv, sandboxEnabled, depStates,
-                                 thisDepSandbox, thisDepTools, depStack)
-                subTreePackages.add(p.getName())
+                                 thisDepSandbox, thisDepTools, stack, aliasName)
+                subTreePackages.add(recipeName)
                 subTreePackages.update(s)
                 depCoreStep = p.getCorePackageStep()
                 depRef = CoreRef(depCoreStep, [p.getName()], thisDepDiffTools, thisDepDiffSandbox)
@@ -2406,14 +2471,14 @@ class Recipe(object):
             # A dependency should be named only once. Hence we can
             # optimistically create the DepTracker object. If the dependency is
             # named more than one we make sure that it is the same variant.
-            depTrack = thisDeps.setdefault(recipe, DepTracker(depRef))
+            depTrack = thisDeps.setdefault(p.getName(), DepTracker(depRef))
             if depTrack.prime():
                 directPackages.append(depRef)
             elif depCoreStep.variantId != depTrack.item.refGetDestination().variantId:
                 self.__raiseIncompatibleLocal(depCoreStep)
             else:
                 raise ParseError("Duplicate dependency '{}'. Each dependency must only be named once!"
-                                    .format(recipe))
+                                    .format(p.getName()))
 
             # Remember dependency diffs before changing them
             origDepDiffTools = thisDepDiffTools
@@ -2454,7 +2519,7 @@ class Recipe(object):
                     env.update(sandbox.environment)
                     if dep.provideGlobal: depEnv.update(sandbox.environment)
 
-            maybeProvideDeps.append((recipe, depRef, p.getName(), origDepDiffTools, origDepDiffSandbox))
+            maybeProvideDeps.append((p.getName(), depRef, origDepDiffTools, origDepDiffSandbox))
 
         # check provided dependencies
         providedDeps = set()
@@ -2464,8 +2529,8 @@ class Recipe(object):
                 raise ParseError("Unknown dependency '{}' in provideDeps".format(pattern.pattern))
             providedDeps |= l
 
-        for (recipe, depRef, name, origDepDiffTools, origDepDiffSandbox) in maybeProvideDeps:
-            if recipe in providedDeps:
+        for (name, depRef, origDepDiffTools, origDepDiffSandbox) in maybeProvideDeps:
+            if name in providedDeps:
                 provideDeps.append(depRef)
                 provideDeps.extend([CoreRef(d, [name], origDepDiffTools, origDepDiffSandbox)
                     for d in depRef.refGetDestination().providedDeps])
@@ -2485,8 +2550,8 @@ class Recipe(object):
                 indirectPackages.append(depRef)
             elif depCoreStep.variantId != depTrack.item.refGetDestination().variantId:
                 self.__raiseIncompatibleProvided(name,
-                    stack + depRef.refGetStack(),
-                    stack + depTrack.item.refGetStack())
+                    stack.getNameStack() + depRef.refGetStack(),
+                    stack.getNameStack() + depTrack.item.refGetStack())
 
             if depTrack.useResultOnce():
                 results.append(depRef)
@@ -2543,7 +2608,7 @@ class Recipe(object):
 
         # set fixed built-in variables
         env['BOB_RECIPE_NAME'] = self.__baseName
-        env['BOB_PACKAGE_NAME'] = self.__packageName
+        env['BOB_PACKAGE_NAME'] = self.__packageName if packageName is None else packageName
 
         # record used environment and tools
         env.touch(self.__packageVars | self.__packageVarsWeak)
@@ -2593,7 +2658,8 @@ class Recipe(object):
         # touchedTools = tools.touchedKeys()
         # diffTools = { n : t for n,t in diffTools.items() if n in touchedTools }
         p = CorePackage(self, toolsDetached, diffTools, sandbox, diffSandbox,
-                directPackages, indirectPackages, states, uidGen(), metaEnv)
+                directPackages, indirectPackages, states, uidGen(), metaEnv,
+                packageName)
 
         # optional checkout step
         if self.__checkout != (None, None, None) or self.__checkoutSCMs or self.__checkoutAsserts:
@@ -2672,9 +2738,9 @@ class Recipe(object):
                 p = reusableCorePackage
             self.__corePackagesByMatch.insert(0, PackageMatcher(
                 reusableCorePackage, inputEnv, inputTools, inputStates,
-                inputSandbox, subTreePackages))
+                inputSandbox, subTreePackages, packageName))
         elif packageCoreStep.getResultId() != reusedCorePackage.getCorePackageStep().getResultId():
-            raise AssertionError("Wrong reusage for " + "/".join(stack))
+            raise AssertionError("Wrong reusage for " + "/".join(stack.getNameStack()))
         else:
             # drop calculated package to keep memory consumption low
             p = reusedCorePackage
@@ -2709,7 +2775,7 @@ These dependencies constitute different variants of '{PKG}' and can therefore no
             help=
 """This error is caused by naming '{PKG}' multiple times in the recipe with incompatible variants.
 Every dependency must only be given once."""
-    .format(PKG=r.corePackage.getName(), CUR=self.__packageName))
+    .format(PKG=r.corePackage.getName()))
 
     def __raiseIncompatibleTools(self, tools, toolDepPackage):
         toolsVars = {}
@@ -2806,8 +2872,62 @@ Every dependency must only be given once."""
         return getLanguage(self.__scriptLanguage)
 
 
+class AliasPackage:
+    SCHEMA = schema.Or(
+        str,
+        {
+            "multiPackage" : {
+                MULTIPACKAGE_NAME_SCHEMA : str
+            }
+        })
+
+    @staticmethod
+    def loadFromFile(recipeSet, rootDir, fileName):
+        baseName = os.path.splitext( fileName )[0].split( os.sep )
+        fileName = os.path.join(rootDir, fileName)
+        try:
+            for n in baseName: RECIPE_NAME_SCHEMA.validate(n)
+        except schema.SchemaError as e:
+            raise ParseError("Invalid recipe name: '{}'".format(fileName))
+        baseName = "::".join( baseName )
+
+        alias = recipeSet.loadYaml(fileName, (AliasPackage.SCHEMA, b''))
+        if isinstance(alias, str):
+            return [ AliasPackage(recipeSet, alias, baseName) ]
+        else:
+            return [
+                AliasPackage(recipeSet, subAlias, baseName + ("-"+subName if subName else ""))
+                for (subName, subAlias) in alias["multiPackage"].items()
+            ]
+
+    def __init__(self, recipeSet, target, packageName):
+        self.__recipeSet = recipeSet
+        self.__target = target
+        self.__packageName = packageName
+
+    def prepare(self, env, sandboxEnabled, states, sandbox, tools, stack, packageName=None):
+        target = env.substitute(self.__target, "alias package")
+        if packageName is None:
+            packageName = self.__packageName
+        return self.__recipeSet.getRecipe(target).prepare(env, sandboxEnabled, states, sandbox,
+                                                          tools, stack,
+                                                          packageName=packageName)
+
+    def getPackageName(self):
+        return self.__packageName
+
+    def resolveClasses(self, env):
+        pass
+
+    def isRoot(self):
+        return False
+
+
 class PackageMatcher:
-    def __init__(self, corePackage, env, tools, states, sandbox, subTreePackages):
+    __slots__ = ( 'corePackage', 'env', 'tools', 'states', 'sandbox',
+                  'subTreePackages', 'packageName')
+
+    def __init__(self, corePackage, env, tools, states, sandbox, subTreePackages, packageName):
         self.corePackage = corePackage
         envData = env.inspect()
         self.env = { name : envData.get(name) for name in env.touchedKeys() }
@@ -2817,8 +2937,9 @@ class PackageMatcher:
         self.states = { n : s.copy() for (n,s) in states.items() }
         self.sandbox = sandbox.resultId if sandbox is not None else None
         self.subTreePackages = subTreePackages
+        self.packageName = packageName
 
-    def matches(self, inputEnv, inputTools, inputStates, inputSandbox):
+    def matches(self, inputEnv, inputTools, inputStates, inputSandbox, packageName):
         for (name, env) in self.env.items():
             if env != inputEnv.get(name): return False
         for (name, tool) in self.tools.items():
@@ -2828,6 +2949,7 @@ class PackageMatcher:
         match = inputSandbox.resultId if inputSandbox is not None else None
         if self.sandbox != match: return False
         if self.states != inputStates: return False
+        if self.packageName != packageName: return False
         return True
 
     def touch(self, inputEnv, inputTools):
@@ -3562,6 +3684,18 @@ class RecipeSet:
                         e.pushFrame(path)
                         raise
 
+            aliasesDir = os.path.join(rootDir, 'aliases')
+            for root, dirnames, filenames in os.walk(aliasesDir):
+                for path in fnmatch.filter(filenames, "[!.]*.yaml"):
+                    try:
+                        aliasPackages = AliasPackage.loadFromFile(self, aliasesDir,
+                            os.path.relpath(os.path.join(root, path), aliasesDir))
+                        for p in aliasPackages:
+                            self.__addRecipe(p)
+                    except ParseError as e:
+                        e.pushFrame(path)
+                        raise
+
         # Out-of-tree builds may have a dedicated default.yaml
         if recipesRoot:
             self.__parseUserConfig("default.yaml")
@@ -3745,6 +3879,7 @@ class RecipeSet:
             schema.Optional('if') : schema.Or(str, IfExpression),
             schema.Optional('tools') : { toolNameSchema : toolNameSchema },
             schema.Optional('checkoutDep') : bool,
+            schema.Optional('alias') : str,
         }
         dependsClause = schema.Schema([
             schema.Or(


Test patch:
diff --git a/test/unit/test_input_recipeset.py b/test/unit/test_input_recipeset.py
index 934b543..0e1ed61 100644
--- a/test/unit/test_input_recipeset.py
+++ b/test/unit/test_input_recipeset.py
@@ -28,7 +28,6 @@ class RecipesTmp:
         self.tmpdir = TemporaryDirectory()
         os.chdir(self.tmpdir.name)
         os.mkdir("recipes")
-        os.mkdir("classes")
 
     def tearDown(self):
         os.chdir(self.cwd)
@@ -38,7 +37,7 @@ class RecipesTmp:
         path = os.path.join("",
             *(os.path.join("layers", l) for l in layer),
             "recipes")
-        if path: os.makedirs(path, exist_ok=True)
+        os.makedirs(path, exist_ok=True)
         with open(os.path.join(path, name+".yaml"), "w") as f:
             f.write(textwrap.dedent(content))
 
@@ -46,7 +45,7 @@ class RecipesTmp:
         path = os.path.join("",
             *(os.path.join("layers", l) for l in layer),
             "classes")
-        if path: os.makedirs(path, exist_ok=True)
+        os.makedirs(path, exist_ok=True)
         with open(os.path.join(path, name+".yaml"), "w") as f:
             f.write(textwrap.dedent(content))
 
@@ -62,6 +61,14 @@ class RecipesTmp:
         with open(os.path.join(path, "default.yaml"), "w") as f:
             f.write(yaml.dump(content))
 
+    def writeAlias(self, name, content, layer=[]):
+        path = os.path.join("",
+            *(os.path.join("layers", l) for l in layer),
+            "aliases")
+        os.makedirs(path, exist_ok=True)
+        with open(os.path.join(path, name+".yaml"), "w") as f:
+            f.write(textwrap.dedent(content))
+
     def generate(self, sandboxEnabled=False, env={}):
         recipes = RecipeSet()
         recipes.parse(env)
@@ -1932,3 +1939,141 @@ class TestScmDefaults(RecipesTmp, TestCase):
         self.assertEqual(props["stripComponents"], 1)
         self.assertEqual(props["fileName"], "downloaded_file")
 
+
+class TestAliases(RecipesTmp, TestCase):
+    """Test recipe aliasing"""
+
+    def testGlobalAlias(self):
+        """Test global alias"""
+        self.writeRecipe("root", """\
+            root: True
+            depends:
+                - virtual
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("real", """\
+            packageScript: "real"
+            """)
+        self.writeAlias("virtual", "real")
+        pkg = self.generate().walkPackagePath("root/virtual")
+        self.assertEqual(pkg.getName(), "virtual")
+        self.assertEqual(pkg.getRecipe().getName(), "real")
+
+    def testGlobalAliasRecursive(self):
+        """Test global alias of an alias"""
+        self.writeRecipe("root", """\
+            root: True
+            depends:
+                - level1
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("real", """\
+            packageScript: "real"
+            """)
+        self.writeAlias("level1", "level2")
+        self.writeAlias("level2", "real")
+        pkg = self.generate().walkPackagePath("root/level1")
+        self.assertEqual(pkg.getName(), "level1")
+        self.assertEqual(pkg.getRecipe().getName(), "real")
+
+    def testLocalAlias(self):
+        """Test that the same dependency can be named with different aliases"""
+        self.writeRecipe("root", """\
+            root: True
+            depends:
+                - name: real
+                  alias: virtual1
+                - name: real
+                  alias: virtual2
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("real", """\
+            packageScript: "real"
+            """)
+        packages = self.generate()
+        p1 = self.generate().walkPackagePath("root/virtual1")
+        p2 = self.generate().walkPackagePath("root/virtual2")
+        self.assertEqual(p1.getName(), "virtual1")
+        self.assertEqual(p1.getRecipe().getName(), "real")
+        self.assertEqual(p2.getName(), "virtual2")
+        self.assertEqual(p2.getRecipe().getName(), "real")
+        self.assertEqual(p1.getPackageStep().getVariantId(),
+                         p2.getPackageStep().getVariantId())
+
+    def testLocalAliasWins(self):
+        """A local alias works with global aliases"""
+        self.writeRecipe("root", """\
+            root: True
+            depends:
+                - name: virtual
+                  alias: foo
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("real", """\
+            packageScript: "real"
+            """)
+        self.writeAlias("virtual", "real")
+        pkg = self.generate().walkPackagePath("root/foo")
+        self.assertEqual(pkg.getName(), "foo")
+        self.assertEqual(pkg.getRecipe().getName(), "real")
+
+    def testGlobalAliasDoesntHideCycle(self):
+        """Global aliases cannot hide the fact that recipes are cyclic"""
+        self.writeRecipe("root", """\
+            root: True
+            depends:
+                - level1
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("level1", """\
+            depends:
+                - level2
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("level2", """\
+            depends:
+                - level3
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeAlias("level3", "level1")
+        with self.assertRaises(ParseError):
+            packages = self.generate()
+            packages.walkPackagePath("root").getPackageStep()
+
+    def testLocalAliasDoesntHideCycle(self):
+        """Per-dependency aliases cannot hide the fact that recipes are cyclic"""
+        self.writeRecipe("root", """\
+            root: True
+            environment:
+                NAME: "x"
+            depends:
+                - name: level1
+                  alias: "${NAME}x"
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("level1", """\
+            depends:
+                - name: level2
+                  alias: "${NAME}x"
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        self.writeRecipe("level2", """\
+            depends:
+                - name: level1
+                  alias: "${NAME}x"
+            buildScript: "true"
+            packageScript: "true"
+            """)
+        with self.assertRaises(ParseError):
+            packages = self.generate()
+            packages.walkPackagePath("root").getPackageStep()
+