Skip to content

Commit 439c143

Browse files
authored
✨Fallback to invariant culture if cultures are not found (#1266)
Fixes #1238 Applications crashed when running on Linux or Raspberry PI systems if .NET cultures were not installed. Specifically, `UnitAbbreviationsCache.Default` threw an exception trying to instantiate the fallback `CultureInfo` with `en-US`. ### Changes - Change fallback culture to `InvariantCulture` - Add `CultureHelper.GetCultureOrInvariant()` to handle `CultureNotFoundException` - Change `UnitInfo` to map invariant culture to `en-US` localization
1 parent 6bd05bf commit 439c143

File tree

5 files changed

+82
-34
lines changed

5 files changed

+82
-34
lines changed

UnitsNet.Tests/UnitAbbreviationsCacheTests.cs

+14-23
Original file line numberDiff line numberDiff line change
@@ -213,29 +213,20 @@ public void GetDefaultAbbreviationThrowsNotImplementedExceptionIfNoneExist()
213213
[Fact]
214214
public void GetDefaultAbbreviationFallsBackToUsEnglishCulture()
215215
{
216-
var oldCurrentCulture = CultureInfo.CurrentCulture;
217-
218-
try
219-
{
220-
// CurrentCulture affects number formatting, such as comma or dot as decimal separator.
221-
// CurrentCulture affects localization, in this case the abbreviation.
222-
// Zulu (South Africa)
223-
var zuluCulture = CultureInfo.GetCultureInfo("zu-ZA");
224-
CultureInfo.CurrentCulture = zuluCulture;
225-
226-
var abbreviationsCache = new UnitAbbreviationsCache();
227-
abbreviationsCache.MapUnitToAbbreviation(CustomUnit.Unit1, AmericanCulture, "US english abbreviation for Unit1");
228-
229-
// Act
230-
string abbreviation = abbreviationsCache.GetDefaultAbbreviation(CustomUnit.Unit1, zuluCulture);
231-
232-
// Assert
233-
Assert.Equal("US english abbreviation for Unit1", abbreviation);
234-
}
235-
finally
236-
{
237-
CultureInfo.CurrentCulture = oldCurrentCulture;
238-
}
216+
// CurrentCulture affects number formatting, such as comma or dot as decimal separator.
217+
// CurrentCulture also affects localization of unit abbreviations.
218+
// Zulu (South Africa)
219+
var zuluCulture = CultureInfo.GetCultureInfo("zu-ZA");
220+
// CultureInfo.CurrentCulture = zuluCulture;
221+
222+
var abbreviationsCache = new UnitAbbreviationsCache();
223+
abbreviationsCache.MapUnitToAbbreviation(CustomUnit.Unit1, AmericanCulture, "US english abbreviation for Unit1");
224+
225+
// Act
226+
string abbreviation = abbreviationsCache.GetDefaultAbbreviation(CustomUnit.Unit1, zuluCulture);
227+
228+
// Assert
229+
Assert.Equal("US english abbreviation for Unit1", abbreviation);
239230
}
240231

241232
[Fact]

UnitsNet/CustomCode/UnitAbbreviationsCache.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public sealed class UnitAbbreviationsCache
2525
/// culture, but no translation is defined, so we return the US English definition as a last resort. If it's not
2626
/// defined there either, an exception is thrown.
2727
/// </example>
28-
internal static readonly CultureInfo FallbackCulture = CultureInfo.GetCultureInfo("en-US");
28+
internal static readonly CultureInfo FallbackCulture = CultureInfo.InvariantCulture;
2929

3030
/// <summary>
3131
/// The static instance used internally for ToString() and Parse() of quantities and units.
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Licensed under MIT No Attribution, see LICENSE file at the root.
2+
// Copyright 2013 Andreas Gullberg Larsen (andreas.larsen84@gmail.com). Maintained at https://github.com/angularsen/UnitsNet.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Globalization;
7+
8+
namespace UnitsNet.InternalHelpers;
9+
10+
/// <summary>
11+
/// Helper class for <see cref="CultureInfo"/> and related operations.
12+
/// </summary>
13+
internal static class CultureHelper
14+
{
15+
private static readonly ConcurrentDictionary<string, CultureInfo> CultureCache = new();
16+
17+
/// <summary>
18+
/// Attempts to get the culture by name, with fallback to invariant culture if not found.<br/>
19+
/// <br/>
20+
/// This is particularly useful for Linux and Raspberry PI environments, where cultures may not always be installed.
21+
/// To simulate the behavior, set environment variable DOTNET_SYSTEM_GLOBALIZATION_INVARIANT='1' when running the application.
22+
/// </summary>
23+
/// <param name="cultureName">The culture name.</param>
24+
/// <returns><see cref="CultureInfo.CurrentCulture"/> if given <c>null</c>, or the culture with the given name if the culture is available, otherwise <see cref="CultureInfo.InvariantCulture"/>.</returns>
25+
internal static CultureInfo GetCultureOrInvariant(string? cultureName)
26+
{
27+
if (cultureName is null) return CultureInfo.CurrentCulture;
28+
29+
try
30+
{
31+
// Use cache to avoid exception and diagnostic log events every time.
32+
return CultureCache.GetOrAdd(cultureName, CultureInfo.GetCultureInfo);
33+
}
34+
catch (CultureNotFoundException)
35+
{
36+
Console.Error.WriteLine($"Failed to get culture '{cultureName}', falling back to invariant culture.");
37+
System.Diagnostics.Debug.WriteLine($"Failed to get culture '{cultureName}', falling back to invariant culture.");
38+
39+
// Cache it, to avoid exception next time.
40+
CultureCache.TryAdd(cultureName, CultureInfo.InvariantCulture);
41+
42+
return CultureInfo.InvariantCulture;
43+
}
44+
}
45+
}

UnitsNet/UnitConverter.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Globalization;
88
using System.Reflection;
99
using System.Linq;
10+
using UnitsNet.InternalHelpers;
1011
using UnitsNet.Units;
1112

1213
namespace UnitsNet
@@ -420,7 +421,7 @@ public static double ConvertByAbbreviation(QuantityValue fromValue, string quant
420421
if (!TryGetUnitType(quantityName, out Type? unitType))
421422
throw new UnitNotFoundException($"The unit type for the given quantity was not found: {quantityName}");
422423

423-
var cultureInfo = string.IsNullOrWhiteSpace(culture) ? CultureInfo.CurrentCulture : CultureInfo.GetCultureInfo(culture);
424+
var cultureInfo = CultureHelper.GetCultureOrInvariant(culture);
424425

425426
var fromUnit = UnitParser.Default.Parse(fromUnitAbbrev, unitType, cultureInfo); // ex: ("m", LengthUnit) => LengthUnit.Meter
426427
var fromQuantity = Quantity.From(fromValue, fromUnit);
@@ -479,7 +480,7 @@ public static bool TryConvertByAbbreviation(QuantityValue fromValue, string quan
479480
if (!TryGetUnitType(quantityName, out Type? unitType))
480481
return false;
481482

482-
var cultureInfo = string.IsNullOrWhiteSpace(culture) ? CultureInfo.CurrentCulture : CultureInfo.GetCultureInfo(culture);
483+
var cultureInfo = CultureHelper.GetCultureOrInvariant(culture);
483484

484485
if (!UnitParser.Default.TryParse(fromUnitAbbrev, unitType, cultureInfo, out Enum? fromUnit)) // ex: ("m", LengthUnit) => LengthUnit.Meter
485486
return false;

UnitsNet/UnitInfo.cs

+19-8
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public UnitInfo(Enum value, string pluralName, BaseUnits baseUnits)
3636
PluralName = pluralName;
3737
BaseUnits = baseUnits ?? throw new ArgumentNullException(nameof(baseUnits));
3838

39-
AbbreviationsMap = new ConcurrentDictionary<CultureInfo, Lazy<IReadOnlyList<string>>>();
39+
AbbreviationsMap = new ConcurrentDictionary<string, Lazy<IReadOnlyList<string>>>();
4040
}
4141

4242
/// <summary>
@@ -75,12 +75,12 @@ internal UnitInfo(Enum value, string pluralName, BaseUnits baseUnits, string qua
7575
private string? QuantityName { get; }
7676

7777
/// <summary>
78-
/// The per-culture abbreviations. To add a custom default abbreviation, add to the beginning of the list.
78+
/// Culture name to abbreviations. To add a custom default abbreviation, add to the beginning of the list.
7979
/// </summary>
80-
private IDictionary<CultureInfo, Lazy<IReadOnlyList<string>>> AbbreviationsMap { get; }
80+
private IDictionary<string, Lazy<IReadOnlyList<string>>> AbbreviationsMap { get; }
8181

8282
/// <summary>
83-
///
83+
///
8484
/// </summary>
8585
/// <param name="formatProvider"></param>
8686
/// <returns></returns>
@@ -91,9 +91,10 @@ public IReadOnlyList<string> GetAbbreviations(IFormatProvider? formatProvider =
9191
formatProvider = CultureInfo.CurrentCulture;
9292

9393
var culture = (CultureInfo)formatProvider;
94+
var cultureName = GetCultureNameOrEnglish(culture);
9495

95-
if(!AbbreviationsMap.TryGetValue(culture, out var abbreviations))
96-
AbbreviationsMap[culture] = abbreviations = new Lazy<IReadOnlyList<string>>(() => ReadAbbreviationsFromResourceFile(culture));
96+
if(!AbbreviationsMap.TryGetValue(cultureName, out var abbreviations))
97+
AbbreviationsMap[cultureName] = abbreviations = new Lazy<IReadOnlyList<string>>(() => ReadAbbreviationsFromResourceFile(culture));
9798

9899
if(abbreviations.Value.Count == 0 && !culture.Equals(UnitAbbreviationsCache.FallbackCulture))
99100
return GetAbbreviations(UnitAbbreviationsCache.FallbackCulture);
@@ -102,7 +103,7 @@ public IReadOnlyList<string> GetAbbreviations(IFormatProvider? formatProvider =
102103
}
103104

104105
/// <summary>
105-
///
106+
///
106107
/// </summary>
107108
/// <param name="formatProvider"></param>
108109
/// <param name="setAsDefault"></param>
@@ -114,6 +115,7 @@ public void AddAbbreviation(IFormatProvider? formatProvider, bool setAsDefault,
114115
formatProvider = CultureInfo.CurrentCulture;
115116

116117
var culture = (CultureInfo)formatProvider;
118+
var cultureName = GetCultureNameOrEnglish(culture);
117119

118120
// Restrict concurrency on writes.
119121
// By using ConcurrencyDictionary and immutable IReadOnlyList instances, we don't need to lock on reads.
@@ -132,10 +134,19 @@ public void AddAbbreviation(IFormatProvider? formatProvider, bool setAsDefault,
132134
}
133135
}
134136

135-
AbbreviationsMap[culture] = new Lazy<IReadOnlyList<string>>(() => currentAbbreviationsList.AsReadOnly());
137+
AbbreviationsMap[cultureName] = new Lazy<IReadOnlyList<string>>(() => currentAbbreviationsList.AsReadOnly());
136138
}
137139
}
138140

141+
private static string GetCultureNameOrEnglish(CultureInfo culture)
142+
{
143+
// Fallback culture is invariant to support DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1,
144+
// but we need to map that to the primary localization, English.
145+
return culture.Equals(CultureInfo.InvariantCulture)
146+
? "en-US"
147+
: culture.Name;
148+
}
149+
139150
private IReadOnlyList<string> ReadAbbreviationsFromResourceFile(CultureInfo culture)
140151
{
141152
var abbreviationsList = new List<string>();

0 commit comments

Comments
 (0)