Parameterised item in MSBuild project

37 Views Asked by At

We have a C# custom property applied at the assembly level to a few hundred projects in the one solution. It takes a number of arguments which are a mixture of constants, values supplied at build time, and others inserted by code generation. The attribute may also be applied more than once per assembly. I'm looking to automate away some of the fragility in this setup. All the projects use csproj SDK XML.

The <AssemblyAttribute> element in a csproj <ItemGroup> looks ideal for this, but the nature of the parameters to this attribute makes it verbose to write this way. Ideally I'd like to just be able to specify the bit that varies per project, something like:

<OurCustomAttribute Value="ProjectSpecificValue"/>
<OurCustomAttribute Value="ProjectSpecificValueTwo"/>

or similar, either in an <ItemGroup>, directly inside the <Project> element itself, or a near equivalent, and have it be transformed into something like:

<ItemGroup>
  <AssemblyAttribute Include="Namespace.OurCustomAttribute">
    <_Parameter1>ProjectSpecificValue-InvariantValue</_Parameter1>
    <_Parameter2>$(SomeProperty)</_Parameter2>
    <_Parameter3>$(SomeOtherProperty)-some-static-string</_Parameter3>
    <!-- ...etc... -->
  </AssemblyAttribute>
  <AssemblyAttribute Include="Namespace.OurCustomAttribute">
    <_Parameter1>ProjectSpecificValueTwo-InvariantValue</_Parameter1>
    <_Parameter2>$(SomeProperty)</_Parameter2>
    <_Parameter3>$(SomeOtherProperty)-some-static-string</_Parameter3>
    <!-- ...etc... -->
  </AssemblyAttribute>
</ItemGroup>

We are using a Directory.Build.props, so some way of defining this behaviour in there would be ideal. Item transforms don't seem capable of this; I've seen from the docs that an inline Task could output items, but it's not clear to me how this could be (terse-ish-ly) invoked from the project file (custom Target somehow?), or if they would be picked up by the Targets which followed, or which BeforeTarget would need to be used here. We'd prefer not to have to create a custom task assembly for this.

Is this kind of parameterisation actually possible in MSBuild XML?

2

There are 2 best solutions below

1
Martin Ullrich On

You can use properties and define the item inside of Directory.Build.props.

The important thing is that MSBuild performs multiple passes over the enitre project and its imports, meaning that for example it reads all the PropertyGroup elements before it processes all the ItemGroup elements. Since Directory.Build.props is imported near the top of the SDK and thus logically before your actual csproj content, you can define properties with defualt values in Directory.Build.Props and then also define the ItemGroup with your assembly attribute item in it in the same file - the property can then still be overwritten in the main csproj file.

For example consider a MyCorp.ComponentNameAttribute with a name and a description and also the target framework used:

Directory.Build.props:

<Project>
  <PropertyGroup>
    <!-- Default these properties to the name of the project file being built without the extension (.csproj) -->
    <MyCorpComponentName>$(MSBuildProjectName)</MyCorpComponentName>
    <MyCorpComponentDescription>$(MSBuildProjectName)</MyCorpComponentDescription>
  </PropertyGroup>
  <ItemGroup>
    <AssemblyAttribute Include="MyCorp.ComponentNameAttribute">
      <_Parameter1>$(MyCorpComponentName)</_Parameter1>
      <_Parameter2>$(MyCorpComponentDescription)</_Parameter2>
      <_Parameter3>$(MyCorpComponentDotNetVersion)</_Parameter3>
    </AssemblyAttribute>
  </ItemGroup>
</Project>

And ComponentA.csproj could then set:

<Project Sdk="...">
  <PropertyGroup>
    <!-- MyCorpComponentName is 'ComponentA' by default, let's say this project doesn't change that -->
    <MyCorpComponentDescription>This component performs important business logic in the domain of XYZ</MyCorpComponentDescription>
  </PropertyGroup>
</Project>

If you want to modify properties AFTER the project content has been set, you can use Directory.Build.targets to have more property groups with conditions on them or when you need to rely on other properties that are set during the project evaluation as defaults (e.g. the TargetFramework is not yet known when processing Directory.Build.props but should be there when Directory.Build.targets is evaluated).

Directory.Build.targets:

<Project>
  <PropertyGroup>
    <MyCorpComponentDotNetVersion Condition="'$(MyCorpComponentDotNetVersion )' == ''">$(TargetFrameworkVersion)</MyCorpComponentDotNetVersion>
  </PropertyGroup>
</Project>
7
Jonathan Dodds On

Single Value Attribute

The Directory.Build.props is auto imported very early -- before the body of the .csproj file and even before the 'standard' properties are defined. Unless you have a specific need, I suggest avoid using the Directory.Build.props file and favor using the Directory.Build.targets file.

The .props and .targets files are plain MSBuild files and can contain any valid MSBuild content. .props is not restricted to properties and items and .targets is not restricted to targets.

