Repository: mesonbuild/meson

Issue:
python exception from i18n module when 'msgfmt' command is not found
**Describe the bug**
When running meson it detected that msgfmt was not found, warning that translations would be skipped, but then promptly threw a python stack trace and died:

```
Program msgfmt found: NO
po/meson.build:6: WARNING: Gettext not found, all translation targets will be ignored.
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/mesonbuild/mesonmain.py", line 153, in run
    return options.run_func(options)
  File "/usr/lib/python3/dist-packages/mesonbuild/msetup.py", line 301, in run
    app.generate()
  File "/usr/lib/python3/dist-packages/mesonbuild/msetup.py", line 185, in generate
    self._generate(env)
  File "/usr/lib/python3/dist-packages/mesonbuild/msetup.py", line 229, in _generate
    intr.run()
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreter/interpreter.py", line 2700, in run
    super().run()
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 149, in run
    self.evaluate_codeblock(self.ast, start=1)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 174, in evaluate_codeblock
    raise e
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 167, in evaluate_codeblock
    self.evaluate_statement(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 180, in evaluate_statement
    return self.function_call(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 455, in function_call
    res = func(node, func_args, kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 277, in wrapper
    return f(*nargs, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 574, in wrapper
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreter/interpreter.py", line 2200, in func_subdir
    self.evaluate_codeblock(codeblock)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 174, in evaluate_codeblock
    raise e
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 167, in evaluate_codeblock
    self.evaluate_statement(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 190, in evaluate_statement
    return self.evaluate_if(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 273, in evaluate_if
    self.evaluate_codeblock(i.block)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 174, in evaluate_codeblock
    raise e
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 167, in evaluate_codeblock
    self.evaluate_statement(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 184, in evaluate_statement
    return self.method_call(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 483, in method_call
    res = obj.method_call(method_name, args, kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreter/interpreterobjects.py", line 746, in method_call
    ret = method(state, args, kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 666, in wrapped
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 64, in wrapped
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 574, in wrapper
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/modules/i18n.py", line 181, in merge_file
    '--msgfmt=' + self.tools['msgfmt'].get_path(),
TypeError: can only concatenate str (not "NoneType") to str

data/meson.build:4:7: ERROR: Unhandled python exception

    This is a Meson bug and should be reported!
```

The build statement at ``data/meson.build:4:7`` was simply this:

```
  i18n.merge_file (
    type: 'desktop',
    input: desktop + '.in',
    output: desktop,
    po_dir: po_dir,
    install: true,
    install_dir: join_paths(datadir, 'applications')
  )
```

I hit this on Debian Sid, with meson 0.62.1 when I had failed to install `gettext`

**To Reproduce**

Experimentally, if you *only* have a call to ``i18n.merge_file()`` it works fine, but if you *first* have a call to ``i18n.gettext()`` then the subsequent ``i18n.merge_file()`` throws the python exception

```
# cat meson.build 


project(
  'demo','c',
  version: '1.0',
  license: 'GPLv3+',
)

i18n = import('i18n')

i18n.gettext(meson.project_name(),
             args: ['--sort-output'],
             preset: 'glib')

i18n.merge_file (
    type: 'desktop',
    input: 'demo.desktop.in',
    output: 'demo.desktop',
    po_dir: 'po',
    )

# meson buid
The Meson build system
Version: 0.62.1
Source dir: /
Build dir: /buid
Build type: native build
Project name: demo
Project version: 1.0
C compiler for the host machine: cc (gcc 11.3.0 "cc (Debian 11.3.0-1) 11.3.0")
C linker for the host machine: cc ld.bfd 2.38
Host machine cpu family: x86_64
Host machine cpu: x86_64
Program msgfmt found: NO
meson.build:13: WARNING: Gettext not found, all translation targets will be ignored.
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/mesonbuild/mesonmain.py", line 153, in run
    return options.run_func(options)
  File "/usr/lib/python3/dist-packages/mesonbuild/msetup.py", line 301, in run
    app.generate()
  File "/usr/lib/python3/dist-packages/mesonbuild/msetup.py", line 185, in generate
    self._generate(env)
  File "/usr/lib/python3/dist-packages/mesonbuild/msetup.py", line 229, in _generate
    intr.run()
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreter/interpreter.py", line 2700, in run
    super().run()
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 149, in run
    self.evaluate_codeblock(self.ast, start=1)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 174, in evaluate_codeblock
    raise e
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 167, in evaluate_codeblock
    self.evaluate_statement(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 184, in evaluate_statement
    return self.method_call(cur)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/interpreterbase.py", line 483, in method_call
    res = obj.method_call(method_name, args, kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreter/interpreterobjects.py", line 746, in method_call
    ret = method(state, args, kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 666, in wrapped
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 64, in wrapped
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/interpreterbase/decorators.py", line 574, in wrapper
    return f(*wrapped_args, **wrapped_kwargs)
  File "/usr/lib/python3/dist-packages/mesonbuild/modules/i18n.py", line 181, in merge_file
    '--msgfmt=' + self.tools['msgfmt'].get_path(),
TypeError: can only concatenate str (not "NoneType") to str

meson.build:15:5: ERROR: Unhandled python exception

    This is a Meson bug and should be reported!

```

