Skip to content

Commit

Permalink
✨Fallback to invariant culture if cultures are not found (#1266)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
angularsen authored Jun 18, 2023
1 parent 6bd05bf commit 439c143
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 34 deletions.
37 changes: 14 additions & 23 deletions UnitsNet.Tests/UnitAbbreviationsCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,29 +213,20 @@ public void GetDefaultAbbreviationThrowsNotImplementedExceptionIfNoneExist()
[Fact]
public void GetDefaultAbbreviationFallsBackToUsEnglishCulture()
{
var oldCurrentCulture = CultureInfo.CurrentCulture;

try
{
// CurrentCulture affects number formatting, such as comma or dot as decimal separator.
// CurrentCulture affects localization, in this case the abbreviation.
// Zulu (South Africa)
var zuluCulture = CultureInfo.GetCultureInfo("zu-ZA");
CultureInfo.CurrentCulture = zuluCulture;

var abbreviationsCache = new UnitAbbreviationsCache();
abbreviationsCache.MapUnitToAbbreviation(CustomUnit.Unit1, AmericanCulture, "US english abbreviation for Unit1");

// Act
string abbreviation = abbreviationsCache.GetDefaultAbbreviation(CustomUnit.Unit1, zuluCulture);

// Assert
Assert.Equal("US english abbreviation for Unit1", abbreviation);
}
finally
{
CultureInfo.CurrentCulture = oldCurrentCulture;
}
// CurrentCulture affects number formatting, such as comma or dot as decimal separator.
// CurrentCulture also affects localization of unit abbreviations.
// Zulu (South Africa)
var zuluCulture = CultureInfo.GetCultureInfo("zu-ZA");
// CultureInfo.CurrentCulture = zuluCulture;

var abbreviationsCache = new UnitAbbreviationsCache();
abbreviationsCache.MapUnitToAbbreviation(CustomUnit.Unit1, AmericanCulture, "US english abbreviation for Unit1");

// Act
string abbreviation = abbreviationsCache.GetDefaultAbbreviation(CustomUnit.Unit1, zuluCulture);

// Assert
Assert.Equal("US english abbreviation for Unit1", abbreviation);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion UnitsNet/CustomCode/UnitAbbreviationsCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public sealed class UnitAbbreviationsCache
/// culture, but no translation is defined, so we return the US English definition as a last resort. If it's not
/// defined there either, an exception is thrown.
/// </example>
internal static readonly CultureInfo FallbackCulture = CultureInfo.GetCultureInfo("en-US");
internal static readonly CultureInfo FallbackCulture = CultureInfo.InvariantCulture;

/// <summary>
/// The static instance used internally for ToString() and Parse() of quantities and units.
Expand Down
45 changes: 45 additions & 0 deletions UnitsNet/InternalHelpers/CultureHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Licensed under MIT No Attribution, see LICENSE file at the root.
// Copyright 2013 Andreas Gullberg Larsen (andreas.larsen84@gmail.com). Maintained at https://github.com/angularsen/UnitsNet.

using System;
using System.Collections.Concurrent;
using System.Globalization;

namespace UnitsNet.InternalHelpers;

/// <summary>
/// Helper class for <see cref="CultureInfo"/> and related operations.
/// </summary>
internal static class CultureHelper
{
private static readonly ConcurrentDictionary<string, CultureInfo> CultureCache = new();

/// <summary>
/// Attempts to get the culture by name, with fallback to invariant culture if not found.<br/>
/// <br/>
/// This is particularly useful for Linux and Raspberry PI environments, where cultures may not always be installed.
/// To simulate the behavior, set environment variable DOTNET_SYSTEM_GLOBALIZATION_INVARIANT='1' when running the application.
/// </summary>
/// <param name="cultureName">The culture name.</param>
/// <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>
internal static CultureInfo GetCultureOrInvariant(string? cultureName)
{
if (cultureName is null) return CultureInfo.CurrentCulture;

try
{
// Use cache to avoid exception and diagnostic log events every time.
return CultureCache.GetOrAdd(cultureName, CultureInfo.GetCultureInfo);
}
catch (CultureNotFoundException)
{
Console.Error.WriteLine($"Failed to get culture '{cultureName}', falling back to invariant culture.");
System.Diagnostics.Debug.WriteLine($"Failed to get culture '{cultureName}', falling back to invariant culture.");

// Cache it, to avoid exception next time.
CultureCache.TryAdd(cultureName, CultureInfo.InvariantCulture);

return CultureInfo.InvariantCulture;
}
}
}
5 changes: 3 additions & 2 deletions UnitsNet/UnitConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Globalization;
using System.Reflection;
using System.Linq;
using UnitsNet.InternalHelpers;
using UnitsNet.Units;

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

var cultureInfo = string.IsNullOrWhiteSpace(culture) ? CultureInfo.CurrentCulture : CultureInfo.GetCultureInfo(culture);
var cultureInfo = CultureHelper.GetCultureOrInvariant(culture);

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

var cultureInfo = string.IsNullOrWhiteSpace(culture) ? CultureInfo.CurrentCulture : CultureInfo.GetCultureInfo(culture);
var cultureInfo = CultureHelper.GetCultureOrInvariant(culture);

if (!UnitParser.Default.TryParse(fromUnitAbbrev, unitType, cultureInfo, out Enum? fromUnit)) // ex: ("m", LengthUnit) => LengthUnit.Meter
return false;
Expand Down
27 changes: 19 additions & 8 deletions UnitsNet/UnitInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public UnitInfo(Enum value, string pluralName, BaseUnits baseUnits)
PluralName = pluralName;
BaseUnits = baseUnits ?? throw new ArgumentNullException(nameof(baseUnits));

AbbreviationsMap = new ConcurrentDictionary<CultureInfo, Lazy<IReadOnlyList<string>>>();
AbbreviationsMap = new ConcurrentDictionary<string, Lazy<IReadOnlyList<string>>>();
}

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

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

/// <summary>
///
///
/// </summary>
/// <param name="formatProvider"></param>
/// <returns></returns>
Expand All @@ -91,9 +91,10 @@ public IReadOnlyList<string> GetAbbreviations(IFormatProvider? formatProvider =
formatProvider = CultureInfo.CurrentCulture;

var culture = (CultureInfo)formatProvider;
var cultureName = GetCultureNameOrEnglish(culture);

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

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

/// <summary>
///
///
/// </summary>
/// <param name="formatProvider"></param>
/// <param name="setAsDefault"></param>
Expand All @@ -114,6 +115,7 @@ public void AddAbbreviation(IFormatProvider? formatProvider, bool setAsDefault,
formatProvider = CultureInfo.CurrentCulture;

var culture = (CultureInfo)formatProvider;
var cultureName = GetCultureNameOrEnglish(culture);

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

AbbreviationsMap[culture] = new Lazy<IReadOnlyList<string>>(() => currentAbbreviationsList.AsReadOnly());
AbbreviationsMap[cultureName] = new Lazy<IReadOnlyList<string>>(() => currentAbbreviationsList.AsReadOnly());
}
}

private static string GetCultureNameOrEnglish(CultureInfo culture)
{
// Fallback culture is invariant to support DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1,
// but we need to map that to the primary localization, English.
return culture.Equals(CultureInfo.InvariantCulture)
? "en-US"
: culture.Name;
}

private IReadOnlyList<string> ReadAbbreviationsFromResourceFile(CultureInfo culture)
{
var abbreviationsList = new List<string>();
Expand Down

0 comments on commit 439c143

Please sign in to comment.