diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 0889628960..c3e4afe827 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,11 @@ 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): + locales = [Locale(locale, encoding)] + if LocaleUtils(locales, self.target).run(): 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') - - return True if SysCommand(f'/usr/bin/arch-chroot {self.target} locale-gen').exit_code == 0 else False + return False 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..a937fdc009 100644 --- a/archinstall/lib/locale_helpers.py +++ b/archinstall/lib/locale_helpers.py @@ -1,4 +1,6 @@ import logging +import os +import re from typing import Iterator, List, Callable from .exceptions import ServiceException @@ -6,16 +8,400 @@ 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 or name.count('@') > 1: + raise ValueError(f'Invalid locale name: {name}') + + self.name = name + self.encoding = encoding + self.modifier = '' + + # Make sure we extract the modifier, that way we can put it in if needed. + 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 the 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') + + if not self.modifier: + self.str = f'{self.language}.{self.encoding}' + else: + self.str = f'{self.language}.{self.encoding}@{self.modifier}' + + def __str__(self) -> str: + return self.str + + 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. + if (self.language == other.language and + self.encoding.replace('-', '').lower() == other.encoding.replace('-', '').lower() and + self.modifier == other.modifier): + return True + + return False + + +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: The installation mount point, if omitted default to the local system. + :type target: str + """ + self.locales = locales + self.target = target + self.locale_gen = f'{target}/etc/locale.gen' + self.locale_conf = f'{target}/etc/locale.conf' + + def list_locales(self) -> List[Locale]: + """ + Get a list of all the locales in the locale-gen configuration file. + + :return: A list of all the locales. + :rtype: List[Locale] + """ + locales = [] + + try: + with open(self.locale_gen, 'r') as fh: + entries = fh.readlines() + except FileNotFoundError: + log(f"Configuration file for locale-gen not found: '{self.locale_gen}'", fg="red", level=logging.ERROR) + else: + # Before the list of locales begins there is an empty line with a '#' in front + # so collect the locales from bottom up and halt when done. + entries.reverse() + + for entry in entries: + text = entry.replace('#', '').strip() + + if text == '': + break + + locales.append(Locale(*text.split())) + + locales.reverse() + + return locales + + def verify_locales(self) -> bool: + """ + Check if the locales match entries in the locale-gen configuration file. + If a match is found then update the name of the locale to the name of the matching entry in case they differ. + + :return: If matched return True else False. + :rtype: bool + """ + list_locales = self.list_locales() + found_all = True + + for locale in self.locales: + found = False + for entry_locale in list_locales: + if locale == entry_locale: + locale.name = entry_locale.name + found = True + break + + if not found: + found_all = False + log(f'Unsupported locale: {locale}', fg='red', level=logging.ERROR) + + return found_all + + def list_uncommented(self) -> List[Locale]: + """ + Get a list of the uncommented entries in the locale-gen configuration file. + + :return: A list of the uncommented entries. + :rtype: List[Locale] + """ + uncommented = [] + + try: + with open(self.locale_gen, 'r') as fh: + entries = fh.readlines() + except FileNotFoundError: + log(f"Configuration file for locale-gen not found: '{self.locale_gen}'", fg="red", level=logging.ERROR) + else: + for entry in entries: + if entry[0] != '#': + uncommented.append(Locale(*entry.split())) + + 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 + """ + if sorted(self.locales, key = lambda x : x.name) == sorted(self.list_uncommented(), key = lambda x : x.name): + return True + + return False + + def uncomment(self) -> bool: + """ + Uncomment the locales matching entries in the locale-gen configuration file and comment all other entries. + + :return: If updated return True else False. + :rtype: bool + """ + try: + with open(self.locale_gen, 'r') as fh: + contents = fh.readlines() + except FileNotFoundError: + log(f"Configuration file for locale-gen not found: '{self.locale_gen}'", fg="red", level=logging.ERROR) + return False + + index = 0 + + # Comment all uncommented entries. + for index, entry in enumerate(contents): + if entry[0] != '#': + contents[index] = f'#{contents[index]}' + + # Uncomment the locales matching entries. + for locale in self.locales: + index = 0 + + for index, entry in enumerate(contents): + uncommented_entry = entry.replace('#', '') + + if uncommented_entry[0].isspace(): + continue + + if locale == Locale(*uncommented_entry.strip().split()): + contents[index] = uncommented_entry + break + + # Open the file again in write mode, to replace the contents + try: + with open(self.locale_gen, 'w') as fh: + fh.writelines(contents) + 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(f'Uncommented entries in locale-gen configuration file', level=logging.INFO) + + for locale in self.list_uncommented(): + log(f' {locale.name} {locale.encoding}', 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}' + + for line in SysCommand(command).decode().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 + """ + if sorted(self.locales, key = lambda x : str(x)) == sorted(self.list_generated(), key = lambda x : str(x)): + return True + + return False + + def remove_generated(self) -> bool: + """ + Remove the generated locales. + + :return: If removed return True else False. + :rtype: bool + """ + try: + os.remove(f'{self.target}/usr/lib/locale/locale-archive') + except FileNotFoundError: + pass + except OSError: + return False + + return True + + def generate(self) -> bool: + """ + Generate the locales. + + :return: If generated return True else False. + :rtype: bool + """ + # Before installing the locale check if the locale archive already exists and remove it if it does. + if not self.remove_generated(): + return False + + log(f'Generating locales...', level=logging.INFO) + + for locale in self.list_uncommented(): + command = f'localedef -i {locale.language} -c -f {locale.encoding} -A /usr/share/locale/locale.alias {locale.name}' + + if self.target: + command = f'/usr/bin/arch-chroot {self.target} {command}' + + log(f' {locale}...', level=logging.INFO) + + if (output := SysCommand(command)).exit_code != 0: + log(f'Failed to generate locale: {output}', fg='red', level=logging.ERROR) + return False + + log(f'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 open(self.locale_conf, 'r') as fh: + lines = fh.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(rf'^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 + + locale = self.locales[0] + set_locale = Locale(set_locale) + + if locale == set_locale or locale.name == set_locale.name: + return True + + return False + + 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 open(self.locale_conf, 'w') as fh: + fh.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(f"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 + + if not self.match_uncommented(): + if not self.uncomment(): + return False + + if not self.match_generated(): + 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() -> List[str]: with open('/etc/locale.gen', 'r') as fp: 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 + # Before the list of locales begins there is an empty line with a '#' in front + # so collect the locales from bottom up and halt when done. entries = fp.readlines() entries.reverse() @@ -124,6 +510,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..5457c33883 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('.', 1)[0] if '.' in locale else locale.split()[0] for locale in locales]) selected_locale = Menu( _('Choose which locale language to use'),