**Expected behavior**
It should report that 'msgfmt' is not found & exit cleanly when calling ``i18n.merge_file``.  This happens correctly today *provided* that ``i18n.gettext`` is not called first.

```
# cat meson.build 


project(
  'demo','c',
  version: '1.0',
  license: 'GPLv3+',
)

i18n = import('i18n')


i18n.merge_file (
    type: 'desktop',
    input: 'demo.desktop.in',
    output: 'demo.desktop',
    po_dir: 'po',
    )

# meson buid
The Meson build system
Version: 0.62.1
Source dir: /
Build dir: /buid
Build type: native build
Project name: demo
Project version: 1.0
C compiler for the host machine: cc (gcc 11.3.0 "cc (Debian 11.3.0-1) 11.3.0")
C linker for the host machine: cc ld.bfd 2.38
Host machine cpu family: x86_64
Host machine cpu: x86_64
Program msgfmt found: NO

meson.build:12:5: ERROR: Program 'msgfmt' not found or not executable

A full log can be found at /buid/meson-logs/meson-log.txt
```


**system parameters**
Running in a debian:sid docker container with only 'apt-get update && apt-get install meson'  run, giving a minimal env
 
# meson --version
0.62.1
# ninja --version
1.10.1
# cat /etc/deb
debconf.conf    debian_version  
# cat /etc/debian_version 
bookworm/sid
# python3 --version
Python 3.10.4



Discussion:

Resolving patch:
diff --git a/mesonbuild/modules/i18n.py b/mesonbuild/modules/i18n.py
--- a/mesonbuild/modules/i18n.py
+++ b/mesonbuild/modules/i18n.py
@@ -140,10 +140,6 @@ def __init__(self, interpreter: 'Interpreter'):
             'xgettext': None,
         }
 
-    @staticmethod
-    def nogettext_warning(location: BaseNode) -> None:
-        mlog.warning('Gettext not found, all translation targets will be ignored.', once=True, location=location)
-
     @staticmethod
     def _get_data_dirs(state: 'ModuleState', dirs: T.Iterable[str]) -> T.List[str]:
         """Returns source directories of relative paths"""
@@ -166,7 +162,7 @@ def _get_data_dirs(state: 'ModuleState', dirs: T.Iterable[str]) -> T.List[str]:
         KwargInfo('type', str, default='xml', validator=in_set_validator({'xml', 'desktop'})),
     )
     def merge_file(self, state: 'ModuleState', args: T.List['TYPE_var'], kwargs: 'MergeFile') -> ModuleReturnValue:
-        if self.tools['msgfmt'] is None:
+        if self.tools['msgfmt'] is None or not self.tools['msgfmt'].found():
             self.tools['msgfmt'] = state.find_program('msgfmt', for_machine=mesonlib.MachineChoice.BUILD)
         podir = path.join(state.build_to_src, state.subdir, kwargs['po_dir'])
 
@@ -223,13 +219,18 @@ def merge_file(self, state: 'ModuleState', args: T.List['TYPE_var'], kwargs: 'Me
         ),
     )
     def gettext(self, state: 'ModuleState', args: T.Tuple[str], kwargs: 'Gettext') -> ModuleReturnValue:
-        for tool in ['msgfmt', 'msginit', 'msgmerge', 'xgettext']:
+        for tool, strict in [('msgfmt', True), ('msginit', False), ('msgmerge', False), ('xgettext', False)]:
             if self.tools[tool] is None:
                 self.tools[tool] = state.find_program(tool, required=False, for_machine=mesonlib.MachineChoice.BUILD)
             # still not found?
             if not self.tools[tool].found():
-                self.nogettext_warning(state.current_node)
-                return ModuleReturnValue(None, [])
+                if strict:
+                    mlog.warning('Gettext not found, all translation (po) targets will be ignored.',
+                                 once=True, location=state.current_node)
+                    return ModuleReturnValue(None, [])
+                else:
+                    mlog.warning(f'{tool!r} not found, maintainer targets will not work',
+                                 once=True, fatal=False, location=state.current_node)
         packagename = args[0]
         pkg_arg = f'--pkgname={packagename}'
 
@@ -251,11 +252,15 @@ def gettext(self, state: 'ModuleState', args: T.Tuple[str], kwargs: 'Gettext') -
         extra_arg = '--extra-args=' + '@@'.join(extra_args) if extra_args else None
 
         potargs = state.environment.get_build_command() + ['--internal', 'gettext', 'pot', pkg_arg]
