Skip to main content

Allow passing a file-like object to write_js

ID
61f22a6
date
2025-01-10 11:17:52+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
f24f37a
message
Allow passing a file-like object to `write_js`
changed files
3 files, 190 additions, 31 deletions

Changed files

CHANGELOG.md (687) → CHANGELOG.md (1140)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7749189..3f57684 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
 # CHANGELOG
 
+## v1.1.0 - 2025-01-10
+
+You can now call `write_js()` with a file-like object.
+This can be text I/O or as binary I/O.
+
+This gives you more control over how the file is written -- for example, you can open the file in "exclusive creation" mode to prevent overwriting an existing file:
+
+```python
+with open("shape.js", "x") as out_file:
+    write_js(
+        out_file,
+        value={"sides": 5, "colour": "red"},
+        varname="redPentagon"
+    )
+```
+
 ## v1.0.1 - 2024-08-26
 
 When you call `append_to_js_array()` or `append_to_js_object()`, previously the new value would all be smushed onto one line.

src/javascript_data_files/__init__.py (5928) → src/javascript_data_files/__init__.py (6646)

diff --git a/src/javascript_data_files/__init__.py b/src/javascript_data_files/__init__.py
index 6f314c9..634cc05 100644
--- a/src/javascript_data_files/__init__.py
+++ b/src/javascript_data_files/__init__.py
@@ -11,6 +11,7 @@ Think of this like the JSON module, but for JavaScript files.
 
 """
 
+import io
 import json
 import pathlib
 import re
@@ -19,7 +20,7 @@ import typing
 import uuid
 
 
-__version__ = "1.0.0"
+__version__ = "1.1.0"
 
 
 def read_js(p: pathlib.Path | str, *, varname: str) -> typing.Any:
@@ -52,10 +53,27 @@ def read_js(p: pathlib.Path | str, *, varname: str) -> typing.Any:
     return json.loads(json_string)
 
 
-def write_js(p: pathlib.Path | str, *, value: typing.Any, varname: str) -> None:
+def _create_js_string(value: typing.Any, varname: str) -> str:
+    """
+    Create a JavaScript string to write to a file.
+    """
+    json_string = json.dumps(value, indent=2)
+    js_string = f"const {varname} = {json_string};\n"
+
+    return js_string
+
+
+def write_js(
+    p: pathlib.Path | str | io.TextIOBase | io.BufferedIOBase,
+    *,
+    value: typing.Any,
+    varname: str,
+) -> None:
     """
     Write a JavaScript "data file".
 
+    You can pass a path-like or file-like object as the first parameter ``p``.
+
     Example:
 
         >>> red_pentagon = {'sides': 5, 'colour': 'red'}
@@ -64,35 +82,41 @@ def write_js(p: pathlib.Path | str, *, value: typing.Any, varname: str) -> None:
         'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
 
     """
-    p = pathlib.Path(p)
-
-    if p.is_dir():
-        raise IsADirectoryError(p)
-
-    json_string = json.dumps(value, indent=2)
-    js_string = f"const {varname} = {json_string};\n"
-
-    p.parent.mkdir(exist_ok=True, parents=True)
-
-    # Write to a temporary file first, then rename this into place.
-    #
-    # This gives us pseudo-atomic writes -- it's probably not perfect, but
-    # it avoids situations where:
-    #
-    #   * Somebody tries to read the file, and it contains a partial JS string
-    #   * The write is interrupted, and the file is left empty
-    #
-    # Both of which have happened!  Because I often use this running on
-    # files on a semi-slow external hard drive, and sometimes things break.
-    #
-    # The UUID is probably overkill because it would be very unusual for
-    # me to have multiple, concurrent writes going on, but it doesn't hurt.
-    tmp_p = p.with_suffix(f".{uuid.uuid4()}.js.tmp")
-
-    with tmp_p.open("x") as out_file:
-        out_file.write(js_string)
-
-    tmp_p.rename(p)
+    js_string = _create_js_string(value, varname)
+
+    if isinstance(p, io.TextIOBase):
+        p.write(js_string)
+    elif isinstance(p, io.BufferedIOBase):
+        p.write(js_string.encode("utf8"))
+    elif isinstance(p, pathlib.Path) or isinstance(p, str):
+        p = pathlib.Path(p)
+
+        if p.is_dir():
+            raise IsADirectoryError(p)
+
+        p.parent.mkdir(exist_ok=True, parents=True)
+
+        # Write to a temporary file first, then rename this into place.
+        #
+        # This gives us pseudo-atomic writes -- it's probably not perfect, but
+        # it avoids situations where:
+        #
+        #   * Somebody tries to read the file, and it contains a partial JS string
+        #   * The write is interrupted, and the file is left empty
+        #
+        # Both of which have happened!  Because I often use this running on
+        # files on a semi-slow external hard drive, and sometimes things break.
+        #
+        # The UUID is probably overkill because it would be very unusual for
+        # me to have multiple, concurrent writes going on, but it doesn't hurt.
+        tmp_p = p.with_suffix(f".{uuid.uuid4()}.js.tmp")
+
+        with tmp_p.open("x") as out_file:
+            out_file.write(js_string)
+
+        tmp_p.rename(p)
+    else:
+        raise TypeError(f"Cannot write JavaScript to {type(p)}!")
 
 
 def append_to_js_array(p: pathlib.Path | str, *, value: typing.Any) -> None:

tests/test_javascript_data_files.py (11903) → tests/test_javascript_data_files.py (15597)

diff --git a/tests/test_javascript_data_files.py b/tests/test_javascript_data_files.py
index 75cf8ff..9a8b420 100644
--- a/tests/test_javascript_data_files.py
+++ b/tests/test_javascript_data_files.py
@@ -2,6 +2,7 @@
 Tests for the ``javascript`` module.
 """
 
