Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update project to work with Django 4 and a maintained IPFS library #2

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,3 @@ venv.bak/

# mypy
.mypy_cache/

26 changes: 26 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
repos:
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can remove this file but the use is a bunch of pre_commit hooks.
It requires having pre-commit but then just pre-commit install` to run the hooks before git commit.

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.2.3
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: flake8
- id: check-merge-conflict
- id: debug-statements
- id: no-commit-to-branch

- repo: https://github.com/asottile/seed-isort-config
rev: v1.9.2
hooks:
- id: seed-isort-config

- repo: https://github.com/ambv/black
rev: 23.1.0
hooks:
- id: black
language_version: python3.11

- repo: https://github.com/pre-commit/mirrors-isort
rev: v4.3.20
hooks:
- id: isort
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Mozilla Public License Version 2.0
means any form of the work other than Source Code Form.

1.7. "Larger Work"
means a work that combines Covered Software with other material, in
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.

1.8. "License"
Expand Down
31 changes: 15 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
django-ipfs-storage
===================

Store [Django file-uploads](https://docs.djangoproject.com/en/1.11/topics/files/)
Store [Django file-uploads](https://docs.djangoproject.com/en/4.0/topics/files/)
on the [Interplanetary File System](https://ipfs.io/).

Uploads are added and pinned to the configured IPFS node,
Expand All @@ -10,9 +10,9 @@ This hash is the name that is saved to your database.
Duplicate content will also have the same address,
saving disk space.

Because of this only file creation and reading is supported.
Because of this, only file creation and reading is supported.

Other IPFS users access and reseed a piece of content
Other IPFS users access and reseed a piece of content
through its unique content ID.
Differently-distributed (i.e. normal HTTP) users
can access the uploads through an HTTP→IPFS gateway.
Expand All @@ -24,7 +24,7 @@ Installation
```bash
pip install django-ipfs-storage
```

It uses the only Python maintained library for IPFS (as of March 2023) [IPFS-Toolkit](https://github.com/emendir/IPFS-Toolkit-Python)

Configuration
-------------
Expand All @@ -34,11 +34,8 @@ and returns URLs pointing to the public <https://ipfs.io/ipfs/> HTTP Gateway

To customise this, set the following variables in your `settings.py`:

- `IPFS_STORAGE_API_URL`: defaults to `'http://localhost:5001/api/v0/'`.
- `IPFS_GATEWAY_API_URL`: defaults to `'https://ipfs.io/ipfs/'`.

Set `IPFS_GATEWAY_API_URL` to `'http://localhost:8080/ipfs/'` to serve content
through your local daemon's HTTP gateway.
- `IPFS_STORAGE_API_URL`: defaults to `'/ip4/0.0.0.0/tcp/5001'`.
- `IPFS_GATEWAY_API_URL`: defaults to `'/ip4/0.0.0.0/tcp/8080'`.


Usage
Expand All @@ -55,9 +52,11 @@ Use IPFS as [Django's default file storage backend](https://docs.djangoproject.c

DEFAULT_FILE_STORAGE = 'ipfs_storage.InterPlanetaryFileSystemStorage'

IPFS_STORAGE_API_URL = 'http://localhost:5001/api/v0/'
IPFS_STORAGE_GATEWAY_URL = 'http://localhost:8080/ipfs/'
```
IPFS_STORAGE_API_URL = '/ip4/localhost/tcp/5001'

IPFS_STORAGE_GATEWAY_URL = '/ip4/localhost/tcp/8080'
IPFS_STORAGE_GATEWAY_API_URL = 'http://localhost:8080/ipfs'
```


### For a specific FileField
Expand All @@ -67,12 +66,12 @@ Alternatively, you may only want to use the IPFS storage backend for a single fi
```python
from django.db import models

from ipfs_storage import InterPlanetaryFileSystemStorage
from ipfs_storage import InterPlanetaryFileSystemStorage


class MyModel(models.Model):
# …
file_stored_on_ipfs = models.FileField(storage=InterPlanetaryFileSystemStorage())
file_stored_on_ipfs = models.FileField(storage=InterPlanetaryFileSystemStorage())
other_file = models.FileField() # will still use DEFAULT_FILE_STORAGE
```

Expand All @@ -84,7 +83,7 @@ FAQ

### Why IPFS?

Not my department. See <https://ipfs.io/#why>.
Not my department. See <https://ipfs.io/#why>.

### How do I ensure my uploads are always available?

Expand All @@ -99,7 +98,7 @@ See above.
### How do I delete an upload?

Because of the distributed nature of IPFS, anyone who accesses a piece
of content keeps a copy, and reseeds it for you automatically until it's
of content keeps a copy, and reseeds it for you automatically until it's
evicted from their node's local cache. Yay bandwidth costs! Boo censorship!

Unfortunately, if you're trying to censor yourself (often quite necessary),
Expand Down
83 changes: 1 addition & 82 deletions ipfs_storage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,82 +1 @@
from urllib.parse import urlparse

from django.conf import settings
from django.core.files.base import File, ContentFile
from django.core.files.storage import Storage
from django.utils.deconstruct import deconstructible
import ipfsapi


__version__ = '0.0.4'


@deconstructible
class InterPlanetaryFileSystemStorage(Storage):
"""IPFS Django storage backend.

Only file creation and reading is supported
due to the nature of the IPFS protocol.
"""

def __init__(self, api_url=None, gateway_url=None):
"""Connect to Interplanetary File System daemon API to add/pin files.

:param api_url: IPFS control API base URL.
Also configurable via `settings.IPFS_STORAGE_API_URL`.
Defaults to 'http://localhost:5001/api/v0/'.
:param gateway_url: Base URL for IPFS Gateway (for HTTP-only clients).
Also configurable via `settings.IPFS_STORAGE_GATEWAY_URL`.
Defaults to 'https://ipfs.io/ipfs/'.
"""
parsed_api_url = urlparse(api_url or getattr(settings, 'IPFS_STORAGE_API_URL', 'http://localhost:5001/api/v0/'))
self._ipfs_client = ipfsapi.connect(
parsed_api_url.hostname,
parsed_api_url.port,
parsed_api_url.path.strip('/')
)
self.gateway_url = gateway_url or getattr(settings, 'IPFS_STORAGE_GATEWAY_URL', 'https://ipfs.io/ipfs/')

def _open(self, name: str, mode='rb') -> File:
"""Retrieve the file content identified by multihash.

:param name: IPFS Content ID multihash.
:param mode: Ignored. The returned File instance is read-only.
"""
return ContentFile(self._ipfs_client.cat(name), name=name)

def _save(self, name: str, content: File) -> str:
"""Add and pin content to IPFS daemon.

:param name: Ignored. Provided to comply with `Storage` interface.
:param content: Django File instance to save.
:return: IPFS Content ID multihash.
"""
multihash = self._ipfs_client.add_bytes(content.__iter__())
self._ipfs_client.pin_add(multihash)
return multihash

def get_valid_name(self, name):
"""Returns name. Only provided for compatibility with Storage interface."""
return name

def get_available_name(self, name, max_length=None):
"""Returns name. Only provided for compatibility with Storage interface."""
return name

def size(self, name: str) -> int:
"""Total size, in bytes, of IPFS content with multihash `name`."""
return self._ipfs_client.object_stat(name)['CumulativeSize']

def delete(self, name: str):
"""Unpin IPFS content from the daemon."""
self._ipfs_client.pin_rm(name)

def url(self, name: str):
"""Returns an HTTP-accessible Gateway URL by default.

Override this if you want direct `ipfs://…` URLs or something.

:param name: IPFS Content ID multihash.
:return: HTTP URL to access the content via an IPFS HTTP Gateway.
"""
return '{gateway_url}{multihash}'.format(gateway_url=self.gateway_url, multihash=name)
from .storage import InterPlanetaryFileSystemStorage
68 changes: 68 additions & 0 deletions ipfs_storage/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from urllib.parse import urlparse

from ipfs_api import ipfshttpclient

from django.conf import settings
from django.core.files.base import File, ContentFile
from django.utils.deconstruct import deconstructible
from django.core.files.storage import Storage


@deconstructible
class InterPlanetaryFileSystemStorage(Storage):
"""IPFS Django storage backend.

Only file creation and reading is supported due to the nature of the IPFS protocol.
"""

def __init__(self, api_url=None, gateway_url=None):
"""Connect to Interplanetary File System daemon API to add/pin files."""
self._ipfs_client = ipfshttpclient.connect(settings.IPFS_STORAGE_API_URL)
self._ipfs_client.config.set(
"Addresses.Gateway", settings.IPFS_STORAGE_GATEWAY_URL
)

def _open(self, name: str, mode="rb") -> File:
"""Retrieve the file content identified by multihash.

:param name: IPFS Content ID multihash.
:param mode: Ignored. The returned File instance is read-only.
"""
return ContentFile(self._ipfs_client.cat(name), name=name)

def _save(self, name: str, content: File) -> str:
"""Add and pin content to IPFS daemon.

:param name: Ignored. Provided to comply with `Storage` interface.
:param content: Django File instance to save.
:return: IPFS Content ID multihash.
"""
multihash = self._ipfs_client.add_bytes(content.__iter__())
self._ipfs_client.pin.add(multihash)
return multihash

def get_valid_name(self, name):
"""Returns name. Only provided for compatibility with Storage interface."""
return name

def get_available_name(self, name, max_length=None):
"""Returns name. Only provided for compatibility with Storage interface."""
return name

def size(self, name: str) -> int:
"""Total size, in bytes, of IPFS content with multihash `name`."""
return self._ipfs_client.object.stat(name)["CumulativeSize"]

def delete(self, name: str):
"""Unpin IPFS content from the daemon."""
self._ipfs_client.pin.rm(name)

def url(self, name: str):
"""Returns an HTTP-accessible Gateway URL by default.

Override this if you want direct `ipfs://…` URLs or something.

:param name: IPFS Content ID multihash.
:return: HTTP URL to access the content via an IPFS HTTP Gateway.
"""
return f"{settings.IPFS_STORAGE_GATEWAY_API_URL}/ipfs/{name}"
16 changes: 16 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[flake8]
ignore = E203, E266, E501
max-line-length = 100
max-complexity = 18
select = B,C,E,F,W,T4,B9

[isort]
use_parentheses = True
multi_line_output = 3
length_sort = 1
lines_between_types = 0
known_django = django
known_third_party = ipfs_api,pytest,setuptools
sections = FUTURE, STDLIB, THIRDPARTY, DJANGO, FIRSTPARTY, LOCALFOLDER
no_lines_before = LOCALFOLDER
known_first_party = skatepedia,scraper
52 changes: 20 additions & 32 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,32 @@
from setuptools import setup, find_packages
from codecs import open

from ipfs_storage import __version__
import os

from setuptools import setup, find_packages

try:
import pypandoc
long_description = pypandoc.convert('README.md', 'rst')
except(IOError, ImportError):
with open('README.rst', encoding='utf-8') as f:
long_description = f.read()
HERE = os.path.dirname(os.path.abspath(__file__))
README = open(os.path.join(HERE, "README.md")).read()

__version__ = "0.1.0"

setup(
name='django-ipfs-storage',
description='IPFS storage backend for Django.',
long_description=long_description,
keywords='django ipfs storage',
name="django-ipfs-storage",
description="IPFS storage backend for Django.",
long_description=README,
keywords="django ipfs storage",
version=__version__,
license='MPL 2.0',

author='Ben Jeffrey',
author_email='[email protected]',
url='https://github.com/jeffbr13/django-ipfs-storage',

license="MPL 2.0",
author="Ben Jeffrey",
author_email="[email protected]",
url="https://github.com/skatepedia/django-ipfs-storage",
classifiers=(
'Development Status :: 3 - Alpha',
'Programming Language :: Python :: 3',
'Intended Audience :: Developers',
'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)',
'Framework :: Django',
"Programming Language :: Python :: 3",
"Intended Audience :: Developers",
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Framework :: Django",
),

packages=find_packages(),

install_requires=(
'django',
'ipfsapi',
"Django",
"IPFS-Toolkit",
),
setup_requires=(
'pypandoc',
)
test_requires=("pytest"),
)
Empty file added tests/__init__.py
Empty file.
27 changes: 27 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from unittest import mock

import pytest

from django.conf import settings


def pytest_configure():
settings.configure(
IPFS_STORAGE_API_URL="/ip4/0.0.0.0/tcp/5001",
IPFS_STORAGE_GATEWAY_URL="/ip4/0.0.0.0/tcp/8080",
IPFS_STORAGE_GATEWAY_API_URL="http://0.0.0.0:8080",
)


@pytest.fixture
def ipfs_client():
"""Return an ipfshttpclient.Client mock.
Used for instantation of :class:`ipfs_storage.InterPlanetaryFileSystemStorage`.
Introduce it in tests as a function argument `ipfs_client`.
"""
with (
mock.patch("ipfs_storage.storage.ipfshttpclient.connect") as ipfs_conn_mock,
mock.patch("ipfs_storage.storage.ipfshttpclient.Client") as client_mock,
):
ipfs_conn_mock.return_value = client_mock
yield client_mock
Loading