diff --git a/.gitignore b/.gitignore index 1dbc687..6769e21 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -20,9 +19,12 @@ lib64/ parts/ sdist/ var/ +wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -37,13 +39,17 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py,cover .hypothesis/ +.pytest_cache/ +cover/ # Translations *.mo @@ -51,12 +57,104 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ # PyBuilder +.pybuilder/ target/ -#Ipython Notebook +# Jupyter Notebook .ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/README.md b/README.md index d8a672a..7750fe3 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,13 @@ - **Flexible**: `livejson` fully supports complex nestings of `list`s and `dict`s, meaning it can represent any valid JSON file. - **Compatible**: `livejson` works on all [versions](https://devguide.python.org/versions/) of Python that are not end-of-life. - **Lightweight**: `livejson` is a single file with no external dependencies. Just install and go! -- **Reliable**: by default, no caching is used. Every single time you access a `livejson.Database`, it's read straight from the file. And every time you write to it, the change is instant. No delays, no conflicts. However, if efficiency is important, you can use the context manager to perform "grouped writes", which allow for performing a large number of operations with only one write at the end. +- **Reliable**: by default, no caching is used. Every single time you access a `livejson.File`, it's read straight from the file. And every time you write to it, the change is instant. No delays, no conflicts. However, if efficiency is important, you can use the context manager to perform "grouped writes", which allow for performing a large number of operations with only one write at the end. - **100% test covered** Be confident that `livejson` is working properly `livejson` can be used for: - **Database storage**: you can use `livejson` to easily write flexible JSON databases, without having to worry about complex `open` and `close` operations, or learning how to use the `json` module. -- **Debugging**: You can use `livejson` to “back up” your Python objects to disk. If you use a `livejson.Database` instead of a `dict` or a `list` and your script crashes, you'll still have a hard copy of your object. And you barely had to change any of your code. +- **Debugging**: You can use `livejson` to “back up” your Python objects to disk. If you use a `livejson.File` instead of a `dict` or a `list` and your script crashes, you'll still have a hard copy of your object. And you barely had to change any of your code. - **General-purpose JSON**: If your script or application needs to interact with JSON files in any way, consider using `livejson`, for simplicity's sake. `livejson` can make your code easier to read and understand, and also save you time. Thanks to [dgelessus](https://github.com/dgelessus) for naming this project. diff --git a/livejson.py b/livejson/__init__.py similarity index 90% rename from livejson.py rename to livejson/__init__.py index 6e38e94..4b98b53 100644 --- a/livejson.py +++ b/livejson/__init__.py @@ -4,39 +4,36 @@ real-time. Magic. """ -import os -import json -import warnings +from __future__ import annotations -# Import from collections.abc for Python 3.x but incase of ImportError -# from Python 2.x, fall back on importing from collections. -try: - from collections.abc import ( - MutableMapping, - MutableSequence, - ) -except ImportError: - from collections import ( - MutableMapping, - MutableSequence, - ) +import enum +import json +import os +from collections.abc import MutableMapping, MutableSequence +from enum import Enum +from typing import Union, Any -warnings.filterwarnings("once", category=DeprecationWarning) +PathLike = Union[str, os.PathLike] # MISC HELPERS -def _initfile(path, data="dict"): +class _DataType(Enum): + List = enum.auto() + Dict = enum.auto() + + +def _initfile(path: PathLike, data_type: _DataType = _DataType.Dict) -> bool | None: # TODO: is return really neccessary? Not used anywhere """Initialize an empty JSON file.""" - data = {} if data.lower() == "dict" else [] + data: dict[Any, Any] | list[Any] = {} if data_type is _DataType.Dict else [] # The file will need to be created if it doesn't exist if not os.path.exists(path): # The file doesn't exist # Raise exception if the directory that should contain the file doesn't # exist dirname = os.path.dirname(path) if dirname and not os.path.exists(dirname): - raise IOError( + raise IOError( # TODO: better error (IOError is deprecated) ("Could not initialize empty JSON file in non-existant " "directory '{}'").format(os.path.dirname(path)) ) @@ -51,11 +48,12 @@ def _initfile(path, data="dict"): return False -class _ObjectBase(object): +class _ObjectBase: """Class inherited by most things. Implements the lowest common denominator for all emulating classes. """ + def __getitem__(self, key): out = self.data[key] @@ -99,6 +97,7 @@ class _NestedBase(_ObjectBase): object, and 'pathToThis' which specifies where in the JSON file this object exists (as a list). """ + def __init__(self, fileobj, pathToThis): self.pathInData = pathToThis self.base = fileobj @@ -151,6 +150,7 @@ class _NestedDict(_NestedBase, MutableMapping): to update the file. """ + def __iter__(self): return iter(self.data) @@ -176,6 +176,7 @@ class _NestedList(_NestedBase, MutableSequence): to update the file. """ + def insert(self, index, value): # See _NestedBase.__setitem__ for details on how this works data = self.base.data @@ -194,6 +195,7 @@ class _BaseFile(_ObjectBase): This implements all the required methods common between MutableMapping and MutableSequence.""" + def __init__(self, path, pretty=False, sort_keys=False): self.path = path self.pretty = pretty @@ -201,7 +203,7 @@ def __init__(self, path, pretty=False, sort_keys=False): self.indent = 2 # Default indentation level _initfile(self.path, - "list" if isinstance(self, ListFile) else "dict") + _DataType.List if isinstance(self, ListFile) else _DataType.Dict) def _data(self): """A simpler version of data to avoid infinite recursion in some cases. @@ -210,7 +212,7 @@ def _data(self): """ if self.is_caching: return self.cache - with open(self.path, "r") as f: + with open(self.path) as f: return json.load(f) @property @@ -266,15 +268,6 @@ def _updateType(self): # Bonus features! - def set_data(self, data): - """Equivalent to setting the "data" attribute. Exists for backwards - compatibility.""" - warnings.warn( - "set_data is deprecated; please set .data instead.", - DeprecationWarning - ) - self.data = data - def remove(self): """Delete the file from the disk completely.""" os.remove(self.path) @@ -282,7 +275,7 @@ def remove(self): @property def file_contents(self): """Get the raw file contents of the file.""" - with open(self.path, "r") as f: + with open(self.path) as f: return f.read() # Grouped writes @@ -312,6 +305,7 @@ class DictFile(_BaseFile, MutableMapping): """A class emulating Python's dict that will update a JSON file as it is modified. """ + def __iter__(self): return iter(self.data) @@ -328,6 +322,7 @@ class ListFile(_BaseFile, MutableSequence): modified. Use this class directly when creating a new file if you want the base object to be an array. """ + def insert(self, index, value): data = self.data data.insert(index, value) @@ -341,7 +336,7 @@ def clear(self): self.data = [] -class File(object): +class File: """The main interface of livejson. Emulates a list or a dict, updating a JSON file in real-time as it is modified. @@ -361,15 +356,15 @@ def __init__(self, path, pretty=False, sort_keys=True, indent=2): _initfile(self.path) - with open(self.path, "r") as f: + with open(self.path) as f: data = json.load(f) if isinstance(data, dict): self.__class__ = DictFile elif isinstance(data, list): self.__class__ = ListFile - @staticmethod - def with_data(path, data, *args, **kwargs): + @classmethod + def with_data(cls, path, data, *args, **kwargs): """Initialize a new file that starts out with some data. Pass data as a list, dict, or JSON string. """ @@ -384,13 +379,6 @@ def with_data(path, data, *args, **kwargs): "'livejson.File' instance if you really " "want to do this.") else: - f = File(path, *args, **kwargs) + f = cls(path, *args, **kwargs) f.data = data return f - - -# Aliases for backwards-compatibility -Database = File -ListDatabase = ListFile -DictDatabase = DictFile - diff --git a/livejson/py.typed b/livejson/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ee9d56d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=67.4.0", "setuptools-scm>=7.1.0"] # Require latest version +build-backend = "setuptools.build_meta" + +[project] +name = "livejson" +# No version needed; setuptools-scm extracts tag from git automatically (see `dynamic = ["version"]`) +authors = [ + {name = "Luke Taylor", email = "luke@deentaylor.com"}, +] +description = "Bind Python objects to JSON files" +readme = "README.md" +# TODO: add url/homepage: https://github.com/controversial/livejson/ +requires-python = ">=3.7" +keywords = ["livejson", "json", "io", "development", "file", "files", "live", "update"] +license = {file = "LICENCE"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Typing :: Typed", + "Topic :: Database", + "Topic :: System :: Monitoring", + "Topic :: Software Development :: Libraries :: Python Modules", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dynamic = ["version"] + +[tool.setuptools] +packages = ["livejson"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 8de0fbf..0000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -from setuptools import setup - -with open('README.md') as f: - readme = f.read() - -setup( - name="livejson", - py_modules=["livejson"], - version="1.9.1", - description="Bind Python objects to JSON files", - long_description=readme, - long_description_content_type="text/markdown", - keywords="livejson json io development file files live update", - license="MIT", - author="Luke Taylor", - author_email="luke@deentaylor.com", - url="https://github.com/controversial/livejson/", -) diff --git a/test.py b/test.py index 43757d3..031db22 100644 --- a/test.py +++ b/test.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import os import unittest import livejson +import pytest class _BaseTest(): @@ -13,104 +16,105 @@ def tearDown(self): os.remove(self.path) - class TestFile(_BaseTest, unittest.TestCase): """ Test the magical JSON file class """ + def test_DictFile(self): """ Test that 'livejson.File's in which the base object is a dict work as expected. This also tests all the methods shared between both types. """ # Test that a blank JSON file can be properly created f = livejson.File(self.path) - self.assertIsInstance(f, livejson.DictFile) # Test DictFile is default - self.assertTrue(os.path.exists(self.path)) - with open(self.path, "r") as fi: - self.assertEqual(fi.read(), "{}") + assert isinstance(f, livejson.DictFile) # Test DictFile is default + assert os.path.exists(self.path) + with open(self.path) as fi: + assert fi.read() == "{}" # Test writing to a file f["a"] = "b" # Test reading values from an existing file newInstance = livejson.DictFile(self.path).data # Tests explicit type - self.assertEqual(newInstance["a"], "b") + assert newInstance["a"] == "b" # Test deleting values f["c"] = "d" - self.assertIn("c", f) # This also conviently tests __contains__ + assert "c" in f # This also conveniently tests __contains__ del f["c"] - self.assertNotIn("c", f) + assert "c" not in f def test_ListFile(self): """ Test that Files in which the base object is an array work """ # Create the JSON file. f = livejson.ListFile(self.path) - self.assertEqual(f.data, []) + assert f.data == [] # Test append, extend, and insert f.append("dogs") f.extend(["cats", "penguins"]) f.insert(0, "turtles") - self.assertIsInstance(f.data, list) - self.assertEqual(f.data, ["turtles", "dogs", "cats", "penguins"]) + assert isinstance(f.data, list) + assert f.data == ["turtles", "dogs", "cats", "penguins"] # Test clear f.clear() - self.assertEqual(len(f), 0) + assert len(f) == 0 # Test creating a new ListFile automatically when file is an Array f2 = livejson.File(self.path) - self.assertIsInstance(f2, livejson.ListFile) + assert isinstance(f2, livejson.ListFile) def test_special_stuff(self): """ Test all the not-strictly-necessary extra API that I added """ f = livejson.File(self.path) f["a"] = "b" # Test 'data' (get a vanilla dict object) - self.assertEqual(f.data, {"a": "b"}) + assert f.data == {"a": "b"} # Test file_contents - self.assertEqual(f.file_contents, "{\"a\": \"b\"}") + assert f.file_contents == "{\"a\": \"b\"}" # Test __str__ and __repr__ - self.assertEqual(str(f), str(f.data)) - self.assertEqual(repr(f), repr(f.data)) + assert str(f) == str(f.data) + assert repr(f) == repr(f.data) # Test __iter__ - self.assertEqual(list(f), list(f.keys())) + assert list(f) == list(f.keys()) # Test remove() f.remove() - self.assertFalse(os.path.exists(self.path)) + assert not os.path.exists(self.path) def test_switchclass(self): """ Test that it can automatically switch classes """ # Test switching under normal usage f = livejson.File(self.path) - self.assertIsInstance(f, livejson.DictFile) - f.set_data([]) - self.assertIsInstance(f, livejson.ListFile) + assert isinstance(f, livejson.DictFile) + f.data = [] + assert isinstance(f, livejson.ListFile) # Test switching when the file is manually changed with open(self.path, "w") as fi: fi.write("{}") # This shouldn't error, it should change types when you do this f["dogs"] = "cats" - self.assertIsInstance(f, livejson.DictFile) + assert isinstance(f, livejson.DictFile) - def test_staticmethod_initalization(self): + def test_classmethod_initialization(self): """ Test initializing the File in special ways with custom - staticmethods """ + classmethods """ f = livejson.File.with_data(self.path, ["a", "b", "c"]) - self.assertEqual(f.data, ["a", "b", "c"]) + assert f.data == ["a", "b", "c"] # Test initialization from JSON string os.remove(self.path) f2 = livejson.File.with_data(self.path, "[\"a\", \"b\", \"c\"]") - self.assertEqual(len(f2), 3) + assert len(f2) == 3 def test_errors(self): """ Test the errors that are set up """ f = livejson.File(self.path) - # Test error for trying to initialize in non-existant directories - self.assertRaises(IOError, livejson.File, "a/b/c.py") + # Test error for trying to initialize in non-existent directories + with pytest.raises(IOError): + livejson.File("a/b/c.py") # Test error when trying to store non-string keys - with self.assertRaises(TypeError): + with pytest.raises(TypeError): f[True] = "test" # Test that storing numeric keys raises a more helpful error message - with self.assertRaisesRegexp(TypeError, "Try using a"): + with pytest.raises(TypeError, match="Try using a"): f[0] = "abc" # When initializing using with_data, test that an error is thrown if # the file already exists - with self.assertRaises(ValueError): + with pytest.raises(ValueError): livejson.File.with_data(self.path, {}) def test_empty_file(self): @@ -120,43 +124,42 @@ def test_empty_file(self): with open(self.path, "w") as fi: fi.write("") f = livejson.File(self.path) - self.assertEqual(f.data, {}) + assert f.data == {} # List files with open(self.path, "w") as fi: fi.write("") f = livejson.ListFile(self.path) - self.assertEqual(f.data, []) + assert f.data == [] def test_rollback(self): """ Test that data can be restored in the case of an error to prevent corruption (see #3)""" - class Test (object): + class Test: pass f = livejson.File(self.path) f["a"] = "b" - with self.assertRaises(TypeError): + with pytest.raises(TypeError): f["test"] = Test() - self.assertEqual(f.data, {"a": "b"}) + assert f.data == {"a": "b"} def test_json_formatting(self): """ Test the extra JSON formatting options """ # Test pretty formatting f = livejson.File(self.path, pretty=True) f["a"] = "b" - self.assertEqual(f.file_contents, '{\n "a": "b"\n}') + assert f.file_contents == '{\n "a": "b"\n}' f.indent = 4 - f.set_data(f.data) # Force an update - self.assertEqual(f.file_contents, '{\n "a": "b"\n}') + f.data = f.data # Force an update + assert f.file_contents == '{\n "a": "b"\n}' # Test sorting of keys f["b"] = "c" f["d"] = "e" f["c"] = "d" - self.assertTrue(f.file_contents.find("a") < - f.file_contents.find("b") < - f.file_contents.find("c") < + assert f.file_contents.find("a") < \ + f.file_contents.find("b") < \ + f.file_contents.find("c") < \ f.file_contents.find("d") - ) class TestNesting(_BaseTest, unittest.TestCase): @@ -165,14 +168,14 @@ def test_list_nesting(self): f = livejson.File(self.path) f["stored_data"] = {} f["stored_data"]["test"] = "value" - self.assertEqual(f.data, {"stored_data": {"test": "value"}}) + assert f.data == {"stored_data": {"test": "value"}} def test_dict_nesting(self): """ Test the nesting of dicts inside a livejson.File """ f = livejson.File(self.path) f["stored_data"] = [] f["stored_data"].append("test") - self.assertEqual(f.data, {"stored_data": ["test"]}) + assert f.data == {"stored_data": ["test"]} def test_multilevel_nesting(self): """ Test that you can nest stuff inside nested stuff :O """ @@ -180,32 +183,31 @@ def test_multilevel_nesting(self): f["stored_data"] = [] f["stored_data"].append({}) f["stored_data"][0]["colors"] = ["green", "purple"] - self.assertEqual(f.data, + assert f.data == \ {"stored_data": [{"colors": ["green", "purple"]}]} - ) def test_misc_methods(self): f = livejson.File(self.path) f["stored_data"] = [{"colors": ["green"]}] # Test that normal __getitem__ still works - self.assertEqual(f["stored_data"][0]["colors"][0], "green") + assert f["stored_data"][0]["colors"][0] == "green" # Test deleting values f["stored_data"][0]["colors"].pop(0) - self.assertEqual(len(f["stored_data"][0]["colors"]), 0) + assert len(f["stored_data"][0]["colors"]) == 0 # Test __iter__ on nested dict f["stored_data"] = {"a": "b", "c": "d"} - self.assertEqual(list(f["stored_data"]), - list(f["stored_data"].keys())) + assert list(f["stored_data"]) == \ + list(f["stored_data"].keys()) def test_errors(self): """ Test the errors that are thrown """ f = livejson.File(self.path) f["data"] = {} # Test that storing non-string keys in a nested dict throws an error - with self.assertRaises(TypeError): + with pytest.raises(TypeError): f["data"][True] = "test" # Test that storing numeric keys raises an additional error message - with self.assertRaisesRegexp(TypeError, "Try using a"): + with pytest.raises(TypeError, match="Try using a"): f["data"][0] = "abc" @@ -213,13 +215,14 @@ class TestGroupedWrites(_BaseTest, unittest.TestCase): """ Test using "grouped writes" with the context manager. These improve efficiency by only writing to the file once, at the end, instead of writing every change as it is made. """ + def test_basics(self): f = livejson.File(self.path) with f: f["a"] = "b" # Make sure that the write doesn't happen until we exit - self.assertEqual(f.file_contents, "{}") - self.assertEqual(f.file_contents, "{\"a\": \"b\"}") + assert f.file_contents == "{}" + assert f.file_contents == "{\"a\": \"b\"}" def test_with_existing_file(self): """ Test that the with block won't clear data """ @@ -227,63 +230,46 @@ def test_with_existing_file(self): f["a"] = "b" with f: f["c"] = "d" - self.assertIn("a", f) + assert "a" in f def test_lists(self): f = livejson.ListFile(self.path) with f: for i in range(10): f.append(i) - self.assertEqual(f.file_contents, "[]") - self.assertEqual(len(f), 10) + assert f.file_contents == "[]" + assert len(f) == 10 def test_switchclass(self): """ Test the switching of classes in the middle of a grouped write """ f = livejson.File(self.path) with f: - self.assertIsInstance(f, livejson.DictFile) - f.set_data([]) - self.assertIsInstance(f, livejson.ListFile) - self.assertEqual(f.file_contents, "{}") - self.assertEqual(f.file_contents, "[]") + assert isinstance(f, livejson.DictFile) + f.data = [] + assert isinstance(f, livejson.ListFile) + assert f.file_contents == "{}" + assert f.file_contents == "[]" def test_misc(self): """ Test miscellaneous other things that seem like they might break with a grouped write """ f = livejson.File(self.path) # Test is_caching, and test that data works with the cache - self.assertEqual(f.is_caching, False) + assert f.is_caching == False with f: - self.assertEqual(f.is_caching, True) + assert f.is_caching == True f["a"] = "b" # Test that data reflects the cache - self.assertEqual(f.data, {"a": "b"}) - self.assertEqual(f.is_caching, False) + assert f.data == {"a": "b"} + assert f.is_caching == False def test_fun_syntax(self): """ This is a fun bit of "syntactic sugar" enabled as a side effect of grouped writes. """ with livejson.File(self.path) as f: f["cats"] = "dogs" - with open(self.path, "r") as fi: - self.assertEqual(fi.read(), "{\"cats\": \"dogs\"}") - - -class TestAliases(_BaseTest, unittest.TestCase): - def test_Database(self): - db = livejson.Database(self.path) - self.assertTrue(os.path.exists(self.path)) - self.assertEqual(db.data, {}) - - def test_ListDatabase(self): - db = livejson.ListDatabase(self.path) - self.assertTrue(os.path.exists(self.path)) - self.assertEqual(db.data, []) - - def test_DictDatabase(self): - db = livejson.DictDatabase(self.path) - self.assertTrue(os.path.exists(self.path)) - self.assertEqual(db.data, {}) + with open(self.path) as fi: + assert fi.read() == "{\"cats\": \"dogs\"}" if __name__ == "__main__":