diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1313529b..841eae82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,10 @@ jobs: - run: python -m flake8 - run: python -m mypy fluent.syntax/fluent fluent.runtime/fluent test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-22.04, windows-2022] python-version: [3.7, 3.8, 3.9, "3.10", 3.11, 3.12, pypy3.9, pypy3.10] steps: - uses: actions/checkout@v4 diff --git a/fluent.runtime/tests/test_fallback.py b/fluent.runtime/tests/test_fallback.py index 0195281f..a02d1951 100644 --- a/fluent.runtime/tests/test_fallback.py +++ b/fluent.runtime/tests/test_fallback.py @@ -1,12 +1,8 @@ -import io -import os import unittest -from unittest import mock +from .utils import patch_files from fluent.runtime import FluentLocalization, FluentResourceLoader -ISFILE = os.path.isfile - class TestLocalization(unittest.TestCase): def test_init(self): @@ -15,21 +11,18 @@ def test_init(self): ) self.assertTrue(callable(l10n.format_value)) - @mock.patch("os.path.isfile") - @mock.patch("codecs.open") - def test_bundles(self, codecs_open, isfile): - data = { - "de/one.ftl": "one = in German", - "de/two.ftl": "two = in German", - "fr/two.ftl": "three = in French", - "en/one.ftl": "four = exists", - "en/two.ftl": "five = exists", - } - isfile.side_effect = lambda p: p in data or ISFILE(p) - codecs_open.side_effect = lambda p, _, __: io.StringIO(data[p]) + @patch_files({ + "de/one.ftl": "one = in German", + "de/two.ftl": "two = in German", + "fr/two.ftl": "three = in French", + "en/one.ftl": "four = exists", + "en/two.ftl": "five = exists", + }) + def test_bundles(self): l10n = FluentLocalization( ["de", "fr", "en"], ["one.ftl", "two.ftl"], FluentResourceLoader("{locale}") ) + # Curious bundles_gen = l10n._bundles() bundle_de = next(bundles_gen) self.assertEqual(bundle_de.locales[0], "de") @@ -49,38 +42,30 @@ def test_bundles(self, codecs_open, isfile): self.assertEqual(l10n.format_value("five"), "exists") -@mock.patch("os.path.isfile") -@mock.patch("codecs.open") class TestResourceLoader(unittest.TestCase): - def test_all_exist(self, codecs_open, isfile): - data = { - "en/one.ftl": "one = exists", - "en/two.ftl": "two = exists", - } - isfile.side_effect = lambda p: p in data - codecs_open.side_effect = lambda p, _, __: io.StringIO(data[p]) + @patch_files({ + "en/one.ftl": "one = exists", + "en/two.ftl": "two = exists", + }) + def test_all_exist(self): loader = FluentResourceLoader("{locale}") resources_list = list(loader.resources("en", ["one.ftl", "two.ftl"])) self.assertEqual(len(resources_list), 1) resources = resources_list[0] self.assertEqual(len(resources), 2) - def test_one_exists(self, codecs_open, isfile): - data = { - "en/two.ftl": "two = exists", - } - isfile.side_effect = lambda p: p in data - codecs_open.side_effect = lambda p, _, __: io.StringIO(data[p]) + @patch_files({ + "en/two.ftl": "two = exists", + }) + def test_one_exists(self): loader = FluentResourceLoader("{locale}") resources_list = list(loader.resources("en", ["one.ftl", "two.ftl"])) self.assertEqual(len(resources_list), 1) resources = resources_list[0] self.assertEqual(len(resources), 1) - def test_none_exist(self, codecs_open, isfile): - data = {} - isfile.side_effect = lambda p: p in data - codecs_open.side_effect = lambda p, _, __: io.StringIO(data[p]) + @patch_files({}) + def test_none_exist(self): loader = FluentResourceLoader("{locale}") resources_list = list(loader.resources("en", ["one.ftl", "two.ftl"])) self.assertEqual(len(resources_list), 0) diff --git a/fluent.runtime/tests/test_utils.py b/fluent.runtime/tests/test_utils.py new file mode 100644 index 00000000..32c0ab0e --- /dev/null +++ b/fluent.runtime/tests/test_utils.py @@ -0,0 +1,42 @@ +import unittest +from .utils import patch_files +import os +import codecs + + +class TestFileSimulate(unittest.TestCase): + def test_basic(self): + @patch_files({ + "the.txt": "The", + "en/one.txt": "One", + "en/two.txt": "Two" + }) + def patch_me(a, b): + self.assertEqual(a, 10) + self.assertEqual(b, "b") + self.assertFileIs(os.path.basename(__file__), None) + self.assertFileIs("the.txt", "The") + self.assertFileIs("en/one.txt", "One") + self.assertFileIs("en\\one.txt", "One") + self.assertFileIs("en/two.txt", "Two") + self.assertFileIs("en\\two.txt", "Two") + self.assertFileIs("en/three.txt", None) + self.assertFileIs("en\\three.txt", None) + + with self.assertRaises(ValueError): + os.path.isfile("en/") + patch_me(10, "b") + + def assertFileIs(self, filename, expect_contents): + """ + expect_contents is None: Expect file does not exist + expect_contents is a str: Expect file to exist and contents to match + """ + if expect_contents is None: + self.assertFalse(os.path.isfile(filename), + f"Expected {filename} to not exist.") + else: + self.assertTrue(os.path.isfile(filename), + f"Expected {filename} to exist.") + with codecs.open(filename, "r", "utf-8") as f: + self.assertEqual(f.read(), expect_contents) diff --git a/fluent.runtime/tests/utils.py b/fluent.runtime/tests/utils.py index 56301df6..fec04180 100644 --- a/fluent.runtime/tests/utils.py +++ b/fluent.runtime/tests/utils.py @@ -1,5 +1,50 @@ +"""Utilities for testing.""" + import textwrap +from pathlib import PureWindowsPath, PurePosixPath +from unittest import mock +from io import StringIO +import functools def dedent_ftl(text): return textwrap.dedent(f"{text.rstrip()}\n") + + +# Needed in test_falllback.py because it uses dict + string compare to make a virtual file structure +def _normalize_file_path(path): + """Note: Does not support absolute paths or paths that + contain '.' or '..' parts.""" + # Cannot use os.path or PurePath, because they only recognize + # one kind of path separator + if PureWindowsPath(path).is_absolute() or PurePosixPath(path).is_absolute(): + raise ValueError(f"Unsupported path: {path}") + parts = path.replace("\\", "/").split("/") + if "." in parts or ".." in parts: + raise ValueError(f"Unsupported path: {path}") + if parts and parts[-1] == "": + # path ends with a trailing pathsep + raise ValueError(f"Path appears to be a directory, not a file: {path}") + return "/".join(parts) + + +def patch_files(files: dict): + """Decorate a function to simulate files ``files`` during the function. + + The keys of ``files`` are file names and must use '/' for path separator. + The values are file contents. Directories or relative paths are not supported. + Example: ``{"en/one.txt": "One", "en/two.txt": "Two"}`` + + The implementation may be changed to match the mechanism used. + """ + + # Here it is possible to validate file names, but skipped + + def then(func): + @mock.patch("os.path.isfile", side_effect=lambda p: _normalize_file_path(p) in files) + @mock.patch("codecs.open", side_effect=lambda p, _, __: StringIO(files[_normalize_file_path(p)])) + @functools.wraps(func) # Make ret look like func to later decorators + def ret(*args, **kwargs): + func(*args[:-2], **kwargs) + return ret + return then