From 3bda1d68ccfee86a93b0ea47a59d950d3ea51f36 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Wed, 11 Mar 2026 10:38:09 -0600 Subject: [PATCH 01/18] Configure Babel and Jinja to extract and render gettext-wrapped messages in HTML templates. --- src/natcap/invest/internationalization/README.md | 6 +++--- src/natcap/invest/internationalization/babel_config.ini | 9 +++++++++ src/natcap/invest/reports/__init__.py | 8 +++++++- 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 src/natcap/invest/internationalization/babel_config.ini diff --git a/src/natcap/invest/internationalization/README.md b/src/natcap/invest/internationalization/README.md index 7b1c077402..5f65815cf4 100644 --- a/src/natcap/invest/internationalization/README.md +++ b/src/natcap/invest/internationalization/README.md @@ -22,7 +22,7 @@ No changes are immediately needed when we add, remove, or edit strings that are When we are ready to get a new batch of translations, here is the process. These instructions assume you have defined the two-letter locale code in an environment variable `$LL`. -1. Run the following from the root invest directory, replacing `` with the language code: +1. Run the following from the root invest directory, replacing `$LL` with the language code: ``` pybabel extract \ --no-wrap \ @@ -30,7 +30,7 @@ pybabel extract \ --version $(python -m setuptools_scm) \ --msgid-bugs-address natcap-software@lists.stanford.edu \ --copyright-holder "Natural Capital Alliance" \ - --output src/natcap/invest/internationalization/messages.pot \ + --mapping src/natcap/invest/internationalization/babel_config.ini \ --output src/natcap/invest/internationalization/messages.pot \ src/ # update message catalog from template @@ -39,7 +39,7 @@ pybabel update \ --input-file src/natcap/invest/internationalization/messages.pot \ --output-file src/natcap/invest/internationalization/locales/$LL/LC_MESSAGES/messages.po ``` -This looks through the source code for strings wrapped in the `gettext(...)` function and writes them to the message catalog template. Then it updates the message catalog for the specificed language. New strings that don't yet have a translation will have an empty `msgstr` value. Previously translated messages that are no longer needed will be commented out but remain in the file. This will save translator time if they're needed again in the future. +This looks through the source code for strings wrapped in the `gettext(...)` function and writes them to the message catalog template. Then it updates the message catalog for the specified language. New strings that don't yet have a translation will have an empty `msgstr` value. Previously translated messages that are no longer needed will be commented out but remain in the file. This will save translator time if they're needed again in the future. 2. Check that the changes look correct, then commit: ``` diff --git a/src/natcap/invest/internationalization/babel_config.ini b/src/natcap/invest/internationalization/babel_config.ini new file mode 100644 index 0000000000..e05af5c6af --- /dev/null +++ b/src/natcap/invest/internationalization/babel_config.ini @@ -0,0 +1,9 @@ +# Extract messages from Python source files + +[python: **.py] + +# Extract messages from Jinja HTML templates + +[jinja2: **/templates/**.html] +ignore_tags = script,style +include_attrs = alt diff --git a/src/natcap/invest/reports/__init__.py b/src/natcap/invest/reports/__init__.py index bc0eba9c95..e2b66ea79e 100644 --- a/src/natcap/invest/reports/__init__.py +++ b/src/natcap/invest/reports/__init__.py @@ -7,12 +7,18 @@ import matplotlib import pandas +from natcap.invest import gettext + jinja_env = jinja2.Environment( loader=jinja2.PackageLoader('natcap.invest.reports', 'templates'), autoescape=jinja2.select_autoescape(), - undefined=jinja2.StrictUndefined + undefined=jinja2.StrictUndefined, + extensions=['jinja2.ext.i18n'], ) +jinja_env.install_gettext_callables( + gettext=gettext, ngettext=None, newstyle=True) + MATPLOTLIB_PARAMS = { 'backend': 'agg', # 'legend.fontsize': 'small', From d4a391f6c4bcd1532e29db629b0a222f7464dc5d Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 20 Mar 2026 16:45:48 -0600 Subject: [PATCH 02/18] Use current locale to set lang attr and populate UG URL in base template. --- src/natcap/invest/__init__.py | 5 +++++ src/natcap/invest/carbon/reporter.py | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/natcap/invest/__init__.py b/src/natcap/invest/__init__.py index d9fc14fd7e..22ec33e5c1 100644 --- a/src/natcap/invest/__init__.py +++ b/src/natcap/invest/__init__.py @@ -43,6 +43,11 @@ LOCALE_CODE = 'en' +def get_locale(): + """Get the current locale code.""" + return LOCALE_CODE + + def set_locale(locale_code): """Set the `gettext` attribute of natcap.invest. diff --git a/src/natcap/invest/carbon/reporter.py b/src/natcap/invest/carbon/reporter.py index 52e01df3bb..4b36f166fc 100644 --- a/src/natcap/invest/carbon/reporter.py +++ b/src/natcap/invest/carbon/reporter.py @@ -8,7 +8,7 @@ import pygeoprocessing from natcap.invest import __version__ -from natcap.invest import gettext +from natcap.invest import gettext, get_locale from natcap.invest.reports import jinja_env, raster_utils, report_constants from natcap.invest.reports.raster_utils import RasterDatatype, RasterPlotConfig from natcap.invest.spec import ModelSpec @@ -131,7 +131,7 @@ def report(file_registry: dict, args_dict: dict, model_spec: ModelSpec, raster_path=file_registry['c_storage_bas'], datatype=RasterDatatype.continuous, spec=model_spec.get_output('c_storage_bas'))] - + intermediate_raster_config_lists = [[ RasterPlotConfig( raster_path=file_registry[f'c_{pool_type}_bas'], @@ -178,7 +178,7 @@ def report(file_registry: dict, args_dict: dict, model_spec: ModelSpec, input_raster_config_list) input_raster_caption = raster_utils.caption_raster_list( input_raster_config_list) - + outputs_img_src = raster_utils.plot_and_base64_encode_rasters( output_raster_config_list) output_raster_caption = raster_utils.caption_raster_list( @@ -214,6 +214,7 @@ def report(file_registry: dict, args_dict: dict, model_spec: ModelSpec, with open(target_html_filepath, 'w', encoding='utf-8') as target_file: target_file.write(TEMPLATE.render( + locale=get_locale(), report_script=model_spec.reporter, invest_version=__version__, report_filepath=target_html_filepath, From efcdf19a5da1fefc239aeee94b6d212acc3e301a Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 20 Mar 2026 16:49:30 -0600 Subject: [PATCH 03/18] Reload model module before calling execute, to ensure text defined in model spec is localized before it is passed to the report. --- src/natcap/invest/cli.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/natcap/invest/cli.py b/src/natcap/invest/cli.py index 5d9caca4ee..ea89d51a24 100644 --- a/src/natcap/invest/cli.py +++ b/src/natcap/invest/cli.py @@ -8,7 +8,6 @@ import json import logging import multiprocessing -import os import pprint import sys import textwrap @@ -16,10 +15,7 @@ import natcap.invest from natcap.invest import datastack -from natcap.invest import set_locale -from natcap.invest import spec from natcap.invest import ui_server -from natcap.invest import utils from natcap.invest import models from pygeoprocessing.utils import GDALUseExceptions @@ -463,8 +459,13 @@ def main(user_args=None): target_model = models.model_id_to_pyname[args.model] model_module = importlib.import_module(name=target_model) - LOGGER.info('Imported target %s from %s', - model_module.__name__, model_module) + + LOGGER.info( + f'Imported target {model_module.__name__} from {model_module}') + + # Reload model module to ensure text defined in model spec is + # localized before it is passed to the report. + importlib.reload(model_module) # We're deliberately not validating here because the user # can just call ``invest validate `` to validate. From 805a2f1f1118eeed639c8c398005ccd8830a3078 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 20 Mar 2026 16:51:12 -0600 Subject: [PATCH 04/18] Wrap all report_constants-defined strings in functions to ensure they are localized on demand. --- src/natcap/invest/carbon/reporter.py | 4 +- src/natcap/invest/ndr/reporter.py | 3 +- src/natcap/invest/reports/report_constants.py | 48 ++++++++++++------- .../reports/sdr_ndr_report_generator.py | 4 +- src/natcap/invest/sdr/reporter.py | 3 +- 5 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/natcap/invest/carbon/reporter.py b/src/natcap/invest/carbon/reporter.py index 4b36f166fc..434d169fb7 100644 --- a/src/natcap/invest/carbon/reporter.py +++ b/src/natcap/invest/carbon/reporter.py @@ -231,10 +231,10 @@ def report(file_registry: dict, args_dict: dict, model_spec: ModelSpec, outputs_img_src=outputs_img_src, outputs_caption=output_raster_caption, intermediate_raster_sections=intermediate_raster_sections, - raster_group_caption=report_constants.RASTER_GROUP_CAPTION, + raster_group_caption=report_constants.raster_group_caption(), output_raster_stats_table=output_raster_stats_table, input_raster_stats_table=input_raster_stats_table, - stats_table_note=report_constants.STATS_TABLE_NOTE, + stats_table_note=report_constants.stats_table_note(), model_spec_outputs=model_spec.outputs, )) diff --git a/src/natcap/invest/ndr/reporter.py b/src/natcap/invest/ndr/reporter.py index 4b1441a275..8c76927c80 100644 --- a/src/natcap/invest/ndr/reporter.py +++ b/src/natcap/invest/ndr/reporter.py @@ -1,4 +1,3 @@ -from natcap.invest.reports import raster_utils from natcap.invest.reports import report_constants from natcap.invest.reports import sdr_ndr_report_generator from natcap.invest.reports.raster_utils import RasterDatatype, RasterPlotConfig @@ -110,7 +109,7 @@ def report(file_registry, args_dict, model_spec, target_html_filepath): raster_path=file_registry['stream'], datatype=RasterDatatype.binary_high_contrast, spec=model_spec.get_output('stream')) - stream_config.caption += report_constants.STREAM_CAPTION_APPENDIX + stream_config.caption += report_constants.stream_caption_appendix() intermediate_raster_plot_configs = [ masked_dem_config, what_drains_config, stream_config] diff --git a/src/natcap/invest/reports/report_constants.py b/src/natcap/invest/reports/report_constants.py index d8fa3e31dd..3f32d5d847 100644 --- a/src/natcap/invest/reports/report_constants.py +++ b/src/natcap/invest/reports/report_constants.py @@ -1,21 +1,35 @@ from natcap.invest import gettext -STATS_TABLE_NOTE = gettext( - '"Valid percent" indicates the percent of pixels that are not ' - 'nodata. Comparing "valid percent" values across rasters may help ' - 'you identify cases of unexpected nodata.' -) - -RASTER_GROUP_CAPTION = gettext( - 'If a plot title includes "resampled," that raster was resampled to ' - 'a lower resolution for rendering in this report. Full resolution ' - 'rasters are available in the output workspace.' -) - -STREAM_CAPTION_APPENDIX = gettext( - ' The stream network may look incomplete at this resolution, and ' - 'therefore it may be necessary to view the full-resolution raster ' - 'in GIS to assess its accuracy.' -) +# All `gettext`-wrapped strings defined in this module should be returned from +# a function, _not_ defined on the module object itself, since text defined at +# the module level is not localized unless/until the module is reloaded. +# Wrapping text strings in functions avoids the need to reload the module +# and ensures text is localized properly when it is needed. + + +def stats_table_note(): + return gettext( + '"Valid percent" indicates the percent of pixels that are not ' + 'nodata. Comparing "valid percent" values across rasters may help ' + 'you identify cases of unexpected nodata.' + ) + + +def raster_group_caption(): + """Get "pre-caption" note about raster resampling.""" + return gettext( + 'If a plot title includes "resampled," that raster was resampled to ' + 'a lower resolution for rendering in this report. Full resolution ' + 'rasters are available in the output workspace.' + ) + + +def stream_caption_appendix(): + return gettext( + ' The stream network may look incomplete at this resolution, and ' + 'therefore it may be necessary to view the full-resolution raster ' + 'in GIS to assess its accuracy.' + ) + TABLE_PAGINATION_THRESHOLD = 10 diff --git a/src/natcap/invest/reports/sdr_ndr_report_generator.py b/src/natcap/invest/reports/sdr_ndr_report_generator.py index 4057db96c3..971537b7ee 100644 --- a/src/natcap/invest/reports/sdr_ndr_report_generator.py +++ b/src/natcap/invest/reports/sdr_ndr_report_generator.py @@ -97,12 +97,12 @@ def report(file_registry, args_dict, model_spec, target_html_filepath, intermediate_outputs_heading=intermediate_outputs_heading, intermediate_outputs_img_src=intermediate_img_src, intermediate_outputs_caption=intermediates_caption, - raster_group_caption=report_constants.RASTER_GROUP_CAPTION, + raster_group_caption=report_constants.raster_group_caption(), ws_vector_table=ws_vector_table, ws_vector_totals_table=ws_vector_totals_table, output_raster_stats_table=output_raster_stats_table, input_raster_stats_table=input_raster_stats_table, - stats_table_note=report_constants.STATS_TABLE_NOTE, + stats_table_note=report_constants.stats_table_note(), model_spec_outputs=model_spec.outputs, )) diff --git a/src/natcap/invest/sdr/reporter.py b/src/natcap/invest/sdr/reporter.py index 5e318706b1..2e442a24ef 100644 --- a/src/natcap/invest/sdr/reporter.py +++ b/src/natcap/invest/sdr/reporter.py @@ -1,5 +1,4 @@ from natcap.invest.reports import sdr_ndr_report_generator -from natcap.invest.reports import raster_utils from natcap.invest.reports import report_constants from natcap.invest.reports.raster_utils import RasterDatatype from natcap.invest.reports.raster_utils import RasterTransform @@ -83,7 +82,7 @@ def report(file_registry, args_dict, model_spec, target_html_filepath): raster_path=file_registry['stream'], datatype=RasterDatatype.binary_high_contrast, spec=model_spec.get_output('stream')) - stream_config.caption += report_constants.STREAM_CAPTION_APPENDIX + stream_config.caption += report_constants.stream_caption_appendix() intermediate_raster_plot_configs = [ masked_dem_config, what_drains_config, stream_config] From 2442ae9368690dc6c702c3c78abb408fa5d97fd4 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 20 Mar 2026 16:53:19 -0600 Subject: [PATCH 05/18] Update base template w/ locale and trans/endtrans tags where needed. --- src/natcap/invest/reports/templates/base.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/natcap/invest/reports/templates/base.html b/src/natcap/invest/reports/templates/base.html index bcafea8766..15e5338b20 100644 --- a/src/natcap/invest/reports/templates/base.html +++ b/src/natcap/invest/reports/templates/base.html @@ -1,9 +1,9 @@ - + - {% block page_title %}InVEST Results: {{ model_name }}{% endblock page_title %} + {% block page_title %}{% trans %}InVEST Results: {{ model_name }}{% endtrans %}{% endblock page_title %} {% block styles %} {% include 'styles.html' %} {% endblock styles %} @@ -16,23 +16,27 @@ {% block content scoped %}

{{ self.page_title() }}

{{ model_description }}

+ {% trans %}

To learn more about the {{ model_name }} model, visit the InVEST User Guide (opens in new browser window).

+ {% endtrans %} {% endblock content %} {% block footer %}
+ {% trans %}

This report was generated by {{ report_script }}, InVEST version {{ invest_version }}.

It was saved to {{ report_filepath }} at {{ timestamp }}.

+ {% endtrans %}
{% endblock footer %} From 1e97509b7dde634adf5f9ee44e0a1d4780881c45 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 20 Mar 2026 17:25:29 -0600 Subject: [PATCH 06/18] Update partials and model-specific templates with gettext() or trans/endtrans tags where needed. --- .../invest/reports/templates/args-table.html | 4 ++-- src/natcap/invest/reports/templates/base.html | 2 +- .../invest/reports/templates/caption.html | 2 +- .../invest/reports/templates/metadata.html | 2 +- .../reports/templates/models/carbon.html | 24 +++++++++---------- .../models/coastal_vulnerability.html | 20 ++++++++-------- .../templates/models/sdr-ndr-report.html | 24 +++++++++---------- .../reports/templates/raster-plot-img.html | 2 +- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/natcap/invest/reports/templates/args-table.html b/src/natcap/invest/reports/templates/args-table.html index 95663dbfd5..a18bafd9a2 100644 --- a/src/natcap/invest/reports/templates/args-table.html +++ b/src/natcap/invest/reports/templates/args-table.html @@ -6,8 +6,8 @@ - - + + diff --git a/src/natcap/invest/reports/templates/base.html b/src/natcap/invest/reports/templates/base.html index 15e5338b20..7e33955652 100644 --- a/src/natcap/invest/reports/templates/base.html +++ b/src/natcap/invest/reports/templates/base.html @@ -1,5 +1,5 @@ - + diff --git a/src/natcap/invest/reports/templates/caption.html b/src/natcap/invest/reports/templates/caption.html index 890e406ead..2201df2243 100644 --- a/src/natcap/invest/reports/templates/caption.html +++ b/src/natcap/invest/reports/templates/caption.html @@ -30,7 +30,7 @@ {% endfor %} {% if source_list %}

- Sources: {{ source_list | join('; ') }} + {% trans %}Sources:{% endtrans %} {{ source_list | join('; ') }}

{% endif %} {% endif %} diff --git a/src/natcap/invest/reports/templates/metadata.html b/src/natcap/invest/reports/templates/metadata.html index 2eebef6c6b..fee0064b9d 100644 --- a/src/natcap/invest/reports/templates/metadata.html +++ b/src/natcap/invest/reports/templates/metadata.html @@ -10,7 +10,7 @@
{{ output.path }}
{{ output.about }}
{% if output.units is defined %} -
Units: {{ output.units }}
+
{% trans %}Units:{% endtrans %} {{ output.units }}
{% endif %} {% endfor %} diff --git a/src/natcap/invest/reports/templates/models/carbon.html b/src/natcap/invest/reports/templates/models/carbon.html index 7bb745ac3d..96707de41a 100644 --- a/src/natcap/invest/reports/templates/models/carbon.html +++ b/src/natcap/invest/reports/templates/models/carbon.html @@ -16,18 +16,18 @@ {% from 'raster-plot-img.html' import raster_plot_img %} {% from 'wide-table.html' import wide_table %} -

Results

+

{% trans %}Results{% endtrans %}

{{ accordion_section( - 'Aggregate Results', + gettext('Aggregate Results'), agg_results_table | safe )}} {{ accordion_section( - 'Primary Outputs', + gettext('Primary Outputs'), content_grid([ (caption(raster_group_caption, pre_caption=True), 100), - (raster_plot_img(outputs_img_src, 'Primary Outputs'), 100), + (raster_plot_img(outputs_img_src, gettext('Primary Outputs')), 100), (caption(outputs_caption, definition_list=True), 100) ]) )}} @@ -44,7 +44,7 @@

Results

{% endfor %} {{ accordion_section( - 'Output Raster Stats', + gettext('Output Raster Stats'), content_grid([ (stats_table_note, 100), (wide_table( @@ -54,27 +54,27 @@

Results

]) )}} -

Inputs

+

{% trans %}Inputs{% endtrans %}

{% if args_dict != None %} {{ accordion_section( - 'Arguments', + gettext('Arguments'), args_table(args_dict) )}} {% endif %} {{ accordion_section( - 'LULC Maps', + gettext('LULC Maps'), content_grid([ (caption(raster_group_caption, pre_caption=True), 100), (caption(lulc_pre_caption, pre_caption=True), 100), - (raster_plot_img(inputs_img_src, 'Raster Inputs'), 100), + (raster_plot_img(inputs_img_src, gettext('LULC Maps')), 100), (caption(inputs_caption, definition_list=True), 100) ]) )}} {{ accordion_section( - 'Input Raster Stats', + gettext('Input Raster Stats'), content_grid([ (stats_table_note, 100), (wide_table( @@ -84,11 +84,11 @@

Inputs

]) )}} -

Metadata

+

{% trans %}Metadata{% endtrans %}

{{ accordion_section( - 'Output Filenames and Descriptions', + gettext('Output Filenames and Descriptions'), list_metadata(model_spec_outputs), expanded=False ) diff --git a/src/natcap/invest/reports/templates/models/coastal_vulnerability.html b/src/natcap/invest/reports/templates/models/coastal_vulnerability.html index f795d67e00..ddde6c946a 100644 --- a/src/natcap/invest/reports/templates/models/coastal_vulnerability.html +++ b/src/natcap/invest/reports/templates/models/coastal_vulnerability.html @@ -7,9 +7,9 @@ {% from 'metadata.html' import list_metadata %} {% from 'caption.html' import caption %} -

Primary Outputs

+

{% trans %}Primary Outputs{% endtrans %}

{{ accordion_section( - 'Coastal exposure and the protective role of habitats', + gettext('Coastal exposure and the protective role of habitats'), content_grid([ (content_grid([ ('
', 100), @@ -27,9 +27,9 @@

Primary Outputs

]) ) }} -

Intermediate Outputs

+

{% trans %}Intermediate Outputs{% endtrans %}

{{ accordion_section( - 'Ranked exposure variables', + gettext('Ranked exposure variables'), content_grid([ ('
', 100), (caption(rank_vars_figure_caption, rank_vars_figure_source_list), 100) @@ -37,7 +37,7 @@

Intermediate Outputs

) }} {{ accordion_section( - 'Pre-ranked variables', + gettext('Pre-ranked variables'), content_grid([ ('
', 100), (caption(facetted_histograms_caption, facetted_histograms_source_list), 100) @@ -45,23 +45,23 @@

Intermediate Outputs

) }} {{ accordion_section( - 'Wave Exposure', + gettext('Wave Exposure'), content_grid([ ('
', 65), (caption(wave_energy_map_caption, wave_energy_map_source_list), 35) ]) ) }} -

Inputs

+

{% trans %}Inputs{% endtrans %}

{{ accordion_section( - 'Arguments', + gettext('Arguments'), args_table(args_dict), )}} -

Metadata

+

{% trans %}Metadata{% endtrans %}

{{ accordion_section( - 'Output Filenames and Descriptions', + gettext('Output Filenames and Descriptions'), list_metadata(model_spec_outputs), expanded=False ) diff --git a/src/natcap/invest/reports/templates/models/sdr-ndr-report.html b/src/natcap/invest/reports/templates/models/sdr-ndr-report.html index adc574ed45..581111b2ac 100644 --- a/src/natcap/invest/reports/templates/models/sdr-ndr-report.html +++ b/src/natcap/invest/reports/templates/models/sdr-ndr-report.html @@ -16,7 +16,7 @@ {% from 'raster-plot-img.html' import raster_plot_img %} {% from 'wide-table.html' import wide_table %} -

Results

+

{% trans %}Results{% endtrans %}

{% if ws_vector_totals_table is defined and ws_vector_totals_table != None %} {% set watershed_results = content_grid([ @@ -37,15 +37,15 @@

Results

{% endif %} {{ accordion_section( - 'Results by Watershed', + gettext('Results by Watershed'), watershed_results )}} {{ accordion_section( - 'Primary Outputs', + gettext('Primary Outputs'), content_grid([ (caption(raster_group_caption, pre_caption=True), 100), - (raster_plot_img(outputs_img_src, 'Primary Outputs'), 100), + (raster_plot_img(outputs_img_src, gettext('Primary Outputs')), 100), (caption(outputs_caption, definition_list=True), 100) ]) )}} @@ -60,7 +60,7 @@

Results

)}} {{ accordion_section( - 'Output Raster Stats', + gettext('Output Raster Stats'), content_grid([ (stats_table_note, 100), (wide_table( @@ -70,24 +70,24 @@

Results

]) )}} -

Inputs

+

{% trans %}Inputs{% endtrans %}

{{ accordion_section( - 'Arguments', + gettext('Arguments'), args_table(args_dict) )}} {{ accordion_section( - 'Raster Inputs', + gettext('Raster Inputs'), content_grid([ (caption(raster_group_caption, pre_caption=True), 100), - (raster_plot_img(inputs_img_src, 'Raster Inputs'), 100), + (raster_plot_img(inputs_img_src, gettext('Raster Inputs')), 100), (caption(inputs_caption, definition_list=True), 100) ]) )}} {{ accordion_section( - 'Input Raster Stats', + gettext('Input Raster Stats'), content_grid([ (stats_table_note, 100), (wide_table( @@ -97,11 +97,11 @@

Inputs

]) )}} -

Metadata

+

{% trans %}Metadata{% endtrans %}

{{ accordion_section( - 'Output Filenames and Descriptions', + gettext('Output Filenames and Descriptions'), list_metadata(model_spec_outputs), expanded=False ) diff --git a/src/natcap/invest/reports/templates/raster-plot-img.html b/src/natcap/invest/reports/templates/raster-plot-img.html index 6f228b577b..372ab85582 100644 --- a/src/natcap/invest/reports/templates/raster-plot-img.html +++ b/src/natcap/invest/reports/templates/raster-plot-img.html @@ -1,6 +1,6 @@ {% macro raster_plot_img(img_src, img_name) -%} Raster plots: {{ img_name }} {%- endmacro %} From d816eb5d007d7a84a48850891e9e1b73f8538d3f Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 20 Mar 2026 17:29:50 -0600 Subject: [PATCH 07/18] Update CV, SDR/NDR reporters with current locale. --- src/natcap/invest/coastal_vulnerability/reporter.py | 3 ++- src/natcap/invest/reports/sdr_ndr_report_generator.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/natcap/invest/coastal_vulnerability/reporter.py b/src/natcap/invest/coastal_vulnerability/reporter.py index 2c36f8622a..c1b1ad8dbc 100644 --- a/src/natcap/invest/coastal_vulnerability/reporter.py +++ b/src/natcap/invest/coastal_vulnerability/reporter.py @@ -7,7 +7,7 @@ import pandas from natcap.invest import __version__ -from natcap.invest import gettext +from natcap.invest import gettext, get_locale import natcap.invest.spec from natcap.invest.reports import jinja_env @@ -396,6 +396,7 @@ def report(file_registry, args_dict, model_spec, target_html_filepath): with open(target_html_filepath, 'w', encoding='utf-8') as target_file: target_file.write(TEMPLATE.render( + locale=get_locale(), report_script=model_spec.reporter, invest_version=__version__, report_filepath=target_html_filepath, diff --git a/src/natcap/invest/reports/sdr_ndr_report_generator.py b/src/natcap/invest/reports/sdr_ndr_report_generator.py index 971537b7ee..56382e465b 100644 --- a/src/natcap/invest/reports/sdr_ndr_report_generator.py +++ b/src/natcap/invest/reports/sdr_ndr_report_generator.py @@ -5,7 +5,7 @@ import time from natcap.invest import __version__ -from natcap.invest import gettext +from natcap.invest import gettext, get_locale from natcap.invest.reports import ( jinja_env, report_constants, sdr_ndr_utils, raster_utils) @@ -81,6 +81,7 @@ def report(file_registry, args_dict, model_spec, target_html_filepath, with open(target_html_filepath, 'w', encoding='utf-8') as target_file: target_file.write(TEMPLATE.render( + locale=get_locale(), report_script=model_spec.reporter, invest_version=__version__, report_filepath=target_html_filepath, From 0caf99c47b9cc7711220ae0e1cf5be0c0e3b0775 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 11:07:49 -0600 Subject: [PATCH 08/18] Update report template tests. --- tests/reports/test_carbon_template.py | 2 ++ tests/reports/test_coastal_vulnerability.py | 1 + tests/reports/test_sdr_ndr_template.py | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/tests/reports/test_carbon_template.py b/tests/reports/test_carbon_template.py index 68e4504be3..f31d7464a3 100644 --- a/tests/reports/test_carbon_template.py +++ b/tests/reports/test_carbon_template.py @@ -12,6 +12,7 @@ def _get_render_args(model_spec): + locale = 'en' report_filepath = 'carbon_report_test.html' invest_version = '987.65.0' timestamp = '1970-01-01' @@ -28,6 +29,7 @@ def _get_render_args(model_spec): agg_results_table = '
NameValue{% trans %}Name{% endtrans %}{% trans %}Value{% endtrans %}
' return { + 'locale': locale, 'report_script': model_spec.reporter, 'invest_version': invest_version, 'report_filepath': report_filepath, diff --git a/tests/reports/test_coastal_vulnerability.py b/tests/reports/test_coastal_vulnerability.py index 34c67acc93..f3b9616b00 100644 --- a/tests/reports/test_coastal_vulnerability.py +++ b/tests/reports/test_coastal_vulnerability.py @@ -27,6 +27,7 @@ def test_template_render(self): rank_vars_figure_json = vegalite_json wave_energy_map_json = vegalite_json html = template.render( + locale='en', report_script=__file__, invest_version='987.65.0', report_filepath='coastal_vulnerability_report_test.html', diff --git a/tests/reports/test_sdr_ndr_template.py b/tests/reports/test_sdr_ndr_template.py index 6f52e4e2b3..5e0a91f568 100644 --- a/tests/reports/test_sdr_ndr_template.py +++ b/tests/reports/test_sdr_ndr_template.py @@ -10,6 +10,7 @@ def _get_render_args(model_spec): + locale = 'en', report_filepath = 'sdr_ndr_report_test.html' invest_version = '987.65.0' timestamp = '1970-01-01' @@ -27,6 +28,7 @@ def _get_render_args(model_spec): raster_group_caption = 'This is another test!' return { + 'locale': locale, 'report_script': model_spec.reporter, 'invest_version': invest_version, 'report_filepath': report_filepath, @@ -91,6 +93,7 @@ def test_watershed_results_totals(self): ws_vector_totals_table = '
' html = TEMPLATE.render( + locale='en', report_script='natcap.invest.test.reporter', invest_version='987.65.0', report_filepath='sdr_ndr_report_test.html', @@ -130,6 +133,7 @@ def test_watershed_results_without_totals(self): ws_vector_totals_table = None html = TEMPLATE.render( + locale='en', report_script='natcap.invest.test.reporter', invest_version='987.65.0', report_filepath='sdr_ndr_report_test.html', From ae4051607eee4692fff6354607fd6f13527fbf49 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 11:49:49 -0600 Subject: [PATCH 09/18] Misc report translation-related cleanup. --- src/natcap/invest/internationalization/README.md | 3 ++- src/natcap/invest/spec.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/natcap/invest/internationalization/README.md b/src/natcap/invest/internationalization/README.md index 5f65815cf4..6e03cfca11 100644 --- a/src/natcap/invest/internationalization/README.md +++ b/src/natcap/invest/internationalization/README.md @@ -30,7 +30,8 @@ pybabel extract \ --version $(python -m setuptools_scm) \ --msgid-bugs-address natcap-software@lists.stanford.edu \ --copyright-holder "Natural Capital Alliance" \ - --mapping src/natcap/invest/internationalization/babel_config.ini \ --output src/natcap/invest/internationalization/messages.pot \ + --mapping src/natcap/invest/internationalization/babel_config.ini \ + --output src/natcap/invest/internationalization/messages.pot \ src/ # update message catalog from template diff --git a/src/natcap/invest/spec.py b/src/natcap/invest/spec.py index 1eb3102dfe..c069a78f22 100644 --- a/src/natcap/invest/spec.py +++ b/src/natcap/invest/spec.py @@ -31,10 +31,12 @@ from . import gettext from .unit_registry import u from . import validation_messages +import natcap.invest.reports LOGGER = logging.getLogger(__name__) + # accessing a file could take a long time if it's in a file streaming service # to prevent the UI from hanging due to slow validation, # set a timeout for these functions. @@ -2274,13 +2276,13 @@ def execute(self, args, create_logfile=False, log_level=logging.NOTSET, if generate_report: LOGGER.info('Generating report for results') - reporter_module = importlib.import_module(self.reporter) target_html_filepath = os.path.join( preprocessed_args['workspace_dir'], (f'{self.model_id}_report' f'{preprocessed_args.get("results_suffix", "")}.html')) with natcap.invest.reports.configure_libraries(): + reporter_module = importlib.import_module(self.reporter) reporter_module.report( registry, preprocessed_args, self, target_html_filepath) From 24bd47666e40fb80382fcd1550325d6050a79911 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 12:04:15 -0600 Subject: [PATCH 10/18] Add base template tests. --- tests/reports/test_base_template.py | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/reports/test_base_template.py diff --git a/tests/reports/test_base_template.py b/tests/reports/test_base_template.py new file mode 100644 index 0000000000..72d156e568 --- /dev/null +++ b/tests/reports/test_base_template.py @@ -0,0 +1,64 @@ +import re +import unittest + +from bs4 import BeautifulSoup + +from natcap.invest.reports import jinja_env + +TEMPLATE = jinja_env.get_template('base.html') + +BSOUP_HTML_PARSER = 'html.parser' + + +def _get_render_args(): + return { + 'locale': 'en', + 'model_name': 'Test Model', + 'model_description': 'This is a test of the base template.', + 'userguide_page': 'testmodel.html', + 'report_script': 'natcap.invest.test.reporter', + 'invest_version': '987.65.0', + 'report_filepath': 'test_report.html', + 'timestamp': '1970-01-01', + } + + +class BaseTemplateTests(unittest.TestCase): + """Unit tests for base template.""" + + def test_base_template_content(self): + """Test that rendered HTML includes key details passed to template.""" + + render_args = _get_render_args() + + html = TEMPLATE.render(render_args) + soup = BeautifulSoup(html, BSOUP_HTML_PARSER) + + self.assertIn(render_args['model_name'], soup.title.string) + self.assertIn(render_args['model_name'], soup.h1.string) + + self.assertIsNotNone(soup.main.find( + string=render_args['model_description'])) + + ug_link = soup.find('a') + self.assertIn(render_args['userguide_page'], ug_link['href']) + + for arg in ['report_script', 'invest_version', + 'report_filepath', 'timestamp']: + # self.assertIn(render_args[arg], soup.footer.string) + self.assertIsNotNone( + soup.footer.find(string=re.compile(render_args[arg]))) + + def test_locale(self): + """Test that locale is set on ``lang`` attribute and in UG URL.""" + + render_args = _get_render_args() + render_args['locale'] = 'es' + + html = TEMPLATE.render(render_args) + soup = BeautifulSoup(html, BSOUP_HTML_PARSER) + + self.assertEqual(soup.html['lang'], 'es') + + ug_link = soup.find('a') + self.assertIn('/es/', ug_link['href']) From b883a30f323b9ee6b2a63e6e2e0604791e6797c0 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 12:23:42 -0600 Subject: [PATCH 11/18] Update i18n readme with reports-related details. --- src/natcap/invest/internationalization/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/natcap/invest/internationalization/README.md b/src/natcap/invest/internationalization/README.md index 6e03cfca11..949e12856c 100644 --- a/src/natcap/invest/internationalization/README.md +++ b/src/natcap/invest/internationalization/README.md @@ -6,6 +6,9 @@ None of the translations files (.pot, .po, .mo) should be manually edited by us. ### `messages.pot` Message catalog template file. This contains all the strings ("messages") that are translated, without any translations. All the PO files are derived from this. +### `babel_config.ini` +Mappings file that tells pybabel where to look when extracting messages into the message catalog. By default, pybabel will extract messages from Python source files; we need this mappings file to ensure it also extracts messages from the Jinja templates that are used for HTML reports. + ### `locales/` Locale directory. The contents of this directory are organized in a specific structure that `gettext` expects. `locales/` contains one subdirectory for each language for which there are any translations (not including the default English). The subdirectories are named after the corresponding ISO 639-1 language code. Each language subdirectory contains a directory `LC_MESSAGES`, which then contains the message catalog files for that language. @@ -70,7 +73,8 @@ Then follow the "Process to update translations" instructions above, starting fr * Model titles * `MODEL_SPEC` `name` and `about` text * Validation messages -* Strings that appear in the UI, such as button labels and tooltip text +* Strings that appear in the Workbench UI, such as button labels and tooltip text +* Strings that appear in HTML reports, such as section headings, figure captions, and table column headers We are not translating: From 7cb66d5f31b8e879a2b61d147d720df1b6583a11 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 13:31:21 -0600 Subject: [PATCH 12/18] Improve a checkbox label w/ context and singular/plural options. --- src/natcap/invest/coastal_vulnerability/reporter.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/natcap/invest/coastal_vulnerability/reporter.py b/src/natcap/invest/coastal_vulnerability/reporter.py index c1b1ad8dbc..9e07512ebf 100644 --- a/src/natcap/invest/coastal_vulnerability/reporter.py +++ b/src/natcap/invest/coastal_vulnerability/reporter.py @@ -222,9 +222,17 @@ def report(file_registry, args_dict, model_spec, target_html_filepath): na_count = exposure_geo.exposure.isna().sum() if na_count: + # There is some redundancy here to ensure the checkbox label is + # localized properly. Note that passing an f-string to gettext will not + # work; we must explicitly use .format() to populate variable values. + if na_count == 1: + null_cb_name = gettext( + '{na_count} point missing the exposure index. Show:') + else: + null_cb_name = gettext( + '{na_count} points missing the exposure index. Show:') null_checkbox = altair.binding_checkbox( - name=(f"{na_count} " - f"{gettext('point(s) missing the exposure index. Show:')}")) + name=(null_cb_name.format(na_count=na_count))) show_null = altair.param(value=False, bind=null_checkbox) null_points_chart = base_points.add_params( show_null From 0364f7c9e14195b502deea25d563e27be5188eb1 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 13:39:46 -0600 Subject: [PATCH 13/18] Adjust formatting of "Stream Network Maps" string to ensure correct message extraction. --- src/natcap/invest/reports/sdr_ndr_report_generator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/natcap/invest/reports/sdr_ndr_report_generator.py b/src/natcap/invest/reports/sdr_ndr_report_generator.py index 56382e465b..b7b2b77390 100644 --- a/src/natcap/invest/reports/sdr_ndr_report_generator.py +++ b/src/natcap/invest/reports/sdr_ndr_report_generator.py @@ -58,8 +58,11 @@ def report(file_registry, args_dict, model_spec, target_html_filepath, intermediate_raster_plot_configs) intermediates_caption = raster_utils.caption_raster_list( intermediate_raster_plot_configs) + # Note that passing an f-string to gettext will not work; + # we must explicitly use .format() to populate variable values. intermediate_outputs_heading = gettext( - f'Stream Network Maps (flow algorithm: {args_dict["flow_dir_algorithm"]})') + 'Stream Network Maps (flow algorithm: {flow_dir_algorithm})' + ).format(flow_dir_algorithm=args_dict["flow_dir_algorithm"].upper()) (ws_vector_table, ws_vector_totals_table) = ( sdr_ndr_utils.generate_results_table_from_vector( From ecf709f53345908ba262052efd7e6292625e46ff Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Mon, 23 Mar 2026 18:35:50 -0600 Subject: [PATCH 14/18] Reload model module only if model has a reporter. Update CLI tests so they'll still pass if/when CBC has a reporter. --- src/natcap/invest/cli.py | 10 ++++++---- tests/test_cli.py | 20 +++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/natcap/invest/cli.py b/src/natcap/invest/cli.py index ea89d51a24..744ecabea0 100644 --- a/src/natcap/invest/cli.py +++ b/src/natcap/invest/cli.py @@ -463,9 +463,11 @@ def main(user_args=None): LOGGER.info( f'Imported target {model_module.__name__} from {model_module}') - # Reload model module to ensure text defined in model spec is - # localized before it is passed to the report. - importlib.reload(model_module) + generate_report = bool(model_module.MODEL_SPEC.reporter) + if generate_report: + # Reload model module to ensure text defined in model spec is + # localized before it is passed to the report. + importlib.reload(model_module) # We're deliberately not validating here because the user # can just call ``invest validate `` to validate. @@ -480,7 +482,7 @@ def main(user_args=None): generate_metadata=True, save_file_registry=True, check_outputs=False, - generate_report=bool(model_module.MODEL_SPEC.reporter)) + generate_report=generate_report) if args.subcommand == 'serve': ui_server.app.run(port=args.port) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1f90811123..49e88b4738 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,9 +55,11 @@ def test_run_coastal_blue_carbon_workspace_in_json(self): parameter_set_file.write( json.dumps(datastack_dict, indent=4, sort_keys=True)) - with unittest.mock.patch( - 'natcap.invest.coastal_blue_carbon.coastal_blue_carbon.execute', - return_value=None) as patched_model: + target = ( + 'natcap.invest.coastal_blue_carbon.coastal_blue_carbon.execute') + + with (unittest.mock.patch(target, return_value=None) as patched_model, + unittest.mock.patch('importlib.reload')): cli.main([ 'run', 'coastal_blue_carbon', # uses an exact modelname @@ -72,9 +74,11 @@ def test_run_coastal_blue_carbon(self): os.path.dirname(__file__), '..', 'data', 'invest-test-data', 'coastal_blue_carbon', 'cbc_galveston_bay.invs.json') - with unittest.mock.patch( - 'natcap.invest.coastal_blue_carbon.coastal_blue_carbon.execute', - return_value=None) as patched_model: + target = ( + 'natcap.invest.coastal_blue_carbon.coastal_blue_carbon.execute') + + with (unittest.mock.patch(target, return_value=None) as patched_model, + unittest.mock.patch('importlib.reload')): cli.main([ 'run', 'coastal_blue_carbon', # uses an exact modelname @@ -193,7 +197,9 @@ def test_model_alias(self): target = ( 'natcap.invest.coastal_blue_carbon.coastal_blue_carbon.execute') - with unittest.mock.patch(target, return_value=None) as patched_model: + + with (unittest.mock.patch(target, return_value=None) as patched_model, + unittest.mock.patch('importlib.reload')): cli.main([ 'run', 'cbc', # uses an alias From 88ab86b343ec63676c05a5b59d5f90fba58e430b Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Wed, 25 Mar 2026 12:39:06 -0600 Subject: [PATCH 15/18] Remove commented-out code. --- tests/reports/test_base_template.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/reports/test_base_template.py b/tests/reports/test_base_template.py index 72d156e568..820387501c 100644 --- a/tests/reports/test_base_template.py +++ b/tests/reports/test_base_template.py @@ -45,7 +45,6 @@ def test_base_template_content(self): for arg in ['report_script', 'invest_version', 'report_filepath', 'timestamp']: - # self.assertIn(render_args[arg], soup.footer.string) self.assertIsNotNone( soup.footer.find(string=re.compile(render_args[arg]))) From 95da27ccb74516776e2bcac5a5032f3bbe0fdcad Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Wed, 25 Mar 2026 16:07:16 -0600 Subject: [PATCH 16/18] Remove errant comma. Co-authored-by: Claire Simpson <112011324+claire-simpson@users.noreply.github.com> --- tests/reports/test_sdr_ndr_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/reports/test_sdr_ndr_template.py b/tests/reports/test_sdr_ndr_template.py index 5e0a91f568..78293c29da 100644 --- a/tests/reports/test_sdr_ndr_template.py +++ b/tests/reports/test_sdr_ndr_template.py @@ -10,7 +10,7 @@ def _get_render_args(model_spec): - locale = 'en', + locale = 'en' report_filepath = 'sdr_ndr_report_test.html' invest_version = '987.65.0' timestamp = '1970-01-01' From b7c0e5d1e6d9b6a74f3e51ebecbc1f9e30a2034f Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 27 Mar 2026 17:25:58 -0600 Subject: [PATCH 17/18] Streamline imports in `spec.py`. --- src/natcap/invest/spec.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/natcap/invest/spec.py b/src/natcap/invest/spec.py index c069a78f22..d12abb260f 100644 --- a/src/natcap/invest/spec.py +++ b/src/natcap/invest/spec.py @@ -1,4 +1,4 @@ -import collections +import collections.abc import contextlib import copy import importlib @@ -17,7 +17,6 @@ from osgeo import ogr from osgeo import osr import geometamaker -import natcap.invest import pandas import pint import pygeoprocessing @@ -26,12 +25,10 @@ field_validator, model_validator import taskgraph +from natcap.invest import gettext, reports, utils, validation_messages +from natcap.invest import __version__ as invest_version from natcap.invest.file_registry import FileRegistry -from natcap.invest import utils -from . import gettext -from .unit_registry import u -from . import validation_messages -import natcap.invest.reports +from natcap.invest.unit_registry import u LOGGER = logging.getLogger(__name__) @@ -2093,7 +2090,7 @@ def generate_metadata_for_outputs(self, file_registry, args_dict): formatted_args = pprint.pformat(args_dict) lineage_statement = ( f'Created by {self.model_id} execute(' - f'\n{formatted_args})\nVersion {natcap.invest.__version__}') + f'\n{formatted_args})\nVersion {invest_version}') keywords = [self.model_id, 'InVEST'] def _generate_metadata(root_key, value): @@ -2281,7 +2278,7 @@ def execute(self, args, create_logfile=False, log_level=logging.NOTSET, (f'{self.model_id}_report' f'{preprocessed_args.get("results_suffix", "")}.html')) - with natcap.invest.reports.configure_libraries(): + with reports.configure_libraries(): reporter_module = importlib.import_module(self.reporter) reporter_module.report( registry, preprocessed_args, self, target_html_filepath) From 62e8ba2fe1265becc45696f04dd4e21f93d4c8c4 Mon Sep 17 00:00:00 2001 From: Emily Davis Date: Fri, 27 Mar 2026 17:31:07 -0600 Subject: [PATCH 18/18] Update i18n README with clarification about Workbench i18n. --- src/natcap/invest/internationalization/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/natcap/invest/internationalization/README.md b/src/natcap/invest/internationalization/README.md index 949e12856c..e4bdaa7910 100644 --- a/src/natcap/invest/internationalization/README.md +++ b/src/natcap/invest/internationalization/README.md @@ -73,9 +73,10 @@ Then follow the "Process to update translations" instructions above, starting fr * Model titles * `MODEL_SPEC` `name` and `about` text * Validation messages -* Strings that appear in the Workbench UI, such as button labels and tooltip text * Strings that appear in HTML reports, such as section headings, figure captions, and table column headers +Strings that appear exclusively in the Workbench UI, such as button labels and tooltip text, are also translated, but they are handled separately. See the [Workbench README](../../../../workbench/readme.md#internationalization) for details. + We are not translating: * "InVEST"