diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 0889628960..e9b1de5f96 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -12,7 +12,7 @@ from .disk import get_partitions_in_use, Partition from .general import SysCommand, generate_password from .hardware import has_uefi, is_vm, cpu_vendor -from .locale_helpers import verify_keyboard_layout, verify_x11_keyboard_layout +from .locale_helpers import Locale, LocaleUtils, verify_keyboard_layout, verify_x11_keyboard_layout from .disk.helpers import findmnt from .mirrors import use_mirrors from .plugins import plugins @@ -440,32 +440,13 @@ def set_hostname(self, hostname: str, *args :str, **kwargs :str) -> None: fh.write(hostname + '\n') def set_locale(self, locale :str, encoding :str = 'UTF-8', *args :str, **kwargs :str) -> bool: - if not len(locale): - return True - - modifier = '' - - # This is a temporary patch to fix #1200 - if '.' in locale: - locale, potential_encoding = locale.split('.', 1) - - # Override encoding if encoding is set to the default parameter - # and the "found" encoding differs. - if encoding == 'UTF-8' and encoding != potential_encoding: - encoding = potential_encoding - - # Make sure we extract the modifier, that way we can put it in if needed. - if '@' in locale: - locale, modifier = locale.split('@', 1) - modifier = f"@{modifier}" - # - End patch - - with open(f'{self.target}/etc/locale.gen', 'a') as fh: - fh.write(f'{locale}.{encoding}{modifier} {encoding}\n') - with open(f'{self.target}/etc/locale.conf', 'w') as fh: - fh.write(f'LANG={locale}.{encoding}{modifier}\n') + try: + locales = [Locale(locale, encoding)] + except ValueError as error: + self.log(f'Invalid locale: {error}', level=logging.ERROR, fg='red') + return False - return True if SysCommand(f'/usr/bin/arch-chroot {self.target} locale-gen').exit_code == 0 else False + return LocaleUtils(locales, self.target).run() def set_timezone(self, zone :str, *args :str, **kwargs :str) -> bool: if not zone: diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py index 5580fa917b..3c9f25d2f9 100644 --- a/archinstall/lib/locale_helpers.py +++ b/archinstall/lib/locale_helpers.py @@ -1,4 +1,6 @@ import logging +import re +import pathlib from typing import Iterator, List, Callable from .exceptions import ServiceException @@ -6,28 +8,410 @@ from .output import log from .storage import storage -def list_keyboard_languages() -> Iterator[str]: - for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() +class Locale(): + def __init__(self, name: str, encoding: str = 'UTF-8'): + """ + A locale composed from a name and encoding. + + :param name: A name that represents a locale of the form language_territory[.encoding][@modifier]. An encoding within the name will override the encoding parameter. + :type name: str + :param encoding: The encoding of the locale; if omitted defaults to UTF-8. + :type encoding: str + """ + if not len(name): + raise ValueError('Locale name is an empty string') + + if name.count('.') > 1: + raise ValueError(f"Locale name '{name}' contains more than one '.'") + + if name.count('@') > 1: + raise ValueError(f"Locale name '{name}' contains more than one '@'") + + self.name = name + self.encoding = encoding + self.modifier = None + + # Extract the modifier if found. + if '@' in name: + name, potential_modifier = name.split('@') + + # Correct the name if it has the encoding and modifier in the wrong order. + if '.' in potential_modifier: + potential_modifier, potential_encoding = potential_modifier.split('.') + self.name = f'{name}.{potential_encoding}@{potential_modifier}' + name = f'{name}.{potential_encoding}' + + self.modifier = potential_modifier + + if '.' in name: + self.language, potential_encoding = name.split('.') + + # Override encoding if name contains an encoding that differs. + if encoding != potential_encoding: + self.encoding = potential_encoding + else: + self.language = name + + if not len(self.encoding): + raise ValueError('Locale encoding is an empty string') + + if not len(self.language): + raise ValueError('Locale language is an empty string') -def list_locales() -> List[str]: - with open('/etc/locale.gen', 'r') as fp: + self.str = f'{self.language}.{self.encoding}' + + if self.modifier is not None: + self.str += '@' + self.modifier + + def __str__(self) -> str: + return self.str + + def __repr__(self) -> str: + return f"Locale('{self.name}', '{self.encoding}')" + + def __eq__(self, other) -> bool: + # Locale names are not checked for a match since they can differ and still be the same locale. + # Encodings are formatted (no dashes and lowercase) before comparison since encodings in the list of generated locales are in this format. + return ( + self.language == other.language + and self.encoding.replace('-', '').lower() == other.encoding.replace('-', '').lower() + and self.modifier == other.modifier + ) + + def __lt__(self, other) -> bool: + return self.str < other.str + + +class LocaleUtils(): + def __init__(self, locales: List[Locale] = [], target: str = ''): + """ + Get locale information, generate locales, and set the system locale. + An instance can contain a list of locales and the target location. + + :param locales: A list of locales, the first locale is intended as the system locale. + :type locales: List[Locale] + :param target: An installation mount point, if omitted default to the local system. + :type target: str + """ + self.locales = locales + self.entries = [] + self.target = target + self.locale_gen = pathlib.Path(f'{target}/etc/locale.gen') + self.locale_conf = pathlib.Path(f'{target}/etc/locale.conf') + + def list_supported(self) -> List[Locale]: + """ + Get a list of supported locales. + + :return: A list of supported locales. + :rtype: List[Locale] + """ locales = [] - # before the list of locales begins there's an empty line with a '#' in front - # so we'll collect the localels from bottom up and halt when we're donw - entries = fp.readlines() - entries.reverse() - for entry in entries: - text = entry.replace('#', '').strip() - if text == '': - break - locales.append(text) + for locale in list_locales(self.target): + locales.append(Locale(*locale.split())) - locales.reverse() return locales + def verify_locales(self) -> bool: + """ + Check if the locales match supported locales. + If a match is found update the name of the locale to the name of the matching entry if they differ. + + :return: If matched return True else False. + :rtype: bool + """ + supported = self.list_supported() + found_all = True + + for locale in self.locales: + found = False + for entry in supported: + if locale == entry: + if locale.name != entry.name: + locale.name = entry.name + found = True + break + + if not found: + found_all = False + log(f'Unsupported locale: {locale}', fg='red', level=logging.ERROR) + + return found_all + + def create_entries_list(self): + self.entries = sorted([f'{locale.name} {locale.encoding}' for locale in self.locales]) + + def list_uncommented(self) -> List[str]: + """ + Get a list of the uncommented entries in the locale-gen configuration file. + + :return: A list of the uncommented entries. + :rtype: List[str] + """ + uncommented = [] + + try: + with self.locale_gen.open('r') as locale_gen: + lines = locale_gen.readlines() + except FileNotFoundError: + log(f"Configuration file for locale-gen not found: '{self.locale_gen}'", fg="red", level=logging.ERROR) + else: + for line in lines: + # Skip commented and blank lines + if line[0] != '#' and not line.isspace(): + uncommented.append(line.strip()) + + return uncommented + + def match_uncommented(self) -> bool: + """ + Check if the locales match the uncommented entries in the locale-gen configuration file. + + :return: If matched return True else False. + :rtype: bool + """ + return self.entries == sorted(self.list_uncommented()) + + def uncomment(self) -> bool: + """ + Uncomment entries in the locale-gen configuration file. + Comment all other uncommented entries and append the locales that do not match entries. + + :return: If updated return True else False. + :rtype: bool + """ + try: + with self.locale_gen.open('r') as locale_gen: + lines = locale_gen.readlines() + except FileNotFoundError: + log(f"Configuration file for locale-gen not found: '{self.locale_gen}'", fg="red", level=logging.ERROR) + return False + + entries = self.entries.copy() + + # Comment all uncommented entries. + for index, line in enumerate(lines): + # Skip commented and blank lines + if line[0] != '#' and not line.isspace(): + lines[index] = '#' + lines[index] + + # Uncomment entries with a match. + for entry in self.entries: + for index, line in enumerate(lines): + if line[1:].strip() == entry: + lines[index] = entry + '\n' + entries.remove(entry) + break + + # Append entries that did not match. + for entry in entries: + lines.append(entry + '\n') + + # Open the file again in write mode, to replace the contents. + try: + with self.locale_gen.open('w') as locale_gen: + locale_gen.writelines(lines) + except PermissionError: + log(f"Permission denied to write to the locale-gen configuration file: '{self.locale_gen}'", fg="red", level=logging.ERROR) + return False + + log('Uncommented entries in locale-gen configuration file', level=logging.INFO) + + for entry in self.list_uncommented(): + log(' ' + entry, level=logging.INFO) + + return True + + def list_generated(self) -> List[Locale]: + """ + Get a list of the generated locales. + + :return: A list of generated locales. + :rtype: List[Locale] + """ + command = 'localedef --list-archive' + generated = [] + + if self.target: + command = f'/usr/bin/arch-chroot {self.target} {command}' + + if (output := SysCommand(command)).exit_code != 0: + log(f"Failed to get list of generated locales: '{output}'", fg="red", level=logging.ERROR) + else: + for line in output.decode('UTF-8').split(): + # Eliminate duplicates by filtering out names that do not contain an encoding. + if '.' in line: + generated.append(Locale(line)) + + return generated + + def match_generated(self) -> bool: + """ + Check if the locales match all the generated locales. + + :return: If matched return True else False. + :rtype: bool + """ + return sorted(self.locales) == self.list_generated() + + def remove_generated(self) -> bool: + """ + Remove the generated locales. + + :return: If removed return True else False. + :rtype: bool + """ + locale_archive = pathlib.Path(f'{self.target}/usr/lib/locale/locale-archive') + + try: + locale_archive.unlink(missing_ok=True) + except OSError: + return False + + return True + + def generate(self) -> bool: + """ + Generate the locales. + + :return: If generated return True else False. + :rtype: bool + """ + command = 'localedef -i {} -c -f {} -A /usr/share/locale/locale.alias {}' + + if self.target: + command = f'/usr/bin/arch-chroot {self.target} ' + command + + log('Generating locales...', level=logging.INFO) + + for locale in sorted(self.locales): + formatted_command = command.format(locale.language, locale.encoding, locale.name) + + log(f' {locale}...', level=logging.INFO) + + if (output := SysCommand(formatted_command)).exit_code != 0: + log(f'Failed to generate locale: {output}', fg='red', level=logging.ERROR) + return False + + log('Generation complete.', level=logging.INFO) + return True + + def get_system_locale(self) -> str: + """ + Get the system locale. + + :return: If set return the locale else None. + :rtype: str + """ + try: + with self.locale_conf.open('r') as locale_conf: + lines = locale_conf.readlines() + except FileNotFoundError: + pass + else: + # Set up a regular expression pattern of a line beginning with 'LANG=' + # followed by and ending in a locale in optional double quotes. + pattern = re.compile(r'^LANG="?(.+?)"?$') + + for line in lines: + if (match_obj := pattern.match(line)) is not None: + return match_obj.group(1) + + return None + + def match_system_locale(self) -> bool: + """ + Check if the first locale in locales is set as the system locale. + + :return: If set return True else False. + :rtype: bool + """ + if (set_locale := self.get_system_locale()) is None: + return False + + return set_locale == self.locales[0].name + + def set_system_locale(self) -> bool: + """ + Set the first locale in locales as the system locale. + + :return: If set return True else False. + :rtype: bool + """ + locale = self.locales[0] + + try: + with self.locale_conf.open('w') as locale_conf: + locale_conf.write(f'LANG={locale.name}\n') + except FileNotFoundError: + log(f"Directory not found: '{self.target}'", fg="red", level=logging.ERROR) + return False + except PermissionError: + log(f"Permission denied to write to the locale configuration file: '{self.locale_conf}'", fg="red", level=logging.ERROR) + return False + + log(f'System locale set to {locale.name}', level=logging.INFO) + return True + + def run(self) -> bool: + """ + Update the configuration file for locale-gen, generate locales, and set the system locale. + + :return: If successful return True else False. + :rtype: bool + """ + if not len(self.locales): + log('No locales to generate or to set as the system locale.', fg='yellow', level=logging.WARNING) + return True + + if not self.verify_locales(): + return False + + self.create_entries_list() + + if not self.match_uncommented(): + if not self.uncomment(): + return False + + if not self.match_generated(): + # Remove the locale archive if it already exists. + if not self.remove_generated(): + return False + + if not self.generate(): + return False + + if not self.match_system_locale(): + if not self.set_system_locale(): + return False + + return True + + +def list_locales(target: str = '') -> List[str]: + """ + Get a list of locales. + + :param target: An installation mount point, if omitted default to the local system. + :type target: str + :return: A list of locales. + :rtype: List[str] + """ + supported = pathlib.Path(f'{target}/usr/share/i18n/SUPPORTED') + + try: + with supported.open('r') as supported_file: + locales = supported_file.readlines() + except FileNotFoundError: + log(f"Supported locale file not found: '{supported}'", fg="red", level=logging.ERROR) + else: + # Remove C.UTF-8 since it is provided by the glibc package. + locales.remove('C.UTF-8 UTF-8\n') + + return locales + def get_locale_mode_text(mode): if mode == 'LC_ALL': mode_text = "general (LC_ALL)" @@ -124,6 +508,11 @@ def list_installed_locales() -> List[str]: lista.append(line.decode('UTF-8').strip()) return lista +def list_keyboard_languages() -> Iterator[str]: + for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() + + def list_x11_keyboard_languages() -> Iterator[str]: for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}): yield line.decode('UTF-8').strip() diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py index 15720064d1..2ea0546dfe 100644 --- a/archinstall/lib/user_interaction/locale_conf.py +++ b/archinstall/lib/user_interaction/locale_conf.py @@ -12,7 +12,7 @@ def select_locale_lang(preset: str = None) -> str: locales = list_locales() - locale_lang = set([locale.split()[0] for locale in locales]) + locale_lang = set([locale.split('.')[0] if '.' in locale else locale.split()[0] for locale in locales]) selected_locale = Menu( _('Choose which locale language to use'),