Items don't take parameters but Include values and values for metadata will be evaluated for property expansion.

The support for the Namespace.OurCustomAttribute can be placed in one file (e.g. Namespace.OurCustomAttribute.targets) that is then Imported in the auto imported Directory.Build.targets. Don't copy/paste into each .csproj file.

<!-- Directory.Build.targets -->
<Project>
  <Import Project="Namespace.OurCustomAttribute.targets" />
</Project>

The _Parameter1, _Parameter2, and _Parameter3 metadata on the AssemblyAttribute item will be matched to parameters of a constructor of the your Namespace.OurCustomAttribute type. Changes to the constructor for the Namespace.OurCustomAttribute type may require changes to the Namespace.OurCustomAttribute.targets file. (You don't show the constructor. I'll assume from your AssemblyAttribute item example that the constructor has three parameters.)

You can set a property for the ProjectSpecificValue value in the .csproj and that property will be available to use in the code from the Directory.Build.targets.

The Namespace.OurCustomAttribute.targets file may be like the following:

<!-- Namespace.OurCustomAttribute.targets -->
<Project>
  <PropertyGroup>
    <ProjectDefaultValue>$(MSBuildProjectName)</ProjectDefaultValue>
    <ProjectSpecificValue Condition="'$(ProjectSpecificValue)' == ''">$(ProjectDefaultValue)</ProjectSpecificValue>

    <SomeProperty Condition="'$(SomeProperty)' == ''">SomeDefaultValue</SomeProperty>

    <SomeOtherProperty Condition="'$(SomeOtherProperty)' == ''">SomeOtherDefaultValue</SomeOtherProperty>

    <_Parameter1Value>$(ProjectSpecificValue)-InvariantValue</_Parameter1Value>
    <_Parameter2Value>$(SomeProperty)</_Parameter2Value>
    <_Parameter3Value>$(SomeOtherProperty)-some-static-string</_Parameter3Value>
  </PropertyGroup>

  <ItemGroup>
    <AssemblyAttribute Include="Namespace.OurCustomAttribute">
      <_Parameter1>$(_Parameter1Value)</_Parameter1>
      <_Parameter2>$(_Parameter2Value)</_Parameter2>
      <_Parameter3>$(_Parameter3Value)</_Parameter3>
    </AssemblyAttribute>
  <ItemGroup>

</Project>

In a .csproj file you would define the $(ProjectSpecificValue) property in a new or existing PropertyGroup.

  <PropertyGroup>
    <ProjectSpecificValue>ValueForThisProject</ProjectSpecificValue>
  </PropertyGroup>

Because the Namespace.OurCustomAttribute.targets file is coupled to the code for the Attribute, it may be a good idea to publish or share the Namespace.OurCustomAttribute.targets file with the Attribute's assembly file.

Multi-value Attribute

The above assumes a single value custom attribute that will be applied to all assemblies.

Item collections allow duplicates. The value used for the Include (which will be the Identity metadata) is not a unique key. Further items with the same Identity but differences in other metadata are not considered to be duplicates.

The following example provides two values for the Namespace.OurCustomAttribute attribute.

  <ItemGroup>
    <AssemblyAttribute Include="Namespace.OurCustomAttribute">
      <_Parameter1>foo</_Parameter1>
    </AssemblyAttribute>
    <AssemblyAttribute Include="Namespace.OurCustomAttribute">
      <_Parameter1>bar</_Parameter1>
    </AssemblyAttribute>
  <ItemGroup>

MSBuild uses the @(AssemblyAttribute) item collection to generate a code file that sets the assembly attributes.

[assembly:Namespace.OurCustomAttribute("foo")]
[assembly:Namespace.OurCustomAttribute("bar")]

For project specific values that don't depend on the build and don't change often it may be best to set the attribute in code yourself.

[assembly:Namespace.OurCustomAttribute("ProjectSpecificValue")]

(Note that this code doesn't need to be in a file named AssemblyInfo.cs. An assembly attribute can be added to any code file.)

You can mix using both @(AssemblyAttribute) and attribute values set in code.

Using a Task

Writing a task for setting an individual value probably doesn't make sense.

A task can't access properties and items. A property or item can be passed to a task as a parameter and an Output element can be added to map a task output value.

If a SetOurCustomAttribute task where created, its usage might look something like the following.

  <Target Name="Example" BeforeTargets="BeforeBuild">
    <SetOurCustomAttribute Value="ProjectSpecificValue">
      <Output TaskParameter="OutputItem" ItemName="AssemblyAttribute" />
    </SetOurCustomAttribute>
  </Target>

In terms of terseness, that's not much of an improvement over using an ItemGroup. Additionally an ItemGroup is not limited to only being used within a target.

A task might make sense if there is a set of build data used to compute a set of values for the attribute. Within the task a new ITaskItem[] would be created and populated. An Output element would be needed to map an output parameter for the ITaskItem[] to AssemblyAttribute.