+import io
 import pathlib
 import typing
 
@@ -112,6 +113,124 @@ class TestWriteJs:
             == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
         )
 
+    def test_can_write_to_str(self, tmp_path: pathlib.Path) -> None:
+        """
+        It can write to a path passed as a ``str``.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        js_path = tmp_path / "shape.js"
+
+        write_js(p=str(js_path), value=red_pentagon, varname="redPentagon")
+
+        assert (
+            js_path.read_text()
+            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+        )
+
+    def test_can_write_to_path(self, tmp_path: pathlib.Path) -> None:
+        """
+        It can write to a path passed as a ``pathlib.Path``.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        js_path = tmp_path / "shape.js"
+
+        write_js(js_path, value=red_pentagon, varname="redPentagon")
+
+        assert (
+            js_path.read_text()
+            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+        )
+
+    def test_can_write_to_file(self, tmp_path: pathlib.Path) -> None:
+        """
+        It can write to a file.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        js_path = tmp_path / "shape.js"
+
+        with open(js_path, "w") as out_file:
+            write_js(out_file, value=red_pentagon, varname="redPentagon")
+
+        assert (
+            js_path.read_text()
+            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+        )
+
+    def test_can_write_to_binary_file(self, tmp_path: pathlib.Path) -> None:
+        """
+        It can write to a file opened in binary mode.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        js_path = tmp_path / "shape.js"
+
+        with open(js_path, "wb") as out_file:
+            write_js(out_file, value=red_pentagon, varname="redPentagon")
+
+        assert (
+            js_path.read_text()
+            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+        )
+
+    def test_can_write_to_string_buffer(self) -> None:
+        """
+        It can write to a string buffer.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        string_buffer = io.StringIO()
+
+        write_js(string_buffer, value=red_pentagon, varname="redPentagon")
+
+        assert (
+            string_buffer.getvalue()
+            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+        )
+
+    def test_can_write_to_bytes_buffer(self) -> None:
+        """
+        It can write to a binary buffer.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        bytes_buffer = io.BytesIO()
+
+        write_js(bytes_buffer, value=red_pentagon, varname="redPentagon")
+
+        assert (
+            bytes_buffer.getvalue()
+            == b'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+        )
+
+    def test_fails_if_file_is_read_only(self, tmp_path: pathlib.Path) -> None:
+        """
+        It cannot write to a file open in read-only mode.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        js_path = tmp_path / "shape.js"
+        js_path.write_text("// empty file")
+
+        with pytest.raises(io.UnsupportedOperation):
+            with open(js_path, "r") as out_file:
+                write_js(out_file, value=red_pentagon, varname="redPentagon")
+
+    def test_throws_typeerror_if_cannot_write(self) -> None:
+        """
+        Writing to something that can't be written to throws a ``TypeError``.
+        """
+        red_pentagon = {"sides": 5, "colour": "red"}
+
+        with pytest.raises(TypeError):
+            write_js(
+                ["this", "is", "not", "a", "file"],  # type: ignore
+                value=red_pentagon,
+                varname="redPentagon",
+            )
+
     def test_fails_if_cannot_write_file(self) -> None:
         """
         Writing to the root folder throws an IsADirectoryError.