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.