diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bccf635..375ae46 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,47 +1,67 @@ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -# TODO: -# - cache this directory $HOME/.cache/pyensembl/ -# - update coveralls -# - get a badge for tests passing -# - download binary dependencies from conda name: Tests -on: [push, pull_request] +on: + push: + branches: [master] + pull_request: jobs: - build: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: | + ruff check serializable tests + + - name: Check formatting with ruff + run: | + ruff format --check serializable tests + + test: runs-on: ubuntu-latest strategy: fail-fast: true matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" + - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest pytest-cov coveralls pylint - pip install -r requirements.txt - pip install . - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Run default linting script - run: | - ./lint.sh + pip install -e ".[dev]" + - name: Run unit tests run: | - ./test.sh - - name: Publish coverage to Coveralls - uses: coverallsapp/github-action@v2.2.3 + pytest tests/ -v --cov=serializable --cov-report=xml + + - name: Upload coverage to Coveralls + if: matrix.python-version == '3.11' + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage.xml diff --git a/.gitignore b/.gitignore index 72364f9..37fc9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ celerybeat-schedule .env # virtualenv +.venv/ venv/ ENV/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ad25914 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +## Repo Info + +- **Package**: `serializable` — base class with serialization helpers for user-defined Python objects. +- **GitHub**: https://github.com/openvax/serializable +- **PyPI**: https://pypi.org/project/serializable/ +- **Author**: Alex Rubinsteyn +- **Primary branch**: `master` + +--- + +## Golden Rules + +1. **Never commit to `master`.** Always `git checkout -b ` before editing. Land via PR. +2. **Every PR bumps the version.** Even doc-only PRs — at minimum a patch bump. Edit `serializable/version.py`. +3. **"Done" means merged AND deployed to PyPI** — never stop at merge. After a PR merges, run `./deploy.sh` from a clean `master`. Skipping deploy = task not done. +4. **File problems as issues, don't silently work around them.** If you hit a bug here or in a sibling openvax repo, open a GitHub issue on the correct repo and link it from the PR. +5. **After a PR ships, look for the next block of work.** Read open issues across relevant openvax repos, group by dependency + urgency. Prefer *foundational* changes that unblock multiple downstream improvements; otherwise chain the smallest independent improvements. + +--- + +## Before Completing Any Task + +Before considering any code change complete, you MUST: + +1. **Run `./format.sh`** — Auto-format all code +2. **Run `./lint.sh`** — Verify linting passes (runs both `ruff check` and `ruff format --check`) +3. **Run `./test.sh`** — Verify all tests pass + +Do not tell the user you are "done" or that changes are "complete" until all three of these pass. + +## Scripts + +- `./format.sh` — Formats code with ruff (run this first) +- `./lint.sh` — Checks linting and formatting (must pass). **Always use this for linting if it exists.** +- `./test.sh` — Runs pytest with coverage (must pass) +- `./deploy.sh` — Builds the distribution and uploads to PyPI via `twine` (gates on `lint.sh` and `test.sh`). **Always use this for deploying if it exists.** +- `./develop.sh` — Installs package in development mode into `.venv` + +## Code Style + +- Use ruff for formatting and linting +- Configuration is in `pyproject.toml` under `[tool.ruff]` +- Line length: 100 characters +- Target Python version: 3.9+ + +--- + +## Workflow Orchestration + +### 1. Upfront Planning +- For ANY non-trivial task (3+ steps or architectural decisions): write a detailed spec before touching code +- If something goes sideways, STOP and re-plan immediately — don't keep pushing +- Use planning/verification steps, not just building +- Write detailed specs upfront to reduce ambiguity + +### 2. Self-Improvement Loop +- After ANY correction from the user: update `tasks/lessons.md` with the pattern +- Write rules for yourself that prevent the same mistake +- Ruthlessly iterate on these lessons until mistake rate drops +- Review lessons at session start for relevant project + +### 3. Verification Before Done +- Never mark a task complete without proving it works +- Diff behavior between the latest code and your changes when relevant +- Ask yourself: "Would a staff engineer approve this?" +- Run tests, check logs, demonstrate correctness + +### 4. Demand Elegance (Balanced) +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" +- Skip this for simple, obvious fixes — don't over-engineer +- Challenge your own work before presenting it + +### 5. Autonomous Bug Fixing +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests — then resolve them +- Zero context switching required from the user +- Fix failing unit tests without being told how + +--- + +## Task Management + +1. **Plan First**: Write plan to `tasks/todo.md` with checkable items +2. **Verify Plan**: Check in before starting implementation +3. **Track Progress**: Mark items complete as you go +4. **Explain Changes**: High-level summary at each step +5. **Document Results**: Add review section to `tasks/todo.md` +6. **Capture Lessons**: Update `tasks/lessons.md` after corrections + +--- + +## Core Principles + +- **Simplicity First**: Make every change as simple as possible. Impact minimal code. +- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards. +- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d4d96a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# Project Instructions + +See @AGENTS.md for verification steps, workflow principles, and repo info. + +--- + +## Claude Code-Specific + +### Plan Mode +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- Use plan mode for verification steps, not just building + +### Subagent Strategy +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One task per subagent for focused execution diff --git a/deploy.sh b/deploy.sh index c1896d9..61a61a3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,7 +1,15 @@ -./lint.sh && \ -./test.sh && \ -python3 -m pip install --upgrade build && \ -python3 -m pip install --upgrade twine && \ -rm -rf dist && \ -python3 -m build && \ -python3 -m twine upload dist/* +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +./lint.sh +./test.sh + +PYTHON_BIN="${DEPLOY_PYTHON:-python3}" +"$PYTHON_BIN" -m pip install --upgrade build twine +rm -rf dist build ./*.egg-info +"$PYTHON_BIN" -m build +"$PYTHON_BIN" -m twine check dist/* +"$PYTHON_BIN" -m twine upload dist/* diff --git a/develop.sh b/develop.sh new file mode 100755 index 0000000..ac656c1 --- /dev/null +++ b/develop.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -e + +VENV_DIR=".venv" + +if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment at $VENV_DIR..." + python -m venv "$VENV_DIR" +fi + +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +# Check if UV is installed and available in the PATH +if command -v uv &> /dev/null; then + echo "Using uv to install package with development dependencies..." + uv pip install -e ".[dev]" +else + echo "uv not found, falling back to regular pip..." + pip install -e ".[dev]" +fi diff --git a/format.sh b/format.sh new file mode 100755 index 0000000..5ae8b59 --- /dev/null +++ b/format.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +SOURCES="serializable tests" + +echo "Running ruff format..." +ruff format $SOURCES + +echo "Formatting complete!" diff --git a/lint-and-test.sh b/lint-and-test.sh deleted file mode 100755 index 2a12451..0000000 --- a/lint-and-test.sh +++ /dev/null @@ -1 +0,0 @@ -./lint.sh && ./test.sh diff --git a/lint.sh b/lint.sh index e041d8e..8998792 100755 --- a/lint.sh +++ b/lint.sh @@ -1,12 +1,13 @@ -#!/bin/bash -set -o errexit +#!/usr/bin/env bash -# getting false positives due to this issue with pylint: -# https://bitbucket.org/logilab/pylint/issues/701/false-positives-with-not-an-iterable-and +set -e -find serializable -name '*.py' \ - | xargs pylint \ - --errors-only \ - --disable=unsubscriptable-object,not-an-iterable +SOURCES="serializable tests" -echo 'Passes pylint check' +echo "Running ruff check..." +ruff check $SOURCES + +echo "Running ruff format check..." +ruff format --check $SOURCES + +echo "All checks passed!" diff --git a/pylintrc b/pylintrc deleted file mode 100644 index b6ed285..0000000 --- a/pylintrc +++ /dev/null @@ -1,5 +0,0 @@ -[TYPECHECK] -# Without ignoring this, we get errors like: -# E:249,20: Module 'numpy' has no 'nan' member (no-member) -ignored-modules = numpy - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0959112 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "serializable" +dynamic = ["version"] +description = "Base class with serialization helpers for user-defined Python objects" +readme = "README.md" +license = {file = "LICENSE"} +authors = [ + {name = "Alex Rubinsteyn", email = "alex.rubinsteyn@unc.edu"} +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Operating System :: OS Independent", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +requires-python = ">=3.9" +dependencies = [ + "typechecks>=0.0.2", + "simplejson>=3.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "ruff", + "coveralls", + "build", + "twine", +] + +[project.urls] +Homepage = "https://github.com/openvax/serializable" +Repository = "https://github.com/openvax/serializable" + +[tool.setuptools.dynamic] +version = {attr = "serializable.version.__version__"} + +[tool.setuptools.packages.find] +include = ["serializable*"] + +[tool.ruff] +target-version = "py39" +line-length = 100 +src = ["serializable", "tests"] +exclude = [ + ".git", + ".venv", + "__pycache__", + "build", + "dist", + "*.egg-info", + ".eggs", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "SIM", # flake8-simplify + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults + "B905", # zip() without explicit strict + "SIM108", # use ternary operator instead of if-else + "UP007", # use X | Y for type unions (need Python 3.10+) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "B006", # allow mutable defaults in tests (exercises serialization behavior) + "B011", # allow assert False + "C405", # allow set([...]) literals used in tests + "C408", # allow dict(...) calls used in tests + "RUF012", # allow class-level test data +] +"__init__.py" = ["F401"] # allow unused imports in __init__.py + +[tool.ruff.lint.isort] +known-first-party = ["serializable"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_functions = "test_*" +addopts = "-v --tb=short" + +[tool.coverage.run] +source = ["serializable"] +omit = ["serializable/version.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 194d31e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pylint>2.0.0,<3.0.0 -typechecks >= 0.0.2,<1.0.0 -simplejson>=3.0.0,<4.0.0 diff --git a/serializable/__init__.py b/serializable/__init__.py index 175412e..6b0863f 100644 --- a/serializable/__init__.py +++ b/serializable/__init__.py @@ -11,22 +11,21 @@ # limitations under the License. -from .serializable import Serializable from .helpers import ( - to_serializable_repr, - from_serializable_repr, - to_json, from_json, + from_serializable_repr, to_dict, + to_json, + to_serializable_repr, ) - -__version__ = "0.4.1" +from .serializable import Serializable +from .version import __version__ __all__ = [ "Serializable", - "to_serializable_repr", - "from_serializable_repr", - "to_json", "from_json", + "from_serializable_repr", "to_dict", + "to_json", + "to_serializable_repr", ] diff --git a/serializable/helpers.py b/serializable/helpers.py index 9b2662f..289185c 100644 --- a/serializable/helpers.py +++ b/serializable/helpers.py @@ -14,12 +14,14 @@ Helper functions for deconstructing classes, functions, and user-defined objects into serializable types. """ -from types import FunctionType, BuiltinFunctionType + +from types import BuiltinFunctionType, FunctionType import simplejson as json from .primitive_types import return_primitive + def init_arg_names(obj): """ Names of arguments to __init__ method of this object's class. @@ -32,48 +34,51 @@ def init_arg_names(obj): except AttributeError: try: init_code = obj.__new__.__func__.__code__ - except AttributeError: + except AttributeError as exc: # if object is a namedtuple then we can return its fields # as the required initial args if hasattr(obj, "_fields"): return obj._fields - else: - raise ValueError("Cannot determine args to %s.__init__" % (obj,)) + raise ValueError(f"Cannot determine args to {obj}.__init__") from exc - arg_names = init_code.co_varnames[:init_code.co_argcount] + arg_names = init_code.co_varnames[: init_code.co_argcount] # drop self argument nonself_arg_names = arg_names[1:] return nonself_arg_names + def simple_object_to_dict(self): return {name: getattr(self, name) for name in init_arg_names(self)} -def _lookup_value(module_string, name, _cache={}): + +_MODULE_LOOKUP_CACHE: dict = {} + + +def _lookup_value(module_string, name): key = (module_string, name) - if key in _cache: - value = _cache[key] - else: - module_parts = module_string.split(".") - value = None - for i in range(1, len(module_parts) + 1): - try: - # try importing successively longer chains of - # sub-modules but break when we hit something that's - # not a module but actually data - qualified_name = ".".join(module_parts[:i]) - value = __import__( - qualified_name, - fromlist=module_parts[:i - 1]) - except ImportError: - break - - if value is None: - raise ImportError(module_parts[0]) - # once we've imported as much as we can, continue with getattr - # lookups - for attribute_name in module_parts[i:] + name.split("."): - value = getattr(value, attribute_name) - _cache[key] = value + if key in _MODULE_LOOKUP_CACHE: + return _MODULE_LOOKUP_CACHE[key] + + module_parts = module_string.split(".") + value = None + i = 0 + for i in range(1, len(module_parts) + 1): + try: + # try importing successively longer chains of + # sub-modules but break when we hit something that's + # not a module but actually data + qualified_name = ".".join(module_parts[:i]) + value = __import__(qualified_name, fromlist=module_parts[: i - 1]) + except ImportError: + break + + if value is None: + raise ImportError(module_parts[0]) + # once we've imported as much as we can, continue with getattr + # lookups + for attribute_name in module_parts[i:] + name.split("."): + value = getattr(value, attribute_name) + _MODULE_LOOKUP_CACHE[key] = value return value @@ -84,10 +89,12 @@ def class_from_serializable_representation(class_repr): """ return _lookup_value(class_repr["__module__"], class_repr["__name__"]) + def get_module_name(obj): module_name = obj.__module__ return module_name + def class_to_serializable_representation(cls): """ Given a class, return two strings: @@ -99,6 +106,7 @@ def class_to_serializable_representation(cls): """ return {"__module__": get_module_name(cls), "__name__": cls.__name__} + def function_from_serializable_representation(fn_repr): """ Given the name of a module and a function it contains, imports that module @@ -106,27 +114,28 @@ def function_from_serializable_representation(fn_repr): """ return _lookup_value(fn_repr["__module__"], fn_repr["__name__"]) + def function_to_serializable_representation(fn): """ Converts a Python function into a serializable representation. Does not currently work for methods or functions with closure data. """ if type(fn) not in (FunctionType, BuiltinFunctionType): - raise ValueError( - "Can't serialize %s : %s, must be globally defined function" % ( - fn, type(fn),)) + raise ValueError(f"Can't serialize {fn} : {type(fn)}, must be globally defined function") if hasattr(fn, "__closure__") and fn.__closure__ is not None: - raise ValueError("No serializable representation for closure %s" % (fn,)) + raise ValueError(f"No serializable representation for closure {fn}") return {"__module__": get_module_name(fn), "__name__": fn.__name__} + SERIALIZED_DICTIONARY_KEYS_FIELD = "__serialized_keys__" -SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX = ( - SERIALIZED_DICTIONARY_KEYS_FIELD + "element_") +SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX = SERIALIZED_DICTIONARY_KEYS_FIELD + "element_" + def index_to_serialized_key_name(index): - return "%s%d" % (SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX, index) + return f"{SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX}{index:d}" + def parse_serialized_keys_index(name): """ @@ -135,11 +144,12 @@ def parse_serialized_keys_index(name): """ if name.startswith(SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX): try: - return int(name[len(SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX):]) - except: + return int(name[len(SERIALIZED_DICTIONARY_KEYS_ELEMENT_PREFIX) :]) + except ValueError: pass return None + def dict_to_serializable_repr(x): """ Recursively convert values of dictionary to serializable representations. @@ -153,7 +163,7 @@ def dict_to_serializable_repr(x): # use the class of x rather just dict since we might want to convert # derived classes such as OrderedDict result = type(x)() - for (k, v) in x.items(): + for k, v in x.items(): if not isinstance(k, str): # JSON does not support using complex types such as tuples # or user-defined objects with implementations of __hash__ as @@ -174,6 +184,7 @@ def dict_to_serializable_repr(x): result[SERIALIZED_DICTIONARY_KEYS_FIELD] = serialized_key_list return result + def from_serializable_dict(x): """ Reconstruct a dictionary by recursively reconstructing all its keys and @@ -192,9 +203,7 @@ def from_serializable_dict(x): return _lookup_value(x.pop("__module__"), x.pop("__name__")) non_string_key_objects = [ - from_json(serialized_key) - for serialized_key - in x.pop(SERIALIZED_DICTIONARY_KEYS_FIELD, []) + from_json(serialized_key) for serialized_key in x.pop(SERIALIZED_DICTIONARY_KEYS_FIELD, []) ] converted_dict = type(x)() for k, v in x.items(): @@ -207,15 +216,16 @@ def from_serializable_dict(x): class_object = converted_dict.pop("__class__") if "__value__" in converted_dict: return class_object(converted_dict["__value__"]) - elif hasattr(class_object, "from_dict"): + if hasattr(class_object, "from_dict"): return class_object.from_dict(converted_dict) - else: - return class_object(**converted_dict) + return class_object(**converted_dict) return converted_dict + def list_to_serializable_repr(x): return [to_serializable_repr(element) for element in x] + def to_dict(obj): """ If value wasn't isn't a primitive scalar or collection then it needs to @@ -224,14 +234,13 @@ def to_dict(obj): """ if isinstance(obj, dict): return obj - elif hasattr(obj, "to_dict"): + if hasattr(obj, "to_dict"): return obj.to_dict() try: return simple_object_to_dict(obj) - except: - raise ValueError( - "Cannot convert %s : %s to dictionary" % ( - obj, type(obj))) + except Exception as exc: + raise ValueError(f"Cannot convert {obj} : {type(obj)} to dictionary") from exc + @return_primitive def to_serializable_repr(x): @@ -242,34 +251,31 @@ def to_serializable_repr(x): t = type(x) if isinstance(x, list): return list_to_serializable_repr(x) - elif t in (set, tuple): + if t in (set, tuple): return { "__class__": class_to_serializable_representation(t), - "__value__": list_to_serializable_repr(x) + "__value__": list_to_serializable_repr(x), } - elif isinstance(x, dict): + if isinstance(x, dict): return dict_to_serializable_repr(x) - elif isinstance(x, (FunctionType, BuiltinFunctionType)): + if isinstance(x, (FunctionType, BuiltinFunctionType)): return function_to_serializable_representation(x) - elif type(x) is type: + if type(x) is type: return class_to_serializable_representation(x) - else: - state_dictionary = to_serializable_repr(to_dict(x)) - state_dictionary["__class__"] = class_to_serializable_representation( - x.__class__) - return state_dictionary + state_dictionary = to_serializable_repr(to_dict(x)) + state_dictionary["__class__"] = class_to_serializable_representation(x.__class__) + return state_dictionary + @return_primitive def from_serializable_repr(x): t = type(x) if isinstance(x, list): return t([from_serializable_repr(element) for element in x]) - elif isinstance(x, dict): + if isinstance(x, dict): return from_serializable_dict(x) - else: - raise TypeError( - "Cannot convert %s : %s from serializable representation to object" % ( - x, type(x))) + raise TypeError(f"Cannot convert {x} : {type(x)} from serializable representation to object") + def to_json(x): """ @@ -278,5 +284,6 @@ def to_json(x): """ return json.dumps(to_serializable_repr(x)) + def from_json(json_string): return from_serializable_repr(json.loads(json_string)) diff --git a/serializable/primitive_types.py b/serializable/primitive_types.py index 280286d..257dcf9 100644 --- a/serializable/primitive_types.py +++ b/serializable/primitive_types.py @@ -14,20 +14,24 @@ A type is considered "primitive" if it is a non-collection type which can be serialized and deserialized successfully. """ + from functools import wraps NoneType = type(None) PRIMITIVE_TYPES = (bool, float, NoneType, str, int) + def return_primitive(fn): """ Decorator which wraps a single argument function to ignore any arguments of primitive type (simply returning them unmodified). """ + @wraps(fn) def wrapped_fn(x): if isinstance(x, PRIMITIVE_TYPES): return x return fn(x) + return wrapped_fn diff --git a/serializable/serializable.py b/serializable/serializable.py index 827d0c9..e961e21 100644 --- a/serializable/serializable.py +++ b/serializable/serializable.py @@ -10,18 +10,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import print_function, division, absolute_import +from typing import ClassVar from .helpers import ( - from_serializable_repr, - to_serializable_repr, - to_json, from_json, + from_serializable_repr, simple_object_to_dict, + to_json, + to_serializable_repr, ) -class Serializable(object): + +class Serializable: """ Base class for all PyEnsembl objects which provides default methods such as to_json, from_json, __reduce__, and from_dict @@ -32,9 +33,8 @@ class Serializable(object): """ def __str__(self): - return "%s(%s)" % ( - self.__class__.__name__, - ", ".join("%s=%s" % (k, v) for (k, v) in self.to_dict().items())) + fields = ", ".join(f"{k}={v}" for (k, v) in self.to_dict().items()) + return f"{self.__class__.__name__}({fields})" def __repr__(self): return str(self) @@ -70,7 +70,7 @@ def _reconstruct_nested_objects(cls, state_dict): # dictionary mapping old keywords to either new names or # None if the keyword has been removed from a class - _SERIALIZABLE_KEYWORD_ALIASES = {} + _SERIALIZABLE_KEYWORD_ALIASES: ClassVar[dict] = {} @classmethod def _update_kwargs(cls, kwargs): @@ -80,8 +80,8 @@ def _update_kwargs(cls, kwargs): # check every class in the inheritance chain for its own # definition of _KEYWORD_ALIASES for klass in cls.mro(): - keyword_rename_dict = getattr(klass, '_SERIALIZABLE_KEYWORD_ALIASES', {}) - for (old_name, new_name) in keyword_rename_dict.items(): + keyword_rename_dict = getattr(klass, "_SERIALIZABLE_KEYWORD_ALIASES", {}) + for old_name, new_name in keyword_rename_dict.items(): if old_name in kwargs: old_value = kwargs.pop(old_name) if new_name and new_name not in kwargs: @@ -123,7 +123,7 @@ def read_json_file(cls, path): """ Construct a VariantCollection from a JSON file. """ - with open(path, 'r') as f: + with open(path) as f: json_string = f.read() return cls.from_json(json_string) diff --git a/serializable/version.py b/serializable/version.py new file mode 100644 index 0000000..89fed8f --- /dev/null +++ b/serializable/version.py @@ -0,0 +1,9 @@ +__version__ = "1.0.0" + + +def print_version(): + print(f"v{__version__}") + + +if __name__ == "__main__": + print_version() diff --git a/setup.py b/setup.py index 2705335..034f013 100644 --- a/setup.py +++ b/setup.py @@ -1,62 +1,6 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import re - from setuptools import setup -current_directory = os.path.dirname(__file__) -readme_filename = 'README.md' -readme_path = os.path.join(current_directory, readme_filename) - -readme = "" -try: - with open(readme_path, 'r') as f: - readme = f.read() -except IOError as e: - print(e) - print("Failed to open %s" % readme_path) - -with open('serializable/__init__.py', 'r') as f: - version = re.search( - r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', - f.read(), - re.MULTILINE).group(1) - -if not version: - raise RuntimeError('Cannot find version information') -if __name__ == '__main__': - setup( - name='serializable', - version=version, - description="Base class with serialization helpers for user-defined Python objects", - author="Alex Rubinsteyn", - author_email="alex@openvax.org", - url="https://github.com/iskandr/serializable", - license="http://www.apache.org/licenses/LICENSE-2.0.html", - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Environment :: Console', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python', - ], - install_requires=[ - "typechecks>=0.0.2", - "simplejson", - ], - long_description=readme, - long_description_content_type='text/markdown', - packages=['serializable'], - ) +if __name__ == "__main__": + # Delegate metadata to pyproject.toml. + setup() diff --git a/test.sh b/test.sh index 7e13e6e..04d4f11 100755 --- a/test.sh +++ b/test.sh @@ -1 +1,2 @@ pytest --cov=serializable/ --cov-report=term-missing tests + diff --git a/tests/common.py b/tests/common.py index d0c5ae3..59d6725 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,8 +10,9 @@ # See the License for the specific language governing permissions and # limitations under the License. + def eq_(x, y, s=None): if s: assert x == y, s else: - assert x == y \ No newline at end of file + assert x == y diff --git a/tests/test_class.py b/tests/test_class.py index 3d253a5..10b2da3 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -11,15 +11,15 @@ # limitations under the License. -from serializable import ( - from_serializable_repr, - to_serializable_repr -) +from serializable import from_serializable_repr, to_serializable_repr + from .common import eq_ -class A(object): + +class A: pass + def test_serialize_custom_class(): A_reconstructed = from_serializable_repr(to_serializable_repr(A)) eq_(A, A_reconstructed) diff --git a/tests/test_default_to_dict_implementation.py b/tests/test_default_to_dict_implementation.py index bcebc3c..8fa422b 100644 --- a/tests/test_default_to_dict_implementation.py +++ b/tests/test_default_to_dict_implementation.py @@ -12,13 +12,16 @@ from serializable import Serializable + from .common import eq_ + class A(Serializable): def __init__(self, x, y=1): self.x = x self.y = y + def test_serializable_default_to_dict(): a = A(10, 1) eq_(a, A.from_dict(a.to_dict())) diff --git a/tests/test_function.py b/tests/test_function.py index 6d4204e..c768c5e 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -12,14 +12,17 @@ from serializable import ( - to_serializable_repr, from_serializable_repr, + to_serializable_repr, ) + from .common import eq_ + def global_fn(): pass + def test_serialize_custom_function(): fn_reconstructed = from_serializable_repr(to_serializable_repr(global_fn)) eq_(global_fn, fn_reconstructed) diff --git a/tests/test_keyword_aliases.py b/tests/test_keyword_aliases.py index 5f43b59..6bc1f8c 100644 --- a/tests/test_keyword_aliases.py +++ b/tests/test_keyword_aliases.py @@ -12,8 +12,10 @@ from serializable import Serializable + from .common import eq_ + class TestClassWithKeywordAliases(Serializable): _SERIALIZABLE_KEYWORD_ALIASES = {"old_x": "x", "old_gone": None} @@ -34,20 +36,22 @@ def __init__(self, x, y): self.x = x self.y = y + def test_normal_keywords(): # testing that nothing got screwed in the normal logic of object # serialization/deserialization by the addition of keyword aliases - obj1 = TestClassWithKeywordAliases.from_json( - TestClassWithKeywordAliases(x=1).to_json()) + obj1 = TestClassWithKeywordAliases.from_json(TestClassWithKeywordAliases(x=1).to_json()) eq_(obj1.x, 1) obj2 = DerivedClassWithMoreKeywordAliases.from_json( - DerivedClassWithMoreKeywordAliases(x=10, y=20).to_json()) + DerivedClassWithMoreKeywordAliases(x=10, y=20).to_json() + ) eq_(obj2.x, 10) eq_(obj2.y, 20) obj3 = DerivedClassWithoutMoreKeywordAliases.from_json( - DerivedClassWithoutMoreKeywordAliases(x=100, y=200).to_json()) + DerivedClassWithoutMoreKeywordAliases(x=100, y=200).to_json() + ) eq_(obj3.x, 100) eq_(obj3.y, 200) diff --git a/tests/test_namedtuple.py b/tests/test_namedtuple.py index f12100e..2f3d28b 100644 --- a/tests/test_namedtuple.py +++ b/tests/test_namedtuple.py @@ -11,12 +11,15 @@ # limitations under the License. from collections import namedtuple -from serializable import to_serializable_repr, from_serializable_repr + +from serializable import from_serializable_repr, to_serializable_repr + from .common import eq_ A = namedtuple("A", "x y") instance = A(1, 2) + def test_namedtuple_to_json(): eq_(instance, from_serializable_repr(to_serializable_repr(instance))) diff --git a/tests/test_nesting.py b/tests/test_nesting.py index 3dc3852..ae2ed29 100644 --- a/tests/test_nesting.py +++ b/tests/test_nesting.py @@ -1,6 +1,8 @@ -from serializable import to_serializable_repr, from_serializable_repr, Serializable +from serializable import Serializable, from_serializable_repr, to_serializable_repr + from .common import eq_ + class A(Serializable): def __init__(self, a_val): self.a = a_val @@ -8,6 +10,7 @@ def __init__(self, a_val): def to_dict(self): return {"a_val": self.a} + class B(Serializable): def __init__(self, set_a_vals=set([A(1), A(2)])): self.set_a_vals = set_a_vals @@ -20,18 +23,22 @@ def test_list_of_lists(): x = [[1, 2], ["hello", "wookies"], [A(1), A(2)]] eq_(x, from_serializable_repr(to_serializable_repr(x))) + def test_dict_of_lists(): x = {"a": [1, 2, 3], "b": ["hello", "goodbye"], "1": [A(1), A(2)]} eq_(x, from_serializable_repr(to_serializable_repr(x))) + def test_dict_with_tuple_keys(): x = {(1, 2): "hello"} eq_(x, from_serializable_repr(to_serializable_repr(x))) + def test_object_with_dict_values(): x = A(dict(snooze=5)) eq_(x, from_serializable_repr(to_serializable_repr(x))) + def test_object_with_set_values(): x = B() eq_(x, from_serializable_repr(to_serializable_repr(x))) diff --git a/tests/test_object.py b/tests/test_object.py index a1cccbd..3a73b0a 100644 --- a/tests/test_object.py +++ b/tests/test_object.py @@ -10,13 +10,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pickle + from serializable import ( + Serializable, from_serializable_repr, to_serializable_repr, - Serializable, ) + from .common import eq_ -import pickle + class A(Serializable): def __init__(self, x, y): @@ -26,15 +29,18 @@ def __init__(self, x, y): def to_dict(self): return {"x": self.x, "y": self.y} + instance = A(1, 2) + def test_serialize_object_with_helpers(): - instance_reconstructed = from_serializable_repr( - to_serializable_repr(instance)) + instance_reconstructed = from_serializable_repr(to_serializable_repr(instance)) eq_(instance, instance_reconstructed) + def test_object_to_json(): eq_(instance, A.from_json(instance.to_json())) + def test_object_pickle(): eq_(instance, pickle.loads(pickle.dumps(instance))) diff --git a/tests/test_primitive_values.py b/tests/test_primitive_values.py index f5f98dd..f44ce61 100644 --- a/tests/test_primitive_values.py +++ b/tests/test_primitive_values.py @@ -10,20 +10,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -from serializable import ( - from_serializable_repr, - to_serializable_repr -) +from serializable import from_serializable_repr, to_serializable_repr + from .common import eq_ + def test_int(): eq_(1, from_serializable_repr(to_serializable_repr(1))) + def test_float(): eq_(1.4, from_serializable_repr(to_serializable_repr(1.4))) + def test_bool(): eq_(False, from_serializable_repr(to_serializable_repr(False))) + def test_str(): eq_("waffles", from_serializable_repr(to_serializable_repr("waffles"))) diff --git a/tests/test_set.py b/tests/test_set.py index 1a15876..4312fd3 100644 --- a/tests/test_set.py +++ b/tests/test_set.py @@ -11,17 +11,20 @@ # limitations under the License. from serializable import ( - to_serializable_repr, + from_json, from_serializable_repr, to_json, - from_json, + to_serializable_repr, ) + from .common import eq_ + def test_tuple_to_serializable(): x = {1, 2.0, "wolves"} eq_(x, from_serializable_repr(to_serializable_repr(x))) + def test_tuple_to_json(): x = {1, 2.0, "wolves"} eq_(x, from_json(to_json(x))) diff --git a/tests/test_tuple.py b/tests/test_tuple.py index d93b6eb..49faa7d 100644 --- a/tests/test_tuple.py +++ b/tests/test_tuple.py @@ -11,17 +11,20 @@ # limitations under the License. from serializable import ( - to_serializable_repr, + from_json, from_serializable_repr, to_json, - from_json, + to_serializable_repr, ) + from .common import eq_ + def test_tuple_to_serializable(): x = (1, 2.0, "wolves") eq_(x, from_serializable_repr(to_serializable_repr(x))) + def test_tuple_to_json(): x = (1, 2.0, "wolves") eq_(x, from_json(to_json(x)))