1
0

Merge branch 'collection-directories'
Some checks failed
Verify Python project can be installed, loaded and have version checked / Test (push) Waiting to run
Run Python tests (through Pytest) / Test (push) Has been cancelled

This commit is contained in:
Jon Michael Aanes 2025-01-08 15:25:39 +01:00
commit 6201fc9c8c
10 changed files with 197 additions and 52 deletions

View File

@ -92,6 +92,7 @@ Full list of requirements:
- [requests-cache](https://pypi.org/project/requests-cache/) - [requests-cache](https://pypi.org/project/requests-cache/)
- [fuse-python](https://pypi.org/project/fuse-python/) - [fuse-python](https://pypi.org/project/fuse-python/)
- [secret_loader](https://gitfub.space/Jmaa/secret_loader) - [secret_loader](https://gitfub.space/Jmaa/secret_loader)
- [requests_util](https://gitfub.space/Jmaa/requests_util)
- [marko](https://pypi.org/project/marko/) - [marko](https://pypi.org/project/marko/)
- [python-frontmatter](https://pypi.org/project/python-frontmatter/) - [python-frontmatter](https://pypi.org/project/python-frontmatter/)

View File

@ -43,6 +43,8 @@ Limitations:
[`python-fuse`](https://github.com/libfuse/python-fuse) implements a whole [`python-fuse`](https://github.com/libfuse/python-fuse) implements a whole
bunch automatically.) bunch automatically.)
Use `umount` to unmount the cards again.
## Architecture ## Architecture
- `FavroFuse` - `FavroFuse`

View File

@ -10,6 +10,7 @@ from .favro_fuse import start_favro_fuse
def main(): def main():
logging.basicConfig() logging.basicConfig()
logging.getLogger().setLevel('INFO')
read_only = False read_only = False
@ -28,7 +29,7 @@ def main():
) )
client.check_logged_in() client.check_logged_in()
start_favro_fuse(client) start_favro_fuse(client, secrets.favro_collection_filter())
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -4,15 +4,20 @@ Implements methods for interacting with the [Favro API](https://favro.com/devel
""" """
import dataclasses import dataclasses
from typing import Any
import requests_cache
import datetime
from collections.abc import Iterator from collections.abc import Iterator
from logging import getLogger from logging import getLogger
import requests import requests
import requests_util
from .favro_data_model import ( from .favro_data_model import (
Card, Card,
CardId, CardId,
Collection, Collection,
CollectionId,
CustomFieldId, CustomFieldId,
CustomFieldInfo, CustomFieldInfo,
OrganizationId, OrganizationId,
@ -30,6 +35,7 @@ logger = getLogger(__name__)
URL_API_ROOT = 'https://favro.com/api/v1' URL_API_ROOT = 'https://favro.com/api/v1'
URL_GET_CARDS = URL_API_ROOT + '/cards' URL_GET_CARDS = URL_API_ROOT + '/cards'
URL_UPDATE_CARD = URL_API_ROOT + '/cards/{card_id}' URL_UPDATE_CARD = URL_API_ROOT + '/cards/{card_id}'
URL_GET_USERS = URL_API_ROOT + '/users/'
URL_GET_USER = URL_API_ROOT + '/users/{user_id}' URL_GET_USER = URL_API_ROOT + '/users/{user_id}'
URL_GET_TAG = URL_API_ROOT + '/tags/{tag_id}' URL_GET_TAG = URL_API_ROOT + '/tags/{tag_id}'
URL_GET_CUSTOM_FIELD = URL_API_ROOT + '/customfields/{custom_field_id}' URL_GET_CUSTOM_FIELD = URL_API_ROOT + '/customfields/{custom_field_id}'
@ -101,24 +107,43 @@ class FavroClient:
) )
self.read_only = read_only self.read_only = read_only
self.cache = CardCache() self.card_cache = CardCache()
self.user_cache = None
# Setup caching
requests_util.setup_limiter(
self.session, URL_API_ROOT, datetime.timedelta(days=7),
)
requests_util.setup_limiter(
self.session, URL_GET_CARDS, datetime.timedelta(minutes=10),
)
requests_util.setup_limiter(
self.session, URL_GET_TASKS, datetime.timedelta(minutes=10),
)
requests_util.setup_limiter(
self.session, URL_GET_CUSTOM_FIELD, datetime.timedelta(days=30),
)
def check_logged_in(self) -> None: def check_logged_in(self) -> None:
next(self.get_todo_list_cards()) next(self.get_todo_list_cards())
def get_todo_list_cards(self) -> Iterator[Card]: def get_todo_list_cards(self) -> Iterator[Card]:
for card in self.get_cards(todo_list=True): yield from self.get_cards(todo_list=True)
self.cache.add_card(card)
yield card
def get_cards( def get_cards(
self, self,
*, *,
seq_id: SeqId | None = None, seq_id: SeqId | None = None,
collection_id: CollectionId | None = None,
todo_list=False, todo_list=False,
) -> Iterator[Card]: ) -> Iterator[Card]:
request = self._get_cards_request(seq_id=seq_id, todo_list=todo_list) logger.info('Getting cards: seq_id=%s, collection_id=%s, todo_list=%s', seq_id, collection_id, todo_list)
yield from self._get_paginated(request, Card.from_json) request = self._get_cards_request(
seq_id=seq_id, todo_list=todo_list, collection_id=collection_id,
)
for card in self._get_paginated(request, Card.from_json):
self.card_cache.add_card(card)
yield card
def get_collections(self) -> Iterator[Collection]: def get_collections(self) -> Iterator[Collection]:
request = requests.Request('GET', URL_GET_COLLECTIONS) request = requests.Request('GET', URL_GET_COLLECTIONS)
@ -135,46 +160,60 @@ class FavroClient:
self, self,
seq_id: SeqId | None = None, seq_id: SeqId | None = None,
todo_list: bool = False, todo_list: bool = False,
request_id: None | str = None, collection_id: CollectionId | None = None,
page: None | int = None,
) -> requests.Request: ) -> requests.Request:
params = {'descriptionFormat': 'markdown'} params = {'descriptionFormat': 'markdown'}
if seq_id is not None: if seq_id is not None:
params['cardSequentialId'] = str(seq_id.raw_id) params['cardSequentialId'] = str(seq_id.raw_id)
if todo_list is True: if todo_list is True:
params['todoList'] = 'true' params['todoList'] = 'true'
if request_id: if collection_id is not None:
params['requestId'] = request_id params['collectionId'] = str(collection_id.raw_id)
if page:
params['page'] = page
return requests.Request('GET', URL_GET_CARDS, params=params) return requests.Request('GET', URL_GET_CARDS, params=params)
def get_card(self, seq_id: SeqId) -> Card: def get_card(self, seq_id: SeqId) -> Card:
if card := self.cache.get_card_by_seq_id(seq_id): if card := self.get_card_if_cached(seq_id):
return card return card
return next(self.get_cards(seq_id=seq_id)) return next(self.get_cards(seq_id=seq_id))
def get_card_if_cached(self, seq_id: SeqId) -> Card | None:
if card := self.card_cache.get_card_by_seq_id(seq_id):
return card
return None
def get_card_by_card_id(self, card_id: CardId) -> Card: def get_card_by_card_id(self, card_id: CardId) -> Card:
response = self.session.get(URL_UPDATE_CARD.format(card_id=card_id.raw_id)) json_data = self._get_json(URL_UPDATE_CARD.format(card_id=card_id.raw_id))
return Card.from_json(response.json()) return Card.from_json(json_data)
def get_user(self, user_id: UserId) -> UserInfo: def get_user(self, user_id: UserId) -> UserInfo:
response = self.session.get(URL_GET_USER.format(user_id=user_id.raw_id)) self._load_users()
return UserInfo.from_json(response.json()) return self.user_cache[user_id]
def _load_users(self) -> None:
if self.user_cache is not None:
return
request = requests.Request('GET', URL_GET_USERS)
user_cache = {}
for user_info in self._get_paginated(request, UserInfo.from_json):
user_cache[user_info.user_id] = user_info
self.user_cache = user_cache
logger.info('Loaded %s users', len(user_cache))
def get_tag(self, tag_id: TagId) -> TagInfo: def get_tag(self, tag_id: TagId) -> TagInfo:
response = self.session.get(URL_GET_TAG.format(tag_id=tag_id.raw_id)) json_data = self._get_json(URL_GET_TAG.format(tag_id=tag_id.raw_id))
return TagInfo.from_json(response.json()) return TagInfo.from_json(json_data)
def get_custom_field(self, custom_field_id: CustomFieldId) -> CustomFieldInfo: def get_custom_field(self, custom_field_id: CustomFieldId) -> CustomFieldInfo:
response = self.session.get( json_data = self._get_json(
URL_GET_CUSTOM_FIELD.format(custom_field_id=custom_field_id.raw_id), URL_GET_CUSTOM_FIELD.format(custom_field_id=custom_field_id.raw_id),
) )
return CustomFieldInfo.from_json(response.json()) return CustomFieldInfo.from_json(json_data)
def _invalidate_cache(self, card_id: CardId) -> None: def _invalidate_cache(self, card_id: CardId) -> None:
card = self.cache.remove(card_id) card = self.card_cache.remove(card_id)
if card: if card:
self.session.cache.delete( self.session.cache.delete(
requests=[self._get_cards_prepared_request(seq_id=card.seq_id)], requests=[self._get_cards_prepared_request(seq_id=card.seq_id)],
@ -185,7 +224,7 @@ class FavroClient:
card_id: CardId, card_id: CardId,
card_contents: CardContents, card_contents: CardContents,
) -> Card | None: ) -> Card | None:
if card := self.cache.remove(card_id): if card := self.card_cache.remove(card_id):
card = dataclasses.replace( card = dataclasses.replace(
card, card,
detailed_description=card_contents.description, detailed_description=card_contents.description,
@ -194,7 +233,7 @@ class FavroClient:
assignments=[], # TODO? assignments=[], # TODO?
dependencies=[], # TODO dependencies=[], # TODO
) )
self.cache.add_card(card) self.card_cache.add_card(card)
return card return card
def update_card_contents( def update_card_contents(
@ -220,7 +259,7 @@ class FavroClient:
} }
url = URL_UPDATE_CARD.format(card_id=card_id.raw_id) url = URL_UPDATE_CARD.format(card_id=card_id.raw_id)
logger.warning('Sending request: %s', url) logger.info('Sending PUT: %s', url)
response = self.session.put( response = self.session.put(
url, url,
json=json_body, json=json_body,
@ -243,9 +282,11 @@ class FavroClient:
request = self.session.prepare_request(base_request) request = self.session.prepare_request(base_request)
# Run query # Run query
logger.warning('Sending request: %s', request.url) logger.debug('Sending GET: %s', request.url)
response = self.session.send(request) response = self.session.send(request)
response.raise_for_status() response.raise_for_status()
if not response.from_cache:
logger.warning('Got new result: %s', request.url)
json = response.json() json = response.json()
for entity_json in json['entities']: for entity_json in json['entities']:
@ -257,3 +298,9 @@ class FavroClient:
page = json['page'] + 1 page = json['page'] + 1
request_id = json['requestId'] request_id = json['requestId']
num_pages = json['pages'] num_pages = json['pages']
def _get_json(self, url: str) -> dict[str, Any]:
logger.debug('Sending GET: %s', url)
response = self.session.get(url)
response.raise_for_status()
return response.json()

View File

@ -33,7 +33,9 @@ CARD_FILENAME_REGEX = r'^PAR\-(\d+)\.md$'
# Formatting # Formatting
def to_custom_field_value(custom_field: CustomField, field_def: CustomFieldInfo) -> str: def to_custom_field_value(
custom_field: CustomField, field_def: CustomFieldInfo,
) -> str | None:
value: CustomFieldItemId | list[CustomFieldItemId] = custom_field.value value: CustomFieldItemId | list[CustomFieldItemId] = custom_field.value
if field_def.type in {'Single select', 'Multiple select'}: if field_def.type in {'Single select', 'Multiple select'}:
items = [field_def.get_field_item(item_id) for item_id in value] items = [field_def.get_field_item(item_id) for item_id in value]
@ -41,21 +43,22 @@ def to_custom_field_value(custom_field: CustomField, field_def: CustomFieldInfo)
return items[0].name return items[0].name
if field_def.type in {'Color'}: if field_def.type in {'Color'}:
return custom_field.color return custom_field.color
assert False, 'Unknown type: ' + field_def.type return None
def to_custom_fields(card: Card, favro_client: FavroClient) -> dict[str, str]: def to_custom_fields(card: Card, favro_client: FavroClient) -> dict[str, str]:
custom_fields = {} custom_fields = {}
for field_assignment in card.custom_fields: for field_assignment in card.custom_fields:
field_def = favro_client.get_custom_field(field_assignment.custom_field_id) field_def = favro_client.get_custom_field(field_assignment.custom_field_id)
custom_fields[field_def.name] = to_custom_field_value( str_value = to_custom_field_value(field_assignment, field_def)
field_assignment, field_def, if str_value is not None:
) custom_fields[field_def.name] = str_value
del field_assignment del field_assignment, str_value
return custom_fields return custom_fields
def to_card_contents(card: Card, favro_client: FavroClient) -> str: def to_card_contents(card: Card, favro_client: FavroClient) -> CardContents:
logger.info('Getting card contents for card: PAR-%s', card.seq_id)
tags = [favro_client.get_tag(tag_id).name for tag_id in card.tags] tags = [favro_client.get_tag(tag_id).name for tag_id in card.tags]
assignments = [ assignments = [
favro_client.get_user(assignment.user).name for assignment in card.assignments favro_client.get_user(assignment.user).name for assignment in card.assignments
@ -67,6 +70,8 @@ def to_card_contents(card: Card, favro_client: FavroClient) -> str:
for dep in card.dependencies for dep in card.dependencies
if dep.is_before if dep.is_before
] ]
custom_fields = to_custom_fields(card, favro_client)
return CardContents( return CardContents(
CARD_IDENTIFIER_FORMAT.format(seq_id=card.seq_id.raw_id), CARD_IDENTIFIER_FORMAT.format(seq_id=card.seq_id.raw_id),
card.name, card.name,
@ -82,7 +87,7 @@ def to_card_contents(card: Card, favro_client: FavroClient) -> str:
is_archived=card.is_archived, is_archived=card.is_archived,
start_date=card.start_date, start_date=card.start_date,
due_date=card.due_date, due_date=card.due_date,
custom_fields=to_custom_fields(card, favro_client), custom_fields=custom_fields,
) )
@ -112,17 +117,26 @@ class FileSystemItem:
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class RootFileSystemItem(FileSystemItem): class RootFileSystemItem(FileSystemItem):
@staticmethod @staticmethod
def from_path_segment(segment: str) -> 'RootFileSystemItem': def from_path_segment(_segment: str) -> 'RootFileSystemItem':
return RootFileSystemItem() return RootFileSystemItem()
def __str__(self):
return '/'
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class OrganizationFileSystemItem(FileSystemItem): class CollectionFileSystemItem(FileSystemItem):
name: str collection_name: str
def __post_init__(self):
assert '/' not in self.collection_name
@staticmethod @staticmethod
def from_path_segment(segment: str) -> 'OrganizationFileSystemItem': def from_path_segment(segment: str) -> 'CollectionFileSystemItem':
return OrganizationFileSystemItem(segment) return CollectionFileSystemItem(segment)
def __str__(self):
return self.collection_name
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@ -135,6 +149,9 @@ class CardFileSystemItem(FileSystemItem):
return CardFileSystemItem(SeqId(int(m.group(1)))) return CardFileSystemItem(SeqId(int(m.group(1))))
return None return None
def __str__(self):
return CARD_FILENAME_FORMAT.format(seq_id=self.seq_id.raw_id)
def path_to_file_system_item( def path_to_file_system_item(
path_str: str, path_components: list[type[FileSystemItem]], path_str: str, path_components: list[type[FileSystemItem]],
@ -143,57 +160,102 @@ def path_to_file_system_item(
component = path_components[len(path)] component = path_components[len(path)]
return component.from_path_segment(path[-1] if path else None) return component.from_path_segment(path[-1] if path else None)
FAST_GETATTR = True
class FavroFuse(fuse.Fuse): class FavroFuse(fuse.Fuse):
"""Favro FileSystem in Userspace.""" """Favro FileSystem in Userspace."""
def __init__( def __init__(
self, self,
*,
favro_client: FavroClient, favro_client: FavroClient,
formatter: CardFileFormatter, formatter: CardFileFormatter,
collection_filter: frozenset[str],
**kwargs, **kwargs,
): ):
self.favro_client = favro_client self.favro_client = favro_client
self.formatter = formatter self.formatter = formatter
self.wiped_cards = set() self.wiped_cards = set()
# self.path_components = [RootFileSystemItem, OrganizationFileSystemItem, CardFileSystemItem] self.collection_filter = collection_filter
self.path_components = [RootFileSystemItem, CardFileSystemItem] self.path_components = [
RootFileSystemItem,
CollectionFileSystemItem,
CardFileSystemItem,
]
super().__init__(**kwargs) super().__init__(**kwargs)
def getattr(self, path: str) -> FavroStat | int: def getattr(self, path: str) -> FavroStat | int:
logger.info('getattr: %s', path)
file_system_item = path_to_file_system_item(path, self.path_components) file_system_item = path_to_file_system_item(path, self.path_components)
st = FavroStat() st = FavroStat()
if isinstance(file_system_item, RootFileSystemItem): if isinstance(file_system_item, RootFileSystemItem | CollectionFileSystemItem):
st.st_mode = stat.S_IFDIR | 0o755 st.st_mode = stat.S_IFDIR | 0o755
st.st_nlink = 2 st.st_nlink = 2
elif isinstance(file_system_item, CardFileSystemItem): elif isinstance(file_system_item, CardFileSystemItem):
card = self.favro_client.get_card(file_system_item.seq_id)
st.st_mode = stat.S_IFREG | 0o666 st.st_mode = stat.S_IFREG | 0o666
st.st_nlink = 1 st.st_nlink = 1
card = self.favro_client.get_card_if_cached(file_system_item.seq_id)
if not FAST_GETATTR and card is None:
card = self.favro_client.get_card(file_system_item.seq_id)
if card is not None:
st.st_size = len(self._format_card_file(card)) st.st_size = len(self._format_card_file(card))
st.st_ctime = int(card.creation_date.timestamp()) st.st_ctime = int(card.creation_date.timestamp())
else:
st.st_size = len(path)
st.st_mtime = st.st_ctime # TODO st.st_mtime = st.st_ctime # TODO
else: else:
return -errno.ENOENT return -errno.ENOENT
return st return st
def _is_allowed_collection(self, item: CollectionFileSystemItem) -> bool:
if self.collection_filter is None:
return True
return item.collection_name in self.collection_filter
def readdir(self, path: str, offset: int) -> Iterator[fuse.Direntry]: def readdir(self, path: str, offset: int) -> Iterator[fuse.Direntry]:
logger.warning('readdir(path=%s, offset=%s)', path, offset) logger.info('Reading directory: %s', path)
file_system_item = path_to_file_system_item(path, self.path_components)
yield fuse.Direntry('.') yield fuse.Direntry('.')
yield fuse.Direntry('..') yield fuse.Direntry('..')
for card in self.favro_client.get_todo_list_cards(): if isinstance(file_system_item, RootFileSystemItem):
yield fuse.Direntry(CARD_FILENAME_FORMAT.format(seq_id=card.seq_id.raw_id)) for collection in self.favro_client.get_collections():
item = CollectionFileSystemItem(collection.name.replace('/', ''))
if self._is_allowed_collection(item):
yield fuse.Direntry(str(item))
del collection, item
elif isinstance(file_system_item, CollectionFileSystemItem):
if not self._is_allowed_collection(file_system_item):
return # Yield nothing
# TODO: move into own function
for collection in self.favro_client.get_collections():
if collection.name.replace('/', '') == file_system_item.collection_name:
collection_id = collection.collection_id
del collection
for card in self.favro_client.get_cards(collection_id=collection_id):
yield fuse.Direntry(str(CardFileSystemItem(card.seq_id)))
del card
logger.info('Finished reading directory: %s', path)
def open(self, path: str, flags: int) -> int | None: def open(self, path: str, flags: int) -> int | None:
logger.info('Opening: %s', path)
file_system_item = path_to_file_system_item(path, self.path_components) file_system_item = path_to_file_system_item(path, self.path_components)
if not isinstance(file_system_item, CardFileSystemItem): if not isinstance(file_system_item, CardFileSystemItem):
return -errno.ENOENT return -errno.ENOENT
return None return None
def read(self, path: str, size: int, offset: int) -> bytes | int: def read(self, path: str, size: int, offset: int) -> bytes | int:
logger.info('Reading: %s', path)
# Check that this is a card file_system_item. # Check that this is a card file_system_item.
file_system_item = path_to_file_system_item(path, self.path_components) file_system_item = path_to_file_system_item(path, self.path_components)
if not isinstance(file_system_item, CardFileSystemItem): if not isinstance(file_system_item, CardFileSystemItem):
@ -214,6 +276,7 @@ class FavroFuse(fuse.Fuse):
return buf return buf
def write(self, path: str, written_buffer: bytes, offset: int) -> int: def write(self, path: str, written_buffer: bytes, offset: int) -> int:
logger.info('Writing: %s', path)
# Check that this is a card file_system_item. # Check that this is a card file_system_item.
file_system_item = path_to_file_system_item(path, self.path_components) file_system_item = path_to_file_system_item(path, self.path_components)
if not isinstance(file_system_item, CardFileSystemItem): if not isinstance(file_system_item, CardFileSystemItem):
@ -237,6 +300,7 @@ class FavroFuse(fuse.Fuse):
return len(written_buffer) return len(written_buffer)
def truncate(self, path: str, new_size: int): def truncate(self, path: str, new_size: int):
logger.info('Truncating: %s', path)
# Check that this is a card file_system_item. # Check that this is a card file_system_item.
file_system_item = path_to_file_system_item(path, self.path_components) file_system_item = path_to_file_system_item(path, self.path_components)
if not isinstance(file_system_item, CardFileSystemItem): if not isinstance(file_system_item, CardFileSystemItem):
@ -285,11 +349,12 @@ Userspace hello example
) )
def start_favro_fuse(favro_client: FavroClient): def start_favro_fuse(favro_client: FavroClient, collection_filter: frozenset[str]):
# TODO: logger.info('Starting favro FUSE with collection filter: %s', collection_filter)
server = FavroFuse( server = FavroFuse(
favro_client=favro_client, favro_client=favro_client,
formatter=CardFileFormatter(), formatter=CardFileFormatter(),
collection_filter=collection_filter,
version='%prog ' + fuse.__version__, version='%prog ' + fuse.__version__,
usage=HELP, usage=HELP,
dash_s_do='setsingle', dash_s_do='setsingle',

View File

@ -1,11 +1,14 @@
import dataclasses import dataclasses
import datetime import datetime
import re import re
import logging
import frontmatter import frontmatter
import marko import marko
import marko.md_renderer import marko.md_renderer
logger = logging.getLogger(__name__)
################################################################################ ################################################################################
# FrontMatter keys # FrontMatter keys
@ -65,6 +68,7 @@ class CardFileFormatter:
def format_card_contents(self, card: CardContents) -> str: def format_card_contents(self, card: CardContents) -> str:
"""Formats card contents. Mostly the inverse of [`parse_card_contents`].""" """Formats card contents. Mostly the inverse of [`parse_card_contents`]."""
logger.info('Formatting card: %s', card.identifier)
# Choose frontmatter data # Choose frontmatter data
frontmatter_data = {} frontmatter_data = {}
if self.obsidian_mode: if self.obsidian_mode:
@ -134,6 +138,8 @@ class CardFileFormatter:
2. Parses header 2. Parses header
3. Finds content. 3. Finds content.
""" """
logger.info('Parsing card contents (len %d)', len(contents))
fm = frontmatter.loads(contents) fm = frontmatter.loads(contents)
del contents del contents

View File

@ -17,3 +17,13 @@ def favro_username():
def favro_password(): def favro_password():
return secrets.load_or_fail('FAVRO_PASSWORD') return secrets.load_or_fail('FAVRO_PASSWORD')
def favro_collection_filter() -> frozenset[str]:
loaded = secrets.load('FAVRO_COLLECTION_FILTER')
if loaded is None:
return None
values = loaded.strip().split('\n')
values = [v.strip() for v in values]
values = [v for v in values if v]
return frozenset(values)

View File

@ -2,5 +2,6 @@ requests
requests-cache requests-cache
fuse-python fuse-python
secret_loader @ git+https://gitfub.space/Jmaa/secret_loader secret_loader @ git+https://gitfub.space/Jmaa/secret_loader
requests_util @ git+https://gitfub.space/Jmaa/requests_util
marko marko
python-frontmatter python-frontmatter

View File

@ -106,6 +106,7 @@ REQUIREMENTS_MAIN = [
'requests-cache', 'requests-cache',
'fuse-python', 'fuse-python',
'secret_loader @ git+https://gitfub.space/Jmaa/secret_loader', 'secret_loader @ git+https://gitfub.space/Jmaa/secret_loader',
'requests_util @ git+https://gitfub.space/Jmaa/requests_util',
'marko', 'marko',
'python-frontmatter', 'python-frontmatter',
] ]

View File

@ -0,0 +1,11 @@
from favro_sync.favro_fuse import to_card_contents
from .test_client import create_client, needs_secrets
@needs_secrets
def test_format_all_cards():
client = create_client()
for card in client.get_cards(todo_list=True):
contents = to_card_contents(card, client)
assert contents is not None