diff --git a/mypy.ini b/mypy.ini --- a/mypy.ini +++ b/mypy.ini @@ -5,11 +5,20 @@ # 3rd party libraries without stubs (yet) +[mypy-aiosqlite.*] +ignore_missing_imports = True + +[mypy-daemon.*] +ignore_missing_imports = True + [mypy-pkg_resources.*] ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True -# [mypy-add_your_lib_here.*] -# ignore_missing_imports = True +[mypy-pyfuse3.*] +ignore_missing_imports = True + +[mypy-pyfuse3_asyncio.*] +ignore_missing_imports = True diff --git a/requirements-swh.txt b/requirements-swh.txt --- a/requirements-swh.txt +++ b/requirements-swh.txt @@ -1,4 +1,4 @@ # Add here internal Software Heritage dependencies, one per line. swh.core -swh.model -swh.web.client +swh.model>=0.7.0 +swh.web.client>=0.2.1 diff --git a/requirements-test.txt b/requirements-test.txt --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest requests # workaround for https://forge.softwareheritage.org/T2634 +requests-mock diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ +aiosqlite pyfuse3 +python-daemon diff --git a/swh/fuse/cache.py b/swh/fuse/cache.py new file mode 100644 --- /dev/null +++ b/swh/fuse/cache.py @@ -0,0 +1,138 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from abc import ABC +import json +from typing import Any, AsyncGenerator, Dict, Optional + +import aiosqlite + +from swh.model.identifiers import SWHID, parse_swhid +from swh.web.client.client import typify_json + + +class FuseCache: + """ SWH FUSE retrieves both metadata and file contents from the Software + Heritage archive via the network. In order to obtain reasonable performances + several caches are used to minimize network transfer. + + Caches are stored on disk in SQLite databases located at + `$XDG_CACHE_HOME/swh/fuse/`. + + All caches are persistent (i.e., they survive the restart of the SWH FUSE + process) and global (i.e., they are shared by concurrent SWH FUSE + processes). + + We assume that no cache *invalidation* is necessary, due to intrinsic + properties of the Software Heritage archive, such as integrity verification + and append-only archive changes. To clean the caches one can just remove the + corresponding files from disk. """ + + def __init__(self, cache_conf: Dict[str, Any]): + self.cache_conf = cache_conf + + async def __aenter__(self): + self.metadata = MetadataCache(self.cache_conf["metadata"]) + self.blob = BlobCache(self.cache_conf["blob"]) + await self.metadata.__aenter__() + await self.blob.__aenter__() + return self + + async def __aexit__(self, type=None, val=None, tb=None) -> None: + await self.metadata.__aexit__() + await self.blob.__aexit__() + + async def get_cached_swhids(self) -> AsyncGenerator[SWHID, None]: + """ Return a list of all previously cached SWHID """ + + # Use the metadata db since it should always contain all accessed SWHIDs + metadata_cursor = await self.metadata.conn.execute( + "select swhid from metadata_cache" + ) + swhids = await metadata_cursor.fetchall() + for raw_swhid in swhids: + yield parse_swhid(raw_swhid[0]) + + +class AbstractCache(ABC): + """ Abstract cache implementation to share common behavior between cache + types (such as: YAML config parsing, SQLite context manager) """ + + def __init__(self, conf: Dict[str, Any]): + self.conf = conf + + async def __aenter__(self): + # In-memory (thus temporary) caching is useful for testing purposes + if self.conf.get("in-memory", False): + path = ":memory:" + else: + path = self.conf["path"] + self.conn = await aiosqlite.connect(path) + return self + + async def __aexit__(self, type=None, val=None, tb=None) -> None: + await self.conn.close() + + +class MetadataCache(AbstractCache): + """ The metadata cache map each SWHID to the complete metadata of the + referenced object. This is analogous to what is available in + `meta/.json` file (and generally used as data source for returning + the content of those files). """ + + async def __aenter__(self): + await super().__aenter__() + await self.conn.execute( + "create table if not exists metadata_cache (swhid, metadata)" + ) + return self + + async def get(self, swhid: SWHID, typify: bool = True) -> Any: + cursor = await self.conn.execute( + "select metadata from metadata_cache where swhid=?", (str(swhid),) + ) + cache = await cursor.fetchone() + if cache: + metadata = json.loads(cache[0]) + return typify_json(metadata, swhid.object_type) if typify else metadata + else: + return None + + async def set(self, swhid: SWHID, metadata: Any) -> None: + await self.conn.execute( + "insert into metadata_cache values (?, ?)", + (str(swhid), json.dumps(metadata)), + ) + + +class BlobCache(AbstractCache): + """ The blob cache map SWHIDs of type `cnt` to the bytes of their archived + content. + + The blob cache entry for a given content object is populated, at the latest, + the first time the object is `read()`-d. It might be populated earlier on + due to prefetching, e.g., when a directory pointing to the given content is + listed for the first time. """ + + async def __aenter__(self): + await super().__aenter__() + await self.conn.execute("create table if not exists blob_cache (swhid, blob)") + return self + + async def get(self, swhid: SWHID) -> Optional[bytes]: + cursor = await self.conn.execute( + "select blob from blob_cache where swhid=?", (str(swhid),) + ) + cache = await cursor.fetchone() + if cache: + blob = cache[0] + return blob + else: + return None + + async def set(self, swhid: SWHID, blob: bytes) -> None: + await self.conn.execute( + "insert into blob_cache values (?, ?)", (str(swhid), blob) + ) diff --git a/swh/fuse/cli.py b/swh/fuse/cli.py --- a/swh/fuse/cli.py +++ b/swh/fuse/cli.py @@ -3,26 +3,45 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +import asyncio +from contextlib import ExitStack +import os + # WARNING: do not import unnecessary things here to keep cli startup time under # control -import os -from typing import Any, Dict +from pathlib import Path +from typing import Any, Dict, Tuple import click +from daemon import DaemonContext # from swh.core import config from swh.core.cli import CONTEXT_SETTINGS +from swh.model.cli import SWHIDParamType # All generic config code should reside in swh.core.config DEFAULT_CONFIG_PATH = os.environ.get( "SWH_CONFIG_FILE", os.path.join(click.get_app_dir("swh"), "global.yml") ) -DEFAULT_CONFIG: Dict[str, Any] = { - "web-api": { - "url": "https://archive.softwareheritage.org/api/1", - "auth-token": None, - } +CACHE_HOME_DIR: Path = ( + Path(os.environ["XDG_CACHE_HOME"]) + if "XDG_CACHE_HOME" in os.environ + else Path.home() / ".cache" +) + +DEFAULT_CONFIG: Dict[str, Tuple[str, Any]] = { + "cache": ( + "dict", + { + "metadata": {"path": CACHE_HOME_DIR / "swh/fuse/metadata.sqlite"}, + "blob": {"path": CACHE_HOME_DIR / "swh/fuse/blob.sqlite"}, + }, + ), + "web-api": ( + "dict", + {"url": "https://archive.softwareheritage.org/api/1", "auth-token": None,}, + ), } @@ -50,17 +69,36 @@ @cli.command() +@click.argument( + "path", + required=True, + metavar="PATH", + type=click.Path(exists=True, dir_okay=True, file_okay=False), +) +@click.argument("swhids", nargs=-1, metavar="[SWHID]...", type=SWHIDParamType()) +@click.option( + "--config-file", + "-C", + default=None, + type=click.Path(exists=True, dir_okay=False,), + help="YAML configuration file", +) @click.option( - "-u", - "--api-url", - default=DEFAULT_CONFIG["web-api"]["url"], - metavar="API_URL", + "-f", + "--foreground", + is_flag=True, show_default=True, - help="base URL for Software Heritage Web API", + help="Run FUSE system in foreground instead of daemon", ) @click.pass_context -def mount(ctx, api_url): - """Mount the Software Heritage archive at the given mount point""" - from .fuse import fuse # XXX +def mount(ctx, swhids, path, config_file, foreground): + """ Mount the Software Heritage archive at the given mount point """ + + from swh.core import config + from swh.fuse import fuse - fuse() + conf = config.read(config_file, DEFAULT_CONFIG) + with ExitStack() as stack: + if not foreground: + stack.enter_context(DaemonContext()) + asyncio.run(fuse.main(swhids, path, conf)) diff --git a/swh/fuse/fs/artifact.py b/swh/fuse/fs/artifact.py new file mode 100644 --- /dev/null +++ b/swh/fuse/fs/artifact.py @@ -0,0 +1,93 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from typing import Any, AsyncIterator + +from swh.fuse.fs.entry import EntryMode, FuseEntry +from swh.model.identifiers import CONTENT, DIRECTORY, SWHID + +# Avoid cycling import +Fuse = "Fuse" + + +class ArtifactEntry(FuseEntry): + """ FUSE virtual entry for a Software Heritage Artifact + + Attributes: + swhid: Software Heritage persistent identifier + prefetch: optional prefetched metadata used to set entry attributes + """ + + def __init__( + self, name: str, mode: int, fuse: Fuse, swhid: SWHID, prefetch: Any = None + ): + super().__init__(name, mode, fuse) + self.swhid = swhid + self.prefetch = prefetch + + +def typify( + name: str, mode: int, fuse: Fuse, swhid: SWHID, prefetch: Any = None +) -> ArtifactEntry: + """ Create an artifact entry corresponding to the given artifact type """ + + getters = {CONTENT: Content, DIRECTORY: Directory} + return getters[swhid.object_type](name, mode, fuse, swhid, prefetch) + + +class Content(ArtifactEntry): + """ Software Heritage content artifact. + + Content leaves (AKA blobs) are represented on disks as regular files, + containing the corresponding bytes, as archived. + + Note that permissions are associated to blobs only in the context of + directories. Hence, when accessing blobs from the top-level `archive/` + directory, the permissions of the `archive/SWHID` file will be arbitrary and + not meaningful (e.g., `0x644`). """ + + async def content(self) -> bytes: + return await self.fuse.get_blob(self.swhid) + + async def length(self) -> int: + # When listing entries from a directory, the API already gave us information + if self.prefetch: + return self.prefetch["length"] + return len(await self.content()) + + async def __aiter__(self): + raise ValueError("Cannot iterate over a content type artifact") + + +class Directory(ArtifactEntry): + """ Software Heritage directory artifact. + + Directory nodes are represented as directories on the file-system, + containing one entry for each entry of the archived directory. Entry names + and other metadata, including permissions, will correspond to the archived + entry metadata. + + Note that the FUSE mount is read-only, no matter what the permissions say. + So it is possible that, in the context of a directory, a file is presented + as writable, whereas actually writing to it will fail with `EPERM`. """ + + async def __aiter__(self) -> AsyncIterator[ArtifactEntry]: + metadata = await self.fuse.get_metadata(self.swhid) + for entry in metadata: + yield typify( + name=entry["name"], + # Use default read-only permissions for directories, and + # archived permissions for contents + mode=( + entry["perms"] + if entry["target"].object_type == CONTENT + else int(EntryMode.RDONLY_DIR) + ), + fuse=self.fuse, + swhid=entry["target"], + # The directory API has extra info we can use to set attributes + # without additional Software Heritage API call + prefetch=entry, + ) diff --git a/swh/fuse/fs/entry.py b/swh/fuse/fs/entry.py new file mode 100644 --- /dev/null +++ b/swh/fuse/fs/entry.py @@ -0,0 +1,48 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from enum import IntEnum +from stat import S_IFDIR, S_IFREG + +# Avoid cycling import +Fuse = "Fuse" + + +class EntryMode(IntEnum): + """ Default entry mode and permissions for the FUSE. + + The FUSE mount is always read-only, even if permissions contradict this + statement (in a context of a directory, entries are listed with permissions + taken from the archive). + """ + + RDONLY_FILE = S_IFREG | 0o444 + RDONLY_DIR = S_IFDIR | 0o555 + + +class FuseEntry: + """ Main wrapper class to manipulate virtual FUSE entries + + Attributes: + name: entry filename + mode: entry permission mode + fuse: internal reference to the main FUSE class + inode: unique integer identifying the entry + """ + + def __init__(self, name: str, mode: int, fuse: Fuse): + self.name = name + self.mode = mode + self.fuse = fuse + self.inode = fuse._alloc_inode(self) + + async def length(self) -> int: + return 0 + + async def content(self): + return None + + async def __aiter__(self): + return None diff --git a/swh/fuse/fs/mountpoint.py b/swh/fuse/fs/mountpoint.py new file mode 100644 --- /dev/null +++ b/swh/fuse/fs/mountpoint.py @@ -0,0 +1,77 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import json +from typing import AsyncIterator + +from swh.fuse.fs.artifact import typify +from swh.fuse.fs.entry import EntryMode, FuseEntry +from swh.model.identifiers import CONTENT, SWHID + +# Avoid cycling import +Fuse = "Fuse" + + +class Root(FuseEntry): + """ The FUSE mountpoint, consisting of the archive/ and meta/ directories """ + + def __init__(self, fuse: Fuse): + super().__init__(name="root", mode=int(EntryMode.RDONLY_DIR), fuse=fuse) + + async def __aiter__(self) -> AsyncIterator[FuseEntry]: + for entry in [ArchiveDir(self.fuse), MetaDir(self.fuse)]: + yield entry + + +class ArchiveDir(FuseEntry): + """ The archive/ directory is lazily populated with one entry per accessed + SWHID, having actual SWHIDs as names """ + + def __init__(self, fuse: Fuse): + super().__init__(name="archive", mode=int(EntryMode.RDONLY_DIR), fuse=fuse) + + async def __aiter__(self) -> AsyncIterator[FuseEntry]: + async for swhid in self.fuse.cache.get_cached_swhids(): + if swhid.object_type == CONTENT: + mode = EntryMode.RDONLY_FILE + else: + mode = EntryMode.RDONLY_DIR + yield typify(str(swhid), int(mode), self.fuse, swhid) + + +class MetaDir(FuseEntry): + """ The meta/ directory contains one SWHID.json file for each SWHID entry + under archive/. The JSON file contain all available meta information about + the given SWHID, as returned by the Software Heritage Web API for that + object. Note that, in case of pagination (e.g., snapshot objects with many + branches) the JSON file will contain a complete version with all pages + merged together. """ + + def __init__(self, fuse: Fuse): + super().__init__(name="meta", mode=int(EntryMode.RDONLY_DIR), fuse=fuse) + + async def __aiter__(self) -> AsyncIterator[FuseEntry]: + async for swhid in self.fuse.cache.get_cached_swhids(): + yield MetaEntry(swhid, self.fuse) + + +class MetaEntry(FuseEntry): + """ An entry from the meta/ directory, containing for each accessed SWHID a + corresponding SWHID.json file with all the metadata from the Software + Heritage archive. """ + + def __init__(self, swhid: SWHID, fuse: Fuse): + super().__init__( + name=str(swhid) + ".json", mode=int(EntryMode.RDONLY_FILE), fuse=fuse + ) + self.swhid = swhid + + async def content(self) -> bytes: + # Get raw JSON metadata from API (un-typified) + metadata = await self.fuse.cache.metadata.get(self.swhid, typify=False) + return json.dumps(metadata).encode() + + async def length(self) -> int: + return len(await self.content()) diff --git a/swh/fuse/fuse.py b/swh/fuse/fuse.py --- a/swh/fuse/fuse.py +++ b/swh/fuse/fuse.py @@ -3,11 +3,217 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information - +import asyncio +import errno import logging -import sys +import os +from pathlib import Path +import time +from typing import Any, Dict, List + +import pyfuse3 +import pyfuse3_asyncio +import requests + +from swh.fuse.cache import FuseCache +from swh.fuse.fs.entry import FuseEntry +from swh.fuse.fs.mountpoint import Root +from swh.model.identifiers import CONTENT, SWHID +from swh.web.client.client import WebAPIClient + + +class Fuse(pyfuse3.Operations): + """ Software Heritage Filesystem in Userspace (FUSE). Locally mount parts of + the archive and navigate it as a virtual file system. """ + + def __init__( + self, root_path: Path, cache: FuseCache, conf: Dict[str, Any], + ): + super(Fuse, self).__init__() + + self._next_inode: int = pyfuse3.ROOT_INODE + self._inode2entry: Dict[int, FuseEntry] = {} + + self.root = Root(fuse=self) + + self.time_ns: int = time.time_ns() # start time, used as timestamp + self.gid = os.getgid() + self.uid = os.getuid() + + self.web_api = WebAPIClient( + conf["web-api"]["url"], conf["web-api"]["auth-token"] + ) + self.cache = cache + + def shutdown(self) -> None: + pass + + def _alloc_inode(self, entry: FuseEntry) -> int: + """ Return a unique inode integer for a given entry """ + + inode = self._next_inode + self._next_inode += 1 + self._inode2entry[inode] = entry + + # TODO add inode recycling with invocation to invalidate_inode when + # the dicts get too big + + return inode + + def inode2entry(self, inode: int) -> FuseEntry: + """ Return the entry matching a given inode """ + + try: + return self._inode2entry[inode] + except KeyError: + raise pyfuse3.FUSEError(errno.ENOENT) + + async def get_metadata(self, swhid: SWHID) -> Any: + """ Retrieve metadata for a given SWHID using Software Heritage API """ + + cache = await self.cache.metadata.get(swhid) + if cache: + return cache + + try: + # TODO: swh-graph API + typify = False # Get the raw JSON from the API + # TODO: async web API + loop = asyncio.get_event_loop() + metadata = await loop.run_in_executor(None, self.web_api.get, swhid, typify) + await self.cache.metadata.set(swhid, metadata) + # Retrieve it from cache so it is correctly typed + return await self.cache.metadata.get(swhid) + except requests.HTTPError: + logging.error(f"Unknown SWHID: '{swhid}'") + + async def get_blob(self, swhid: SWHID) -> bytes: + """ Retrieve the blob bytes for a given content SWHID using Software + Heritage API """ + + if swhid.object_type != CONTENT: + raise pyfuse3.FUSEError(errno.EINVAL) + + # Make sure the metadata cache is also populated with the given SWHID + await self.get_metadata(swhid) + + cache = await self.cache.blob.get(swhid) + if cache: + return cache + + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, self.web_api.content_raw, swhid) + blob = b"".join(list(resp)) + await self.cache.blob.set(swhid, blob) + return blob + + async def get_attrs(self, entry: FuseEntry) -> pyfuse3.EntryAttributes: + """ Return entry attributes """ + + attrs = pyfuse3.EntryAttributes() + attrs.st_size = 0 + attrs.st_atime_ns = self.time_ns + attrs.st_ctime_ns = self.time_ns + attrs.st_mtime_ns = self.time_ns + attrs.st_gid = self.gid + attrs.st_uid = self.uid + attrs.st_ino = entry.inode + attrs.st_mode = entry.mode + attrs.st_size = await entry.length() + return attrs + + async def getattr( + self, inode: int, _ctx: pyfuse3.RequestContext + ) -> pyfuse3.EntryAttributes: + """ Get attributes for a given inode """ + + entry = self.inode2entry(inode) + return await self.get_attrs(entry) + + async def opendir(self, inode: int, _ctx: pyfuse3.RequestContext) -> int: + """ Open a directory referred by a given inode """ + + # Re-use inode as directory handle + return inode + + async def readdir(self, fh: int, offset: int, token: pyfuse3.ReaddirToken) -> None: + """ Read entries in an open directory """ + + # opendir() uses inode as directory handle + inode = fh + + # TODO: add cache on direntry list? + direntry = self.inode2entry(inode) + next_id = offset + 1 + i = 0 + async for entry in direntry: + if i < offset: + i += 1 + continue + + name = os.fsencode(entry.name) + attrs = await self.get_attrs(entry) + if not pyfuse3.readdir_reply(token, name, attrs, next_id): + break + + next_id += 1 + self._inode2entry[attrs.st_ino] = entry + + async def open( + self, inode: int, _flags: int, _ctx: pyfuse3.RequestContext + ) -> pyfuse3.FileInfo: + """ Open an inode and return a unique file handle """ + + # Re-use inode as file handle + return pyfuse3.FileInfo(fh=inode, keep_cache=True) + + async def read(self, fh: int, offset: int, length: int) -> bytes: + """ Read `length` bytes from file handle `fh` at position `offset` """ + + # open() uses inode as file handle + inode = fh + + entry = self.inode2entry(inode) + data = await entry.content() + return data[offset : offset + length] + + async def lookup( + self, parent_inode: int, name: str, _ctx: pyfuse3.RequestContext + ) -> pyfuse3.EntryAttributes: + """ Look up a directory entry by name and get its attributes """ + + name = os.fsdecode(name) + parent_entry = self.inode2entry(parent_inode) + + async for entry in parent_entry: + if name == entry.name: + attr = await self.get_attrs(entry) + return attr + + logging.error(f"Unknown name during lookup: '{name}'") + raise pyfuse3.FUSEError(errno.ENOENT) + + +async def main(swhids: List[SWHID], root_path: Path, conf: Dict[str, Any]) -> None: + """ swh-fuse CLI entry-point """ + + # Use pyfuse3 asyncio layer to match the rest of Software Heritage codebase + pyfuse3_asyncio.enable() + + async with FuseCache(conf["cache"]) as cache: + fs = Fuse(root_path, cache, conf) + + # Initially populate the cache + for swhid in swhids: + await fs.get_metadata(swhid) + fuse_options = set(pyfuse3.default_options) + fuse_options.add("fsname=swhfs") + fuse_options.add("debug") + pyfuse3.init(fs, root_path, fuse_options) -def fuse(): - logging.error("not implemented: FUSE mounting") - sys.exit(1) + try: + await pyfuse3.main() + finally: + fs.shutdown() + pyfuse3.close(unmount=True) diff --git a/swh/fuse/tests/api_data.py b/swh/fuse/tests/api_data.py new file mode 100644 --- /dev/null +++ b/swh/fuse/tests/api_data.py @@ -0,0 +1,445 @@ +# GENERATED FILE, DO NOT EDIT. +# Run './gen-api-data.py > api_data.py' instead. +# fmt: off + +API_URL = 'https://invalid-test-only.archive.softwareheritage.org/api/1' +ROOT_SWHID = 'swh:1:dir:9eb62ef7dd283f7385e7d31af6344d9feedd25de' +ROOT_URL = 'directory/9eb62ef7dd283f7385e7d31af6344d9feedd25de/' +README_URL = 'content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/' +README_RAW_URL = 'content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/raw/' + +MOCK_ARCHIVE = { + 'directory/9eb62ef7dd283f7385e7d31af6344d9feedd25de/': # NoQA: E501 + r"""[ + { + "checksums": { + "sha1": "39a0a88cd8eae4504e1d33b0c0f88059044d761f", + "sha1_git": "a0a96088c74f49a961a80bc0851a84214b0a9f83", + "sha256": "7c0f4eaf45838f26ae951b490beb0d11034a30b21e5c39a54c4223f5c2018890" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 16166, + "name": ".clang-format", + "perms": 33188, + "status": "visible", + "target": "a0a96088c74f49a961a80bc0851a84214b0a9f83", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:a0a96088c74f49a961a80bc0851a84214b0a9f83/", + "type": "file" + }, + { + "checksums": { + "sha1": "0e31de4130c64f23e9ee5fc761fdbd807dc94360", + "sha1_git": "43967c6b20151ee126db08e24758e3c789bcb844", + "sha256": "dbd64d3f532b962d4681d79077cc186340f5f439de7f99c709b01892332af866" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 59, + "name": ".cocciconfig", + "perms": 33188, + "status": "visible", + "target": "43967c6b20151ee126db08e24758e3c789bcb844", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:43967c6b20151ee126db08e24758e3c789bcb844/", + "type": "file" + }, + { + "checksums": { + "sha1": "52b62d115dfae2ed19561db14e8d10ee22659e7f", + "sha1_git": "a64d219137455f407a7b1f2c6b156c5575852e9e", + "sha256": "4c9ba8e0ef521ce01474e98eddfc77afaec8a8e259939a139590c00505646527" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 71, + "name": ".get_maintainer.ignore", + "perms": 33188, + "status": "visible", + "target": "a64d219137455f407a7b1f2c6b156c5575852e9e", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:a64d219137455f407a7b1f2c6b156c5575852e9e/", + "type": "file" + }, + { + "checksums": { + "sha1": "6cc5a38e6f6ca93e21c78cb9c54794a42c3031c3", + "sha1_git": "4b32eaa9571e64e47b51c43537063f56b204d8b3", + "sha256": "dc52a4e1ee3615c87691aca7f667c7e49f6900f36b5c20339ac497366ba9406c" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 62, + "name": ".gitattributes", + "perms": 33188, + "status": "visible", + "target": "4b32eaa9571e64e47b51c43537063f56b204d8b3", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:4b32eaa9571e64e47b51c43537063f56b204d8b3/", + "type": "file" + }, + { + "checksums": { + "sha1": "1966a794db7d9518f321a8ecb0736c16de59bd91", + "sha1_git": "162bd2b67bdf6a28be7a361b8418e4e31d542854", + "sha256": "a9766c936a81df2ed3ea41f506ddaad88a78d9cf47e093df414b3b6f2e6d8e14" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 1852, + "name": ".gitignore", + "perms": 33188, + "status": "visible", + "target": "162bd2b67bdf6a28be7a361b8418e4e31d542854", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:162bd2b67bdf6a28be7a361b8418e4e31d542854/", + "type": "file" + }, + { + "checksums": { + "sha1": "2bb15bd51c981842b6f710d2e057972e5f22cfcc", + "sha1_git": "332c7833057f51da02805add9b60161ff31aee71", + "sha256": "d7b69571529964b3c8444a73ac720bbb883cc70fc4b78a36d1ac277f660c50bb" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 17283, + "name": ".mailmap", + "perms": 33188, + "status": "visible", + "target": "332c7833057f51da02805add9b60161ff31aee71", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:332c7833057f51da02805add9b60161ff31aee71/", + "type": "file" + }, + { + "checksums": { + "sha1": "0473e748fee37c7b68487fb102c0d563bbc641b3", + "sha1_git": "a635a38ef9405fdfcfe97f3a435393c1e9cae971", + "sha256": "fb5a425bd3b3cd6071a3a9aff9909a859e7c1158d54d32e07658398cd67eb6a0" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 496, + "name": "COPYING", + "perms": 33188, + "status": "visible", + "target": "a635a38ef9405fdfcfe97f3a435393c1e9cae971", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:a635a38ef9405fdfcfe97f3a435393c1e9cae971/", + "type": "file" + }, + { + "checksums": { + "sha1": "f27043aefa4b69b921df3728ee906c3f03087d29", + "sha1_git": "32ee70a7562eec7345e98841473abb438379a4fd", + "sha256": "23242c7183ee2815e27fea2346b0e5ad9131b8b611b200ae0418d4027cea2a3d" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 99788, + "name": "CREDITS", + "perms": 33188, + "status": "visible", + "target": "32ee70a7562eec7345e98841473abb438379a4fd", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:32ee70a7562eec7345e98841473abb438379a4fd/", + "type": "file" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "Documentation", + "perms": 16384, + "target": "1ba46735273aa020a173c0ad0c813179530dd117", + "target_url": "https://archive.softwareheritage.org/api/1/directory/1ba46735273aa020a173c0ad0c813179530dd117/", + "type": "dir" + }, + { + "checksums": { + "sha1": "2491dd3bed10f6918ed1657ab5a7a8efbddadf5d", + "sha1_git": "fa441b98c9f6eac1617acf1772ae8b371cfd42aa", + "sha256": "75df66064f75e91e6458862cd9413b19e65b77eefcc8a95dcbd6bf36fd2e4b59" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 1327, + "name": "Kbuild", + "perms": 33188, + "status": "visible", + "target": "fa441b98c9f6eac1617acf1772ae8b371cfd42aa", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:fa441b98c9f6eac1617acf1772ae8b371cfd42aa/", + "type": "file" + }, + { + "checksums": { + "sha1": "6b9b12a6bbff219dfbb45ec068af9aa7cf1b7288", + "sha1_git": "745bc773f567067a85ce6574fb41ce80833247d9", + "sha256": "a592dae7d067cd8e5dc43e3f9dc363eba9eb1f7cf80c6178b5cd291c0b76d3ec" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 555, + "name": "Kconfig", + "perms": 33188, + "status": "visible", + "target": "745bc773f567067a85ce6574fb41ce80833247d9", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:745bc773f567067a85ce6574fb41ce80833247d9/", + "type": "file" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "LICENSES", + "perms": 16384, + "target": "a49a894ea3684b6c044448c37f812356550d14a2", + "target_url": "https://archive.softwareheritage.org/api/1/directory/a49a894ea3684b6c044448c37f812356550d14a2/", + "type": "dir" + }, + { + "checksums": { + "sha1": "eb207b62f2fe0225dc55d9b87d82f6009e864117", + "sha1_git": "f0068bceeb6158a30c6eee430ca6d2a7e4c4013a", + "sha256": "3c81b34eaf99d943e4c2fac2548f3d9d740a9e9683ccedbaacfb82796c7965e1" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 569101, + "name": "MAINTAINERS", + "perms": 33188, + "status": "visible", + "target": "f0068bceeb6158a30c6eee430ca6d2a7e4c4013a", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:f0068bceeb6158a30c6eee430ca6d2a7e4c4013a/", + "type": "file" + }, + { + "checksums": { + "sha1": "9bfa205a4d23b9a60889bd9b59010574670b8b90", + "sha1_git": "f2116815416091dbfa7dcf58ae179ae3241ec1b1", + "sha256": "e87fb2b9482b9066b47c1656e55ebc4897bbb226daa122bcd4a2858fef19e597" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 63305, + "name": "Makefile", + "perms": 33188, + "status": "visible", + "target": "f2116815416091dbfa7dcf58ae179ae3241ec1b1", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:f2116815416091dbfa7dcf58ae179ae3241ec1b1/", + "type": "file" + }, + { + "checksums": { + "sha1": "ca1dc365022dcaa728dfb11bcde40ad3cce0574b", + "sha1_git": "669ac7c32292798644b21dbb5a0dc657125f444d", + "sha256": "bad58d396f62102befaf23a8a2ab6b1693fdc8f318de3059b489781f28865612" + }, + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": 727, + "name": "README", + "perms": 33188, + "status": "visible", + "target": "669ac7c32292798644b21dbb5a0dc657125f444d", + "target_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/", + "type": "file" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "arch", + "perms": 16384, + "target": "cf12c1ce4de958ab4ddcb008fe89118b82a3c7b7", + "target_url": "https://archive.softwareheritage.org/api/1/directory/cf12c1ce4de958ab4ddcb008fe89118b82a3c7b7/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "block", + "perms": 16384, + "target": "a77c89fa64b8ec37c9aa0fa98add54bfb6075257", + "target_url": "https://archive.softwareheritage.org/api/1/directory/a77c89fa64b8ec37c9aa0fa98add54bfb6075257/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "certs", + "perms": 16384, + "target": "527d8f94235029c6f571414df5f8ed2951a0ca5b", + "target_url": "https://archive.softwareheritage.org/api/1/directory/527d8f94235029c6f571414df5f8ed2951a0ca5b/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "crypto", + "perms": 16384, + "target": "1fb1357e2d22af4332091937ed960a47f78d0b5e", + "target_url": "https://archive.softwareheritage.org/api/1/directory/1fb1357e2d22af4332091937ed960a47f78d0b5e/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "drivers", + "perms": 16384, + "target": "3b5be1ee0216ec59c70e132681be4a5d79e7da9b", + "target_url": "https://archive.softwareheritage.org/api/1/directory/3b5be1ee0216ec59c70e132681be4a5d79e7da9b/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "fs", + "perms": 16384, + "target": "1dbf8d211613db72f5b83b0987023bd5acf866ee", + "target_url": "https://archive.softwareheritage.org/api/1/directory/1dbf8d211613db72f5b83b0987023bd5acf866ee/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "include", + "perms": 16384, + "target": "74991fd1a983c6b3f72c8815f7de81a3abddb255", + "target_url": "https://archive.softwareheritage.org/api/1/directory/74991fd1a983c6b3f72c8815f7de81a3abddb255/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "init", + "perms": 16384, + "target": "c944a589113271d878e27bbc31ae369edecaff90", + "target_url": "https://archive.softwareheritage.org/api/1/directory/c944a589113271d878e27bbc31ae369edecaff90/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "ipc", + "perms": 16384, + "target": "ff553b9398fea6b2e290ea4a95f7a94f1cf3c22c", + "target_url": "https://archive.softwareheritage.org/api/1/directory/ff553b9398fea6b2e290ea4a95f7a94f1cf3c22c/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "kernel", + "perms": 16384, + "target": "8c700fd3589e6d2befa4d9b2cc79471eac37da38", + "target_url": "https://archive.softwareheritage.org/api/1/directory/8c700fd3589e6d2befa4d9b2cc79471eac37da38/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "lib", + "perms": 16384, + "target": "0f2936da43bebe4f26b3be83e8fa392c4f9e82cf", + "target_url": "https://archive.softwareheritage.org/api/1/directory/0f2936da43bebe4f26b3be83e8fa392c4f9e82cf/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "mm", + "perms": 16384, + "target": "e15d954c1ed09e6fc29c184515834696d8e70e7c", + "target_url": "https://archive.softwareheritage.org/api/1/directory/e15d954c1ed09e6fc29c184515834696d8e70e7c/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "net", + "perms": 16384, + "target": "41e1603b37542d265eade0555e0db66668135575", + "target_url": "https://archive.softwareheritage.org/api/1/directory/41e1603b37542d265eade0555e0db66668135575/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "samples", + "perms": 16384, + "target": "9fa649fea3c8ab6b4926f0e7721a21a36b685153", + "target_url": "https://archive.softwareheritage.org/api/1/directory/9fa649fea3c8ab6b4926f0e7721a21a36b685153/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "scripts", + "perms": 16384, + "target": "e4e5b45d7c44d0bd2c6feb1a257fff7303d2c67e", + "target_url": "https://archive.softwareheritage.org/api/1/directory/e4e5b45d7c44d0bd2c6feb1a257fff7303d2c67e/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "security", + "perms": 16384, + "target": "a4a58d89fc506c3660610105a08de60614cdc980", + "target_url": "https://archive.softwareheritage.org/api/1/directory/a4a58d89fc506c3660610105a08de60614cdc980/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "sound", + "perms": 16384, + "target": "bf9e1568b8ce61157a322fddbaab1a0c76be15ef", + "target_url": "https://archive.softwareheritage.org/api/1/directory/bf9e1568b8ce61157a322fddbaab1a0c76be15ef/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "tools", + "perms": 16384, + "target": "83d6279411023bf7edf6bde6ce2e3748912f4936", + "target_url": "https://archive.softwareheritage.org/api/1/directory/83d6279411023bf7edf6bde6ce2e3748912f4936/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "usr", + "perms": 16384, + "target": "aae2ca939e0f7ac6b5e489e4c7835e1a15588cff", + "target_url": "https://archive.softwareheritage.org/api/1/directory/aae2ca939e0f7ac6b5e489e4c7835e1a15588cff/", + "type": "dir" + }, + { + "dir_id": "9eb62ef7dd283f7385e7d31af6344d9feedd25de", + "length": null, + "name": "virt", + "perms": 16384, + "target": "d7f6f10a8509839e404d1cc5af51317ac8b26276", + "target_url": "https://archive.softwareheritage.org/api/1/directory/d7f6f10a8509839e404d1cc5af51317ac8b26276/", + "type": "dir" + } +] +""", # NoQA: E501 + 'content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/': # NoQA: E501 + r"""{ + "checksums": { + "blake2s256": "746aaa0816ffc8cadf5e7f70b8bb93a47a76299ef263c743dbfef2644c6a0245", + "sha1": "ca1dc365022dcaa728dfb11bcde40ad3cce0574b", + "sha1_git": "669ac7c32292798644b21dbb5a0dc657125f444d", + "sha256": "bad58d396f62102befaf23a8a2ab6b1693fdc8f318de3059b489781f28865612" + }, + "data_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/raw/", + "filetype_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/filetype/", + "language_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/language/", + "length": 727, + "license_url": "https://archive.softwareheritage.org/api/1/content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/license/", + "status": "visible" +} +""", # NoQA: E501 + 'content/sha1_git:669ac7c32292798644b21dbb5a0dc657125f444d/raw/': # NoQA: E501 + r"""Linux kernel +============ + +There are several guides for kernel developers and users. These guides can +be rendered in a number of formats, like HTML and PDF. Please read +Documentation/admin-guide/README.rst first. + +In order to build the documentation, use ``make htmldocs`` or +``make pdfdocs``. The formatted documentation can also be read online at: + + https://www.kernel.org/doc/html/latest/ + +There are various text files in the Documentation/ subdirectory, +several of them using the Restructured Text markup notation. + +Please read the Documentation/process/changes.rst file, as it contains the +requirements for building and running the kernel, and information about +the problems which may result by upgrading your kernel. +""", # NoQA: E501 +} diff --git a/swh/fuse/tests/conftest.py b/swh/fuse/tests/conftest.py new file mode 100644 --- /dev/null +++ b/swh/fuse/tests/conftest.py @@ -0,0 +1,66 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from multiprocessing import Process +from os import listdir +from pathlib import Path +import subprocess +from tempfile import NamedTemporaryFile, TemporaryDirectory +import time + +from click.testing import CliRunner +import pytest +import yaml + +from swh.fuse import cli + +from .api_data import API_URL, MOCK_ARCHIVE, ROOT_SWHID + + +@pytest.fixture +def web_api_mock(requests_mock): + for api_call, data in MOCK_ARCHIVE.items(): + requests_mock.get(f"{API_URL}/{api_call}", text=data) + return requests_mock + + +@pytest.fixture +def fuse_mntdir(web_api_mock): + tmpdir = TemporaryDirectory(suffix=".swh-fuse-test") + tmpfile = NamedTemporaryFile(suffix=".swh-fuse-test.yml") + + config = { + "cache": {"metadata": {"in-memory": True}, "blob": {"in-memory": True}}, + "web-api": {"url": API_URL, "auth-token": None}, + } + + # Run FUSE in foreground mode but in a separate process, so it does not + # block execution and remains easy to kill during teardown + def fuse_process(tmpdir, tmpfile): + with tmpdir as mntdir, tmpfile as config_path: + config_path = Path(config_path.name) + config_path.write_text(yaml.dump(config)) + CliRunner().invoke( + cli.mount, + args=[mntdir, ROOT_SWHID, "--foreground", "--config-file", config_path], + ) + + fuse = Process(target=fuse_process, args=[tmpdir, tmpfile]) + fuse.start() + # Wait max 3 seconds for the FUSE to correctly mount + for i in range(30): + try: + root = listdir(tmpdir.name) + if root: + break + except FileNotFoundError: + pass + time.sleep(0.1) + else: + raise FileNotFoundError(f"Could not mount FUSE in {tmpdir.name}") + + yield tmpdir.name + + subprocess.run(["fusermount", "-u", tmpdir.name], check=True) diff --git a/swh/fuse/tests/gen-api-data.py b/swh/fuse/tests/gen-api-data.py new file mode 100755 --- /dev/null +++ b/swh/fuse/tests/gen-api-data.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import json + +import requests + +API_URL_real = "https://archive.softwareheritage.org/api/1" +API_URL_test = "https://invalid-test-only.archive.softwareheritage.org/api/1" + +# Use the Linux kernel as a testing repository +ROOT_HASH = "9eb62ef7dd283f7385e7d31af6344d9feedd25de" +README_HASH = "669ac7c32292798644b21dbb5a0dc657125f444d" + +ROOT_SWHID = f"swh:1:dir:{ROOT_HASH}" + +urls = { + "ROOT": f"directory/{ROOT_HASH}/", + "README": f"content/sha1_git:{README_HASH}/", + "README_RAW": f"content/sha1_git:{README_HASH}/raw/", +} + +print("# GENERATED FILE, DO NOT EDIT.") +print("# Run './gen-api-data.py > api_data.py' instead.") +print("# fmt: off") + +print("") +print(f"API_URL = '{API_URL_test}'") +print(f"ROOT_SWHID = '{ROOT_SWHID}'") +for name, url in urls.items(): + print(f"{name}_URL = '{url}'") +print("") + +print("MOCK_ARCHIVE = {") +for url in urls.values(): + print(f" '{url}': # NoQA: E501") + print(' r"""', end="") + + data = requests.get(f"{API_URL_real}/{url}").text + if url.endswith("/raw/"): + print(data, end="") + else: + parsed = json.loads(data) + print(json.dumps(parsed, indent=2, sort_keys=True)) + + print('""", # NoQA: E501') +print("}") diff --git a/swh/fuse/tests/test_cli.py b/swh/fuse/tests/test_cli.py --- a/swh/fuse/tests/test_cli.py +++ b/swh/fuse/tests/test_cli.py @@ -3,17 +3,15 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information -import unittest +from pathlib import Path -from click.testing import CliRunner +from .api_data import ROOT_SWHID -from swh.fuse import cli - -class TestMount(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - - def test_no_args(self): - result = self.runner.invoke(cli.mount) - self.assertNotEqual(result.exit_code, 0) +def test_mountpoint(fuse_mntdir): + archive_dir = Path(fuse_mntdir, "archive") + meta_dir = Path(fuse_mntdir, "meta") + swhid_dir = Path(fuse_mntdir, "archive", ROOT_SWHID) + assert archive_dir.is_dir() + assert meta_dir.is_dir() + assert swhid_dir.is_dir() diff --git a/swh/fuse/tests/test_content.py b/swh/fuse/tests/test_content.py new file mode 100644 --- /dev/null +++ b/swh/fuse/tests/test_content.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from .api_data import MOCK_ARCHIVE, README_RAW_URL, ROOT_SWHID + + +def test_file_exists(fuse_mntdir): + readme_path = Path(fuse_mntdir, "archive", ROOT_SWHID, "README") + assert readme_path.is_file() + + +def test_cat_file(fuse_mntdir): + readme_path = Path(fuse_mntdir, "archive", ROOT_SWHID, "README") + expected = MOCK_ARCHIVE[README_RAW_URL] + with open(readme_path, "r") as f: + actual = f.read() + assert actual == expected diff --git a/swh/fuse/tests/test_directory.py b/swh/fuse/tests/test_directory.py new file mode 100644 --- /dev/null +++ b/swh/fuse/tests/test_directory.py @@ -0,0 +1,14 @@ +import json +from os import listdir +from pathlib import Path + +from .api_data import MOCK_ARCHIVE, ROOT_SWHID, ROOT_URL + + +def test_ls_root_swhid(fuse_mntdir): + root_resp = json.loads(MOCK_ARCHIVE[ROOT_URL]) + expected = [entry["name"] for entry in root_resp] + + swhid_dir = Path(fuse_mntdir, "archive", ROOT_SWHID) + actual = listdir(swhid_dir) + assert actual == expected diff --git a/tox.ini b/tox.ini --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ [testenv:black] skip_install = true deps = - black + black==19.10b0 commands = {envpython} -m black --check swh