+        potargs.append(f'--source-root={state.source_root}')
+        if state.subdir:
+            potargs.append(f'--subdir={state.subdir}')
         if datadirs:
             potargs.append(datadirs)
         if extra_arg:
             potargs.append(extra_arg)
-        potargs.append('--xgettext=' + self.tools['xgettext'].get_path())
+        if self.tools['xgettext'].found():
+            potargs.append('--xgettext=' + self.tools['xgettext'].get_path())
         pottarget = build.RunTarget(packagename + '-pot', potargs, [], state.subdir, state.subproject,
                                     state.environment, default_env=False)
         targets.append(pottarget)
@@ -292,6 +297,9 @@ def gettext(self, state: 'ModuleState', args: T.Tuple[str], kwargs: 'Gettext') -
         targets.append(allgmotarget)
 
         updatepoargs = state.environment.get_build_command() + ['--internal', 'gettext', 'update_po', pkg_arg]
+        updatepoargs.append(f'--source-root={state.source_root}')
+        if state.subdir:
+            updatepoargs.append(f'--subdir={state.subdir}')
         if lang_arg:
             updatepoargs.append(lang_arg)
         if datadirs:
@@ -299,7 +307,8 @@ def gettext(self, state: 'ModuleState', args: T.Tuple[str], kwargs: 'Gettext') -
         if extra_arg:
             updatepoargs.append(extra_arg)
         for tool in ['msginit', 'msgmerge']:
-            updatepoargs.append(f'--{tool}=' + self.tools[tool].get_path())
+            if self.tools[tool].found():
+                updatepoargs.append(f'--{tool}=' + self.tools[tool].get_path())
         updatepotarget = build.RunTarget(packagename + '-update-po', updatepoargs, [], state.subdir, state.subproject,
                                          state.environment, default_env=False)
         targets.append(updatepotarget)
diff --git a/mesonbuild/scripts/gettext.py b/mesonbuild/scripts/gettext.py
--- a/mesonbuild/scripts/gettext.py
+++ b/mesonbuild/scripts/gettext.py
@@ -23,6 +23,7 @@
 parser.add_argument('--datadirs', default='')
 parser.add_argument('--langs', default='')
 parser.add_argument('--localedir', default='')
+parser.add_argument('--source-root', default='')
 parser.add_argument('--subdir', default='')
 parser.add_argument('--xgettext', default='xgettext')
 parser.add_argument('--msgmerge', default='msgmerge')
@@ -45,7 +46,7 @@ def read_linguas(src_sub: str) -> T.List[str]:
         print(f'Could not find file LINGUAS in {src_sub}')
         return []
 
-def run_potgen(src_sub: str, xgettext: str, pkgname: str, datadirs: str, args: T.List[str]) -> int:
+def run_potgen(src_sub: str, xgettext: str, pkgname: str, datadirs: str, args: T.List[str], source_root: str) -> int:
     listfile = os.path.join(src_sub, 'POTFILES.in')
     if not os.path.exists(listfile):
         listfile = os.path.join(src_sub, 'POTFILES')
@@ -59,7 +60,7 @@ def run_potgen(src_sub: str, xgettext: str, pkgname: str, datadirs: str, args: T
 
     ofile = os.path.join(src_sub, pkgname + '.pot')
     return subprocess.call([xgettext, '--package-name=' + pkgname, '-p', src_sub, '-f', listfile,
-                            '-D', os.environ['MESON_SOURCE_ROOT'], '-k_', '-o', ofile] + args,
+                            '-D', source_root, '-k_', '-o', ofile] + args,
                            env=child_env)
 
 def update_po(src_sub: str, msgmerge: str, msginit: str, pkgname: str, langs: T.List[str]) -> int:
@@ -77,18 +78,16 @@ def run(args: T.List[str]) -> int:
     subcmd = options.command
     langs = options.langs.split('@@') if options.langs else None
     extra_args = options.extra_args.split('@@') if options.extra_args else []
-    subdir = os.environ.get('MESON_SUBDIR', '')
-    if options.subdir:
-        subdir = options.subdir
-    src_sub = os.path.join(os.environ['MESON_SOURCE_ROOT'], subdir)
+    subdir = options.subdir
+    src_sub = os.path.join(options.source_root, subdir)
 
     if not langs:
         langs = read_linguas(src_sub)
 
     if subcmd == 'pot':
-        return run_potgen(src_sub, options.xgettext, options.pkgname, options.datadirs, extra_args)
+        return run_potgen(src_sub, options.xgettext, options.pkgname, options.datadirs, extra_args, options.source_root)
     elif subcmd == 'update_po':
-        if run_potgen(src_sub, options.xgettext, options.pkgname, options.datadirs, extra_args) != 0:
+        if run_potgen(src_sub, options.xgettext, options.pkgname, options.datadirs, extra_args, options.source_root) != 0:
             return 1
         return update_po(src_sub, options.msgmerge, options.msginit, options.pkgname, langs)
     else:


Test patch: