diff --git a/ipfshttpclient/client/__init__.py b/ipfshttpclient/client/__init__.py index 8b170103..3158ae62 100644 --- a/ipfshttpclient/client/__init__.py +++ b/ipfshttpclient/client/__init__.py @@ -23,6 +23,7 @@ from . import bootstrap from . import config #TODO: `from . import dag` +from . import dag from . import dht from . import files from . import key @@ -40,8 +41,8 @@ def assert_version(version: str, minimum: str = VERSION_MINIMUM, - maximum: str = VERSION_MAXIMUM, - blacklist: ty.Iterable[str] = VERSION_BLACKLIST) -> None: + maximum: str = VERSION_MAXIMUM, + blacklist: ty.Iterable[str] = VERSION_BLACKLIST) -> None: """Make sure that the given daemon version is supported by this client version. @@ -67,7 +68,7 @@ def assert_version(version: str, minimum: str = VERSION_MINIMUM, if minimum > version or version >= maximum: raise exceptions.VersionMismatch(version, minimum, maximum) - + for blacklisted in blacklist: blacklisted = list(map(int, blacklisted.split('-', 1)[0].split('.'))) if version == blacklisted: @@ -77,16 +78,16 @@ def assert_version(version: str, minimum: str = VERSION_MINIMUM, def connect( addr: http.addr_t = DEFAULT_ADDR, base: str = DEFAULT_BASE, *, - + chunk_size: int = multipart.default_chunk_size, offline: bool = False, session: bool = False, - + auth: http.auth_t = None, cookies: http.cookies_t = None, headers: http.headers_t = {}, timeout: http.timeout_t = 120, - + # Backward-compat username: ty.Optional[str] = None, password: ty.Optional[str] = None @@ -94,7 +95,7 @@ def connect( """Create a new :class:`~ipfshttpclient.Client` instance and connect to the daemon to validate that its version is supported as well as applying any known workarounds for the given daemon version - + Raises ------ ~ipfshttpclient.exceptions.VersionMismatch @@ -103,7 +104,7 @@ def connect( ~ipfshttpclient.exceptions.ProtocolError ~ipfshttpclient.exceptions.StatusError ~ipfshttpclient.exceptions.TimeoutError - + All parameters are identical to those passed to the constructor of the :class:`~ipfshttpclient.Client` class. """ @@ -114,39 +115,39 @@ def connect( auth=auth, cookies=cookies, headers=headers, timeout=timeout, username=username, password=password, ) - + # Query version number from daemon and validate it assert_version(client.apply_workarounds()["Version"]) - + return client class Client(files.Base, miscellaneous.Base): """The main IPFS HTTP client class - + Allows access to an IPFS daemon instance using its HTTP API by exposing an `IPFS Interface Core `__ compatible set of methods. - + It is possible to instantiate this class directly, using the same parameters as :func:`connect`, to prevent the client from checking for an active and compatible version of the daemon. In general however, calling :func:`connect` should be preferred. - + In order to reduce latency between individual API calls, this class may keep a pool of TCP connections between this client and the API daemon open between requests. The only caveat of this is that the client object should be closed when it is not used anymore to prevent resource leaks. - + The easiest way of using this “session management” facility is using a context manager:: - + with ipfshttpclient.connect() as client: print(client.version()) # These calls… print(client.version()) # …will reuse their TCP connection - + A client object may be re-opened several times:: - + client = ipfshttpclient.connect() print(client.version()) # Perform API call on separate TCP connection with client: @@ -155,26 +156,27 @@ class Client(files.Base, miscellaneous.Base): with client: print(client.version()) # These calls… print(client.version()) # …will share a different TCP connection - + When storing a long-running :class:`Client` object use it like this:: - + class Consumer: def __init__(self): self._client = ipfshttpclient.connect(session=True) - + # … other code … - + def close(self): # Call this when you're done self._client.close() """ - + # Fix up docstring so that Sphinx doesn't ignore the constructors parameter list __doc__ += "\n\n" + "\n".join(l[1:] for l in base.ClientBase.__init__.__doc__.split("\n")) - + bitswap = base.SectionProperty(bitswap.Section) block = base.SectionProperty(block.Section) bootstrap = base.SectionProperty(bootstrap.Section) config = base.SectionProperty(config.Section) + dag = base.SectionProperty(dag.Section) dht = base.SectionProperty(dht.Section) key = base.SectionProperty(key.Section) name = base.SectionProperty(name.Section) @@ -184,23 +186,23 @@ def close(self): # Call this when you're done repo = base.SectionProperty(repo.Section) swarm = base.SectionProperty(swarm.Section) unstable = base.SectionProperty(unstable.Section) - - + + ###################### # SESSION MANAGEMENT # ###################### - + def __enter__(self): self._client.open_session() return self - + def __exit__(self, exc_type, exc_value, traceback): self.close() - + def close(self): """Close any currently open client session and free any associated resources. - + If there was no session currently open this method does nothing. An open session is not a requirement for using a :class:`~ipfshttpclient.Client` object and as such all method defined on it will continue to work, but @@ -209,34 +211,34 @@ def close(self): in the future. See the class's description for details. """ self._client.close_session() - - + + ########### # HELPERS # ########### - + def apply_workarounds(self): """Query version information of the referenced daemon and enable any workarounds known for the corresponding version - + Returns ------- dict The version information returned by the daemon """ version_info = self.version() - + version = tuple(map(int, version_info["Version"].split('-', 1)[0].split('.'))) - + self._workarounds.clear() if version < (0, 5): # pragma: no cover (workaround) # Not really a workaround, but make use of HEAD requests on versions # that support them to speed things up if we are not interested in the # response anyways self._workarounds.add("use_http_head_for_no_result") - + return version_info - + @utils.return_field('Hash') @base.returns_single_item(dict) def add_bytes(self, data: bytes, **kwargs): @@ -261,7 +263,7 @@ def add_bytes(self, data: bytes, **kwargs): """ body, headers = multipart.stream_bytes(data, chunk_size=self.chunk_size) return self._client.request('/add', decoder='json', - data=body, headers=headers, **kwargs) + data=body, headers=headers, **kwargs) @utils.return_field('Hash') @base.returns_single_item(dict) @@ -287,7 +289,7 @@ def add_str(self, string, **kwargs): """ body, headers = multipart.stream_text(string, chunk_size=self.chunk_size) return self._client.request('/add', decoder='json', - data=body, headers=headers, **kwargs) + data=body, headers=headers, **kwargs) def add_json(self, json_obj, **kwargs): """Adds a json-serializable Python dict as a json file to IPFS. @@ -308,8 +310,8 @@ def add_json(self, json_obj, **kwargs): Hash of the added IPFS object """ return self.add_bytes(encoding.Json().encode(json_obj), **kwargs) - - + + @base.returns_single_item() def get_json(self, cid, **kwargs): """Loads a json object from IPFS. @@ -329,4 +331,4 @@ def get_json(self, cid, **kwargs): object Deserialized IPFS JSON object value """ - return self.cat(cid, decoder='json', **kwargs) \ No newline at end of file + return self.cat(cid, decoder='json', **kwargs) diff --git a/ipfshttpclient/client/dag.py b/ipfshttpclient/client/dag.py new file mode 100644 index 00000000..043d7944 --- /dev/null +++ b/ipfshttpclient/client/dag.py @@ -0,0 +1,149 @@ +import typing as ty + +from . import base + +from .. import multipart + + +class Section(base.SectionBase): + @base.returns_single_item(base.ResponseBase) + def get(self, cid: str, **kwargs: base.CommonArgs): + """Get and serialize the DAG node named by CID. + + .. code-block:: python + + >>> client.dag.get('QmTkzDwWqPbnAh5YiV5VwcTLnGdwSNsNTn2aDxdXBFca7D') + {'Data': '\x08\x01', + 'Links': [ + {'Hash': 'Qmd2xkBfEwEs9oMTk77A6jrsgurpF3ugXSg7dtPNFkcNMV', + 'Name': 'Makefile', 'Size': 174}, + {'Hash': 'QmeKozNssnkJ4NcyRidYgDY2jfRZqVEoRGfipkgath71bX', + 'Name': 'example', 'Size': 1474}, + {'Hash': 'QmZAL3oHMQYqsV61tGvoAVtQLs1WzRe1zkkamv9qxqnDuK', + 'Name': 'home', 'Size': 3947}, + {'Hash': 'QmZNPyKVriMsZwJSNXeQtVQSNU4v4KEKGUQaMT61LPahso', + 'Name': 'lib', 'Size': 268261}, + {'Hash': 'QmSY8RfVntt3VdxWppv9w5hWgNrE31uctgTiYwKir8eXJY', + 'Name': 'published-version', 'Size': 55} + ]} + + Parameters + ---------- + cid + Key of the object to retrieve, in CID format + + Returns + ------- + dict + Cid with the address of the dag object + """ + args = (str(cid),) + return self._client.request('/dag/get', args, decoder='json', **kwargs) + + @base.returns_single_item(base.ResponseBase) + def put(self, data: ty.IO, **kwargs: base.CommonArgs): + """Stores input as a DAG object and returns its key. + + .. code-block:: python + + >>> client.dag.put(io.BytesIO(b''' + ... { + ... "Data": "another", + ... "Links": [ { + ... "Name": "some link", + ... "Hash": "QmXg9Pp2ytZ14xgmQjYEiHjVjMFXzCV … R39V", + ... "Size": 8 + ... } ] + ... }''')) + {'Cid': { + '/': 'bafyreifgjgbmtykld2e3yncey3naek5xad3h4m2pxmo3of376qxh54qk34' + } + } + + Parameters + ---------- + data + IO stream object with data that should be put + + Returns + ------- + dict + Cid with the address of the dag object + """ + body, headers = multipart.stream_files(data, chunk_size=self.chunk_size) + return self._client.request('/dag/put', decoder='json', data=body, + headers=headers, **kwargs) + + @base.returns_single_item(base.ResponseBase) + def resolve(self, cid: str, **kwargs: base.CommonArgs): + """Resolves a DAG node from its Cid + + .. code-block:: python + + >>> client.dag.resolve('QmTkzDwWqPbnAh5YiV5VwcTLnGdwSNsNTn2aDxdXBFca7D') + {'Cid': { + '/': 'QmTkzDwWqPbnAh5YiV5VwcTLnGdwSNsNTn2aDxdXBFca7D' + } + } + + Parameters + ---------- + cid + Key of the object to resolve, in CID format + + Returns + ------- + dict + Cid with the address of the dag object + """ + args = (str(cid),) + return self._client.request('/dag/resolve', args, decoder='json', **kwargs) + + @base.returns_single_item(base.ResponseBase) + def imprt(self, data: ty.IO, **kwargs: base.CommonArgs): + """Imports a .car file with a DAG to IPFS + + .. code-block:: python + + >>> with open('data.car', 'rb') as file + ... client.dag.imprt(file) + {'Root': { + 'Cid': { + '/': 'bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya' + } + } + } + + Parameters + ---------- + data + IO stream object with data that should be imported + + Returns + ------- + dict + Dictionary with the root CID of the DAG imported + """ + body, headers = multipart.stream_files(data, chunk_size=self.chunk_size) + return self._client.request('/dag/import', decoder='json', data=body, + headers=headers, **kwargs) + + def export(self, cid: str, **kwargs: base.CommonArgs): + """Exports a DAG into a .car file format + + .. code-block:: python + + >>> bytes = client.dag.export('bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya') + + Parameters + ---------- + cid + Key of the object to export, in CID format + + Returns + ------- + bytes + DAG in a .car format + """ + args = (str(cid),) + return self._client.request('/dag/export', args, **kwargs) diff --git a/test/functional/fake_json/data.car b/test/functional/fake_json/data.car new file mode 100644 index 00000000..af2b2e41 Binary files /dev/null and b/test/functional/fake_json/data.car differ diff --git a/test/functional/test_dag.py b/test/functional/test_dag.py new file mode 100644 index 00000000..6345020c --- /dev/null +++ b/test/functional/test_dag.py @@ -0,0 +1,44 @@ +import io + +import conftest + + +def test_put_get_resolve(client): + data = io.BytesIO(br'{"links": []}') + response = client.dag.put(data) + + assert 'Cid' in response + assert '/' in response['Cid'] + assert response['Cid']['/'] == 'bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya' + + response = client.dag.get('bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya') + + assert 'links' in response + assert response['links'] == [] + + response = client.dag.resolve('bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya') + + assert 'Cid' in response + assert response['Cid']['/'] == 'bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya' + + +def test_import_export(client): + # This file was created by inserting a simple JSON object into IPFS and + # exporting it using `ipfs dag export > file.car` + data_car = conftest.TEST_DIR / 'fake_json' / 'data.car' + data_car = str(data_car) + + with open(data_car, 'rb') as file: + response = client.dag.imprt(file) + + assert 'Root' in response + assert 'Cid' in response['Root'] + assert '/' in response['Root']['Cid'] + + cid = response['Root']['Cid'] + assert cid['/'] == 'bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya' + + data = client.dag.export('bafyreidepjmjhvhlvp5eyxqpmyyi7rxwvl7wsglwai3cnvq63komq4tdya') + + with open(data_car, 'rb') as file: + assert data == file.read()