Skip to content

Commit

Permalink
Initital commit of once code.
Browse files Browse the repository at this point in the history
  • Loading branch information
aebrahim committed Sep 5, 2023
0 parents commit a00acc3
Show file tree
Hide file tree
Showing 10 changed files with 751 additions and 0 deletions.
31 changes: 31 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Python 3",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"github.vscode-github-actions"
]
}
}

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},

// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],

// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt",

// Configure tool-specific properties.
// "customizations": {},

// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

30 changes: 30 additions & 0 deletions .github/workflows/annotate_pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Annotate Pull Request

# In a separate workflow because of
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
on:
workflow_run:
workflows:
- Python test
types:
- completed

jobs:
python_test_reporter:
runs-on: ubuntu-latest
permissions:
checks: write
if: github.event.workflow_run.name == 'Python test'
strategy:
fail-fast: false
matrix:
os: [ubuntu-20.04, windows-2019, macos-11]
python-version: ["3.10", "3.11", "3.12-dev"]
steps:
- name: Test Report
uses: dorny/test-reporter@v1
with:
name: test-py${{ matrix.python-version}}-on-${{ matrix.os }}
reporter: java-junit
artifact: test-py${{ matrix.python-version}}-on-${{ matrix.os }}
path: test_py${{ matrix.python-version }}_on_${{ matrix.os }}.xml
55 changes: 55 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Release Wheels

on:
push:
release:
types:
- published

jobs:
build_wheel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.11
cache: pip
- run: pip wheel .
- uses: actions/upload-artifact@v3
with:
name: wheel
path: ./*.whl

upload_wheel_test:
needs: [build_wheel]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/once-py
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v3
with:
name: wheel
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

upload_wheel:
needs: [build_wheel]
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
environment:
name: pypi
url: https://pypi.org/p/once-py
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v3
with:
name: wheel
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
46 changes: 46 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Python test

on: [push, pull_request]

jobs:
test:
name: test-py${{ matrix.python-version}}-on-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-20.04, windows-2019, macos-11]
python-version: ["3.10", "3.11", "3.12-dev", "pypy3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install pytest
- run: pytest . --junitxml=junit/test_py${{ matrix.python-version }}_on_${{ matrix.os }}.xml
- name: Upload pytest test results
uses: actions/upload-artifact@v3
if: success() || failure()
with:
name: test-py${{ matrix.python-version}}-on-${{ matrix.os }}
path: junit/test_py${{ matrix.python-version }}_on_${{ matrix.os }}.xml

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: psf/black@stable

mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.11
cache: pip
- run: |
pip install mypy
mypy .
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
build/
dist/
*.egg-info
__pycache__
.DS_Store
*.whl
_version.py
.pytest_cache/
9 changes: 9 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright (c) 2023 Delfina Care Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Once

This library provides functionality to ensure a function is called exactly
once in Python, heavily inspired by `std::call_once`.

During initialization, we often want to ensure code is run **exactly once**.
But thinking about all the different ways this constraint can be violated can
be time-consuming and complex. We don't want to have to reason about what other
callers are doing and from which thread.

Introducing a simple solution - the `once.once` decorator! Simply decorate a
function with this decorator, and this library will handle all the edge cases
to ensure it is called exactly once! The first call will invoke the function,
and all subsequent calls will return the same result. Enough talking, let's
cut to an example:

```python
import once

@once.once
def my_expensive_object():
load_expensive_resource()
load_more_expensive_resources()
return ObjectSingletonUsingLotsOfMemory()

def caller_one():
my_expensive_object().use_it()

def caller_two_from_a_separate_thread():
my_expensive_object().use_it()

def optional_init_function_to_prewarm():
my_expensive_object()

```

This module is extremely simple, with no external dependencies, and heavily
tested for races.
157 changes: 157 additions & 0 deletions once.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Utility for initialization ensuring functions are called only once."""
import abc
import inspect
import functools
import threading
import weakref


def _new_lock() -> threading.Lock:
return threading.Lock()


def _is_method(func):
"""Determine if a function is a method on a class."""
if isinstance(func, (classmethod, staticmethod)):
return True
sig = inspect.signature(func)
return "self" in sig.parameters


class _OnceBase(abc.ABC):
"""Abstract Base Class for once function decorators."""

def __init__(self, func):
self._inspect_function(func)
functools.update_wrapper(self, func)
self.lock = _new_lock()
self.called = False
self.return_value = None
self.func = func

@abc.abstractmethod
def _inspect_function(self, func):
"""Inspect the passed-in function to ensure it can be wrapped.
This function should raise a SyntaxError if the passed-in function is
not suitable."""

def _execute_call_once(self, func, *args, **kwargs):
if self.called:
return self.return_value
with self.lock:
if self.called:
return self.return_value
self.return_value = func(*args, **kwargs)
self.called = True
return self.return_value


class once(_OnceBase): # pylint: disable=invalid-name
"""Decorator to ensure a function is only called once.
The restriction of only one call also holds across threads. However, this
restriction does not apply to unsuccessful function calls. If the function
raises an exception, the next call will invoke a new call to the function.
If the function is called with multiple arguments, it will still only be
called only once.
This decorator will fail for methods defined on a class. Use
once_per_class or once_per_instance for methods on a class instead.
Please note that because the value returned by the decorated function is
stored to return for subsequent calls, it will not be eligible for garbage
collection until after the decorated function itself has been deleted. For
module and class level functions (i.e. non-closures), this means the return
value will never be deleted.
"""

def _inspect_function(self, func):
if _is_method(func):
raise SyntaxError(
"Attempting to use @once.once decorator on method "
"instead of @once.once_per_class or @once.once_per_instance"
)

def __call__(self, *args, **kwargs):
return self._execute_call_once(self.func, *args, **kwargs)


class once_per_class(_OnceBase): # pylint: disable=invalid-name
"""A version of once for class methods which runs once across all instances."""

def _inspect_function(self, func):
if not _is_method(func):
raise SyntaxError(
"Attempting to use @once.once_per_class method-only decorator "
"instead of @once.once"
)

# This is needed for a decorator on a class method to return a
# bound version of the function to the object or class.
def __get__(self, obj, cls):
if isinstance(self.func, classmethod):
func = functools.partial(self.func.__func__, cls)
return functools.partial(self._execute_call_once, func)
if isinstance(self.func, staticmethod):
return functools.partial(self._execute_call_once, self.func)
return functools.partial(self._execute_call_once, self.func, obj)


class once_per_instance(_OnceBase): # pylint: disable=invalid-name
"""A version of once for class methods which runs once per instance."""

def __init__(self, func):
super().__init__(func)
self.return_value = weakref.WeakKeyDictionary()
self.inflight_lock = {}

def _inspect_function(self, func):
if isinstance(func, (classmethod, staticmethod)):
raise SyntaxError("Must use @once.once_per_class on classmethod and staticmethod")
if not _is_method(func):
raise SyntaxError(
"Attempting to use @once.once_per_instance method-only decorator "
"instead of @once.once"
)

# This is needed for a decorator on a class method to return a
# bound version of the function to the object.
def __get__(self, obj, cls):
del cls
return functools.partial(self._execute_call_once_per_instance, obj)

def _execute_call_once_per_instance(self, obj, *args, **kwargs):
# We only append to the call history, and do not overwrite or remove keys.
# Therefore, we can check the call history without a lock for an early
# exit.
# Another concern might be the weakref dictionary for return_value
# getting garbage collected without a lock. However, because
# user_function references whichever key it matches, it cannot be
# garbage collected during this call.
if obj in self.return_value:
return self.return_value[obj]
with self.lock:
if obj in self.return_value:
return self.return_value[obj]
if obj in self.inflight_lock:
inflight_lock = self.inflight_lock[obj]
else:
inflight_lock = _new_lock()
self.inflight_lock[obj] = inflight_lock
# Now we have a per-object lock. This means that we will not block
# other instances. In addition to better performance, this reduces the
# potential for deadlocks.
with inflight_lock:
if obj in self.return_value:
return self.return_value[obj]
result = self.func(obj, *args, **kwargs)
self.return_value[obj] = result
# At this point, any new call will find a cache hit before
# even grabbing a lock. It is now safe to clean up the inflight
# lock entry from the dictionary, as all subsequent will not need
# it. Any other previously called inflight requests already have
# their reference to the lock object, and do not need it present
# in this dict either.
self.inflight_lock.pop(obj)
return result
Loading

0 comments on commit a00acc3

Please sign in to comment.