Skip to content

Commit 1527c1d

Browse files
initial commit
0 parents  commit 1527c1d

File tree

9 files changed

+376
-0
lines changed

9 files changed

+376
-0
lines changed

.github/workflows/ci.yml

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
formatting:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Check out the code
14+
uses: actions/checkout@v3
15+
16+
- name: Install poetry
17+
run: pipx install poetry
18+
19+
- name: Determine dependencies
20+
run: poetry lock
21+
22+
- uses: actions/setup-python@v4
23+
with:
24+
python-version: "3.9"
25+
cache: poetry
26+
27+
- name: Install Dependencies using Poetry
28+
run: poetry install
29+
30+
- name: Check formatting
31+
run: poetry run black --check .
32+
33+
linting:
34+
runs-on: ubuntu-latest
35+
steps:
36+
- name: Check out the code
37+
uses: actions/checkout@v3
38+
39+
- name: Install poetry
40+
run: pipx install poetry
41+
42+
- name: Determine dependencies
43+
run: poetry lock
44+
45+
- uses: actions/setup-python@v4
46+
with:
47+
python-version: "3.9"
48+
cache: poetry
49+
50+
- name: Install Dependencies using Poetry
51+
run: poetry install
52+
53+
- name: Check code
54+
run: poetry run flake8
55+
56+
testing:
57+
runs-on: ubuntu-latest
58+
steps:
59+
- uses: actions/checkout@v3
60+
61+
- name: Install poetry
62+
run: pipx install poetry
63+
64+
- name: Determine dependencies
65+
run: poetry lock
66+
67+
- uses: actions/setup-python@v4
68+
with:
69+
python-version: "3.9"
70+
cache: poetry
71+
72+
- name: Install dependencies
73+
run: poetry install
74+
75+
- name: Run pytest
76+
run: poetry run coverage run -m pytest tests/tests.py
77+
78+
- name: Run Coverage
79+
run: poetry run coverage report -m
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: PR
2+
on:
3+
pull_request_target:
4+
types:
5+
- opened
6+
- reopened
7+
- edited
8+
- synchronize
9+
10+
jobs:
11+
title-format:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: amannn/[email protected]
15+
env:
16+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/release-please.yml

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
on:
2+
push:
3+
branches:
4+
- main
5+
6+
name: release-please
7+
8+
jobs:
9+
release-please:
10+
runs-on: ubuntu-latest
11+
outputs:
12+
release_created: ${{ steps.release.outputs.release_created }}
13+
steps:
14+
- uses: GoogleCloudPlatform/release-please-action@v3
15+
id: release
16+
with:
17+
release-type: python
18+
package-name: snakemake-storage-plugin-fs
19+
20+
publish:
21+
runs-on: ubuntu-latest
22+
needs: release-please
23+
if: ${{ needs.release-please.outputs.release_created }}
24+
steps:
25+
- uses: actions/checkout@v3
26+
27+
- uses: actions/setup-python@v2
28+
with:
29+
python-version: "3.9"
30+
31+
- name: Install poetry
32+
run: pipx install poetry
33+
34+
- name: Determine dependencies
35+
run: poetry lock
36+
37+
- uses: actions/setup-python@v4
38+
with:
39+
python-version: "3.9"
40+
cache: poetry
41+
42+
- name: Install Dependencies using Poetry
43+
run: |
44+
pip install connection-pool # because it is incompatible with poetry
45+
poetry install
46+
47+
- name: Publish to PyPi
48+
env:
49+
PYPI_USERNAME: __token__
50+
PYPI_PASSWORD: ${{ secrets.PYPI_TOKEN }}
51+
run: poetry publish --build --username $PYPI_USERNAME --password $PYPI_PASSWORD

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
poetry.lock

README.md

Whitespace-only changes.

pyproject.toml

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[tool.poetry]
2+
name = "snakemake-storage-plugin-fs"
3+
version = "0.1.0"
4+
description = ""
5+
authors = ["Johannes Koester <[email protected]>"]
6+
readme = "README.md"
7+
8+
[tool.poetry.dependencies]
9+
python = "^3.11"
10+
snakemake-interface-common = "^1.14.2"
11+
snakemake-interface-storage-plugins = "^1.3.0"
12+
sysrsync = "^1.1.1"
13+
14+
15+
[tool.poetry.group.dev.dependencies]
16+
black = "^23.11.0"
17+
flake8 = "^6.1.0"
18+
coverage = "^7.3.2"
19+
pytest = "^7.4.3"
20+
snakemake = "^7.32.4"
21+
22+
[build-system]
23+
requires = ["poetry-core"]
24+
build-backend = "poetry.core.masonry.api"

setup.cfg

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[flake8]
2+
# Recommend matching the black line length (default 88),
3+
# rather than using the flake8 default of 79:
4+
max-line-length = 88
5+
extend-ignore =
6+
# See https://github.com/PyCQA/pycodestyle/issues/373
7+
E203,
+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import asyncio
2+
from dataclasses import dataclass, field
3+
import os
4+
from pathlib import Path
5+
import shutil
6+
from typing import Any, Iterable, Optional
7+
8+
import sysrsync
9+
10+
from snakemake_interface_storage_plugins.settings import StorageProviderSettingsBase
11+
from snakemake_interface_storage_plugins.storage_provider import (
12+
StorageProviderBase,
13+
StorageQueryValidationResult,
14+
)
15+
from snakemake_interface_storage_plugins.storage_object import (
16+
StorageObjectRead,
17+
StorageObjectWrite,
18+
StorageObjectGlob,
19+
retry_decorator,
20+
)
21+
from snakemake_interface_storage_plugins.io import (
22+
IOCacheStorageInterface,
23+
get_constant_prefix,
24+
)
25+
26+
27+
# Required:
28+
# Implementation of your storage provider
29+
# This class can be empty as the one below.
30+
# You can however use it to store global information or maintain e.g. a connection
31+
# pool.
32+
class StorageProvider(StorageProviderBase):
33+
# For compatibility with future changes, you should not overwrite the __init__
34+
# method. Instead, use __post_init__ to set additional attributes and initialize
35+
# futher stuff.
36+
37+
def __post_init__(self):
38+
# This is optional and can be removed if not needed.
39+
# Alternatively, you can e.g. prepare a connection to your storage backend here.
40+
# and set additional attributes.
41+
pass
42+
43+
@classmethod
44+
def is_valid_query(cls, query: str) -> StorageQueryValidationResult:
45+
"""Return whether the given query is valid for this storage provider."""
46+
# Ensure that also queries containing wildcards (e.g. {sample}) are accepted
47+
# and considered valid. The wildcards will be resolved before the storage
48+
# object is actually used.
49+
try:
50+
Path(query)
51+
except Exception:
52+
return False
53+
54+
def list_objects(self, query: Any) -> Iterable[str]:
55+
"""Return an iterator over all objects in the storage that match the query.
56+
57+
This is optional and can raise a NotImplementedError() instead.
58+
"""
59+
query = Path(query)
60+
if query.is_dir():
61+
return map(str, Path(query).rglob("*"))
62+
elif query.exists():
63+
return query,
64+
else:
65+
return ()
66+
67+
68+
# Required:
69+
# Implementation of storage object. If certain methods cannot be supported by your
70+
# storage (e.g. because it is read-only see
71+
# snakemake-storage-http for comparison), remove the corresponding base classes
72+
# from the list of inherited items.
73+
class StorageObject(StorageObjectRead, StorageObjectWrite, StorageObjectGlob):
74+
# For compatibility with future changes, you should not overwrite the __init__
75+
# method. Instead, use __post_init__ to set additional attributes and initialize
76+
# futher stuff.
77+
78+
def __post_init__(self):
79+
# This is optional and can be removed if not needed.
80+
# Alternatively, you can e.g. prepare a connection to your storage backend here.
81+
# and set additional attributes.
82+
self.query_path = Path(self.query)
83+
84+
async def inventory(self, cache: IOCacheStorageInterface):
85+
"""From this file, try to find as much existence and modification date
86+
information as possible. Only retrieve that information that comes for free
87+
given the current object.
88+
"""
89+
# This is optional and can be left as is
90+
91+
# If this is implemented in a storage object, results have to be stored in
92+
# the given IOCache object.
93+
key = self.cache_key()
94+
try:
95+
stat = self._stat()
96+
except FileNotFoundError:
97+
cache.exists_in_storage[key] = False
98+
if self.query_path.is_symlink():
99+
# get symlink stat
100+
lstat = self._stat(follow_symlinks=False)
101+
else:
102+
lstat = stat
103+
cache.mtime[key] = lstat.st_mtime
104+
cache.size[key] = stat.st_size
105+
cache.exists_in_storage[key] = True
106+
107+
def get_inventory_parent(self) -> Optional[str]:
108+
"""Return the parent directory of this object."""
109+
# this is optional and can be left as is
110+
parent = self.query_path.parent
111+
if parent == Path("."):
112+
return None
113+
else:
114+
return parent
115+
116+
def local_suffix(self) -> str:
117+
"""Return a unique suffix for the local path, determined from self.query."""
118+
suffix = self.query
119+
if suffix.startswith("/"):
120+
# convert absolute path to unique relative path
121+
suffix = f"__abspath__/{suffix[1:]}"
122+
return self.query.removeprefix("/")
123+
124+
def close(self):
125+
# Nothing to be done here.
126+
pass
127+
128+
# Fallible methods should implement some retry logic.
129+
# The easiest way to do this (but not the only one) is to use the retry_decorator
130+
# provided by snakemake-interface-storage-plugins.
131+
def exists(self) -> bool:
132+
# return True if the object exists
133+
return self.query_path.exists()
134+
135+
def mtime(self) -> float:
136+
# return the modification time
137+
return self._stat(follow_symlinks=False).st_mtime
138+
139+
def size(self) -> int:
140+
# return the size in bytes
141+
return self._stat().st_size
142+
143+
def retrieve_object(self):
144+
# Ensure that the object is accessible locally under self.local_path()
145+
sysrsync.run(
146+
self.query_path,
147+
self.local_path(),
148+
)
149+
150+
def store_object(self):
151+
# Ensure that the object is stored at the location specified by
152+
# self.local_path().
153+
sysrsync.run(
154+
self.local_path(),
155+
self.query_path,
156+
)
157+
158+
def remove(self):
159+
# Remove the object from the storage.
160+
shutil.rmtree(self.query_path)
161+
162+
def list_candidate_matches(self) -> Iterable[str]:
163+
"""Return a list of candidate matches in the storage for the query."""
164+
# This is used by glob_wildcards() to find matches for wildcards in the query.
165+
# The method has to return concretized queries without any remaining wildcards.
166+
prefix = Path(get_constant_prefix(self.query))
167+
if prefix.is_dir():
168+
return map(str, prefix.rglob("*"))
169+
else:
170+
return prefix,
171+
172+
def _stat(self, follow_symlinks: bool = True):
173+
# We don't want the cached variant (Path.stat), as we cache ourselves in
174+
# inventory and afterwards the information may change.
175+
return os.stat(self.query_path, follow_symlinks=follow_symlinks)

tests/tests.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Optional, Type
2+
import uuid
3+
from snakemake_interface_storage_plugins.tests import TestStorageBase
4+
from snakemake_interface_storage_plugins.storage_provider import StorageProviderBase
5+
from snakemake_interface_storage_plugins.settings import StorageProviderSettingsBase
6+
from snakemake_storage_plugin_fs import StorageProvider
7+
8+
9+
class TestStorageNoSettings(TestStorageBase):
10+
__test__ = True
11+
retrieve_only = False
12+
13+
def get_query(self) -> str:
14+
return "test/test.txt"
15+
16+
def get_query_not_existing(self) -> str:
17+
return f"test/{uuid.uuid4().hex}"
18+
19+
def get_storage_provider_cls(self) -> Type[StorageProviderBase]:
20+
return StorageProvider
21+
22+
def get_storage_provider_settings(self) -> Optional[StorageProviderSettingsBase]:
23+
return None

0 commit comments

Comments
 (0)