Skip to content
Snippets Groups Projects
Commit 7197d2b1 authored by Philipp Stadler's avatar Philipp Stadler
Browse files

refactor: split export_apkgs.py into a module

parent cc654950
No related branches found
No related tags found
1 merge request!1feat!: add special CSV deck field, comment support and skip fields by mapping to the empty string
......@@ -17,5 +17,5 @@ to-static-jsonp
COPY Pipfile Pipfile.lock /app/
RUN cd /app && pipenv install --system --deploy
COPY export_apkgs.py /app/export_apkgs.py
RUN echo "#!/bin/sh\n$(python-libfaketime)\n/app/export_apkgs.py \$@" > /bin/export_apkgs && chmod +x /bin/export_apkgs
COPY ./export_apkgs /app/export_apkgs
RUN echo "#!/bin/sh\n$(python-libfaketime)\npython3 -m /app/export_apkgs \$@" > /bin/export_apkgs && chmod +x /bin/export_apkgs
from .config import load_apkg_spec
from .exporter import ApkgExporter
from pathlib import Path
from tempfile import TemporaryDirectory
import argparse
def export_package_from_spec(spec_path: Path, args: argparse.Namespace) -> Path:
with TemporaryDirectory() as intermediate_dir:
package_directory = spec_path.parent
spec = load_apkg_spec(spec_path)
exporter = ApkgExporter(
spec=spec,
content_dir=package_directory,
intermediate_dir=Path(intermediate_dir),
args=args
)
return exporter.export()
from . import export_package_from_spec
from .config import APKG_SPEC_FILENAME
from pathlib import Path
import argparse
parser = argparse.ArgumentParser(
prog="export_apkgs",
description="Generates apkgs from a spec in a specified directory")
parser.add_argument('-c', '--content', default='content', help="path to YAML or directory tree that contains .apkg-spec.yaml, each spec generates an APKG")
parser.add_argument('-t', '--templates-dir', required=True)
parser.add_argument('-o', '--output-dir', help="directory to put the output apkgs in")
parser.add_argument('-n', '--dry-run', action='store_true', required=False, help="Don't actually build Anki files, but print the files that would be generated")
args = parser.parse_args()
content = Path(args.content)
if content.suffix == '.yaml' or content.suffix == '.yml':
# directly specified a YAML as argument
export_package_from_spec(content, args)
else:
# got a directory or directory tree to find specs in
for apkg_spec_path in Path(args.content).glob(f'**/{APKG_SPEC_FILENAME}'):
export_package_from_spec(apkg_spec_path, args)
from .model import ApkgSpec, TemplateSpec
from pathlib import Path
from yaml import load, CLoader
APKG_SPEC_FILENAME = '.apkg-spec.yaml'
TEMPLATE_SPEC_FILENAME = '.template-spec.yaml'
def load_template_spec(templates_dir: Path, template: str) -> TemplateSpec:
with open(templates_dir / template / TEMPLATE_SPEC_FILENAME) as spec_yaml:
spec_yaml = load(spec_yaml, Loader=CLoader)
return TemplateSpec(**spec_yaml)
def load_apkg_spec(spec_path: Path) -> ApkgSpec:
with open(spec_path) as spec_yaml:
spec_yaml = load(spec_yaml, Loader=CLoader)
return ApkgSpec(**spec_yaml)
class MissingResourceException(Exception):
pass
#!/usr/bin/env python3
from .config import load_template_spec
from .error import MissingResourceException
from .format import format_time
from .model import *
from anki.collection import Collection, ExportAnkiPackageOptions, ImportAnkiPackageOptions, ImportAnkiPackageRequest
from anki.import_export_pb2 import ImportAnkiPackageUpdateCondition
from anki.models import ChangeNotetypeRequest, NotetypeDict
from anki.notes import Note
from datetime import datetime, timezone
from datetime import datetime
from functools import reduce
from itertools import chain
from libfaketime import fake_time
from pathlib import Path
from pydantic import BaseModel, field_serializer, field_validator
from semver import Version
from tempfile import TemporaryDirectory
from typing import Literal
from yaml import load, CLoader
from os import getenv
from pathlib import Path
import argparse
import csv
APKG_SPEC_FILENAME = '.apkg-spec.yaml'
TEMPLATE_SPEC_FILENAME = '.template-spec.yaml'
parser = argparse.ArgumentParser(
prog="export_apkgs.py",
description="Generates apkgs from a spec in a specified directory")
parser.add_argument('-c', '--content', default='content', help="path to YAML or directory tree that contains .apkg-spec.yaml, each spec generates an APKG")
parser.add_argument('-t', '--templates-dir', required=True)
parser.add_argument('-o', '--output-dir', help="directory to put the output apkgs in")
parser.add_argument('-n', '--dry-run', action='store_true', required=False, help="Don't actually build Anki files, but print the files that would be generated")
class NoteType(BaseModel):
id: datetime
name: str
fields: list[str]
class CardType(BaseModel):
name: str
template: Path
class ImportApkg(BaseModel):
note_type: str
class ImportCsv(BaseModel):
content_version: datetime
delimiter: str
note_type: str
file_patterns: list[str]|None
deck_name_pattern: str
fields_mapping: list[str]
fields_static: dict[str, str]
tags: list[str]
class ImportImages(BaseModel):
content_version: datetime
note_type: str
deck_name_pattern: str
fields_mapping: dict[str, str]
class TemplateSpec(BaseModel):
template_version: datetime
note_type: NoteType
card_types: list[CardType]
resource_paths: list[Path]
class ApkgSpec(BaseModel):
# semver of the content version, appended to the filename after a dash
content_version: str
templates: list[str]
content: list[
dict[Literal['import_apkg'], ImportApkg]|
dict[Literal['import_csv'], ImportCsv]|
dict[Literal['import_images'], ImportImages]
]
resource_paths: list[Path]
@field_serializer('content_version')
def dump_content_version(self, ver: Version):
return str(ver)
@field_validator('content_version')
def validate_content_version(cls, version):
Version.parse(version)
return version
def load_apkg_spec(spec_path: Path) -> ApkgSpec:
with open(spec_path) as spec_yaml:
spec_yaml = load(spec_yaml, Loader=CLoader)
return ApkgSpec(**spec_yaml)
def load_template_spec(templates_dir: Path, template: str) -> TemplateSpec:
with open(templates_dir / template / TEMPLATE_SPEC_FILENAME) as spec_yaml:
spec_yaml = load(spec_yaml, Loader=CLoader)
return TemplateSpec(**spec_yaml)
def format_time(time: datetime):
""" Formats the given time in a format suitable for faketime """
return time.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
TEMP_DECK_NAME = 'just_imported'
class ApkgExporter:
spec: ApkgSpec
content_dir: Path
......@@ -213,6 +132,8 @@ class ApkgExporter:
self.add_resource(col, resources_path)
elif resources_path.is_dir():
self.add_resources(col, resources_path.glob('*'))
else:
raise MissingResourceException(f'resource {resources_path} not found or is not a file or directory')
def add_resource(self, col: Collection, resource_path: Path):
actual_path = col.media.add_file(resource_path)
......@@ -532,26 +453,3 @@ class ApkgExporter:
for key, value in replacements.items():
pattern = pattern.replace('{{' + key + '}}', value)
return pattern
def export_package_from_spec(spec_path: Path, args: argparse.Namespace) -> Path:
with TemporaryDirectory() as intermediate_dir:
package_directory = spec_path.parent
spec = load_apkg_spec(spec_path)
exporter = ApkgExporter(
spec=spec,
content_dir=package_directory,
intermediate_dir=Path(intermediate_dir),
args=args
)
return exporter.export()
if __name__ == "__main__":
args = parser.parse_args()
content = Path(args.content)
if content.suffix == '.yaml' or content.suffix == '.yml':
# directly specified a YAML as argument
export_package_from_spec(content, args)
else:
# got a directory or directory tree to find specs in
for apkg_spec_path in Path(args.content).glob(f'**/{APKG_SPEC_FILENAME}'):
export_package_from_spec(apkg_spec_path, args)
from datetime import datetime, timezone
def format_time(time: datetime):
""" Formats the given time in a format suitable for faketime """
return time.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, field_serializer, field_validator
from semver import Version
from typing import Literal
class NoteType(BaseModel):
id: datetime
name: str
fields: list[str]
class CardType(BaseModel):
name: str
template: Path
class ImportApkg(BaseModel):
note_type: str
class ImportCsv(BaseModel):
content_version: datetime
delimiter: str
note_type: str
file_patterns: list[str]|None
deck_name_pattern: str
fields_mapping: list[str]
fields_static: dict[str, str]
tags: list[str]
class ImportImages(BaseModel):
content_version: datetime
note_type: str
deck_name_pattern: str
fields_mapping: dict[str, str]
class TemplateSpec(BaseModel):
template_version: datetime
note_type: NoteType
card_types: list[CardType]
resource_paths: list[Path]
class ApkgSpec(BaseModel):
# semver of the content version, appended to the filename after a dash
content_version: str
templates: list[str]
content: list[
dict[Literal['import_apkg'], ImportApkg]|
dict[Literal['import_csv'], ImportCsv]|
dict[Literal['import_images'], ImportImages]
]
resource_paths: list[Path]
@field_serializer('content_version')
def dump_content_version(self, ver: Version):
return str(ver)
@field_validator('content_version')
def validate_content_version(cls, version):
Version.parse(version)
return version
content_version: 1.0.0
templates:
- q_a
content:
- import_csv:
content_version: 2024-01-19 19:00:00+00:00
note_type: Q/A Testnotetype
file_patterns:
- '*.csv'
delimiter: ';'
# and to making one deck per card type
deck_name_pattern: '{{card_type}}'
fields_mapping:
- guid
- Question
- Answer
fields_static: {}
tags: []
resource_paths:
- missing.txt
test-csv-note-1;What is the scientific name of the only tapir living in Asia?;Tapirus indicus
\ No newline at end of file
from anki.collection import Collection, ImportAnkiPackageRequest, ImportAnkiPackageOptions
from anki.import_export_pb2 import ImportAnkiPackageUpdateCondition
from export_apkgs import export_package_from_spec
from dataclasses import dataclass
from export_apkgs import export_package_from_spec
from export_apkgs.error import MissingResourceException
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
......@@ -18,6 +19,7 @@ CONTENT_PATH_CSV = 'test/fixtures/csv'
CONTENT_PATH_CSV_UPDATED_CONTENT = 'test/fixtures/csv_updated_content'
CONTENT_PATH_FIELDS_STATIC = 'test/fixtures/fields_static'
CONTENT_PATH_ANKI_TEXT = 'test/fixtures/anki_text'
CONTENT_PATH_RESOURCE_MISSING = 'test/fixtures/resource_missing'
TEMPLATES_PATH = 'test/fixtures/templates'
TEMPLATES_PATH_UPDATED = 'test/fixtures/templates_updated'
......@@ -403,6 +405,20 @@ class TestExportApkgs(unittest.TestCase):
self.assertEqual(
len(col.find_cards('deck:"Tapirs::Species"')),
1)
def test_missing_resource(self):
"""
Referenced resource file does not exist
"""
with TemporaryDirectory() as first_export_dir:
args_first = MockArgs(
content=CONTENT_PATH_RESOURCE_MISSING,
templates_dir=TEMPLATES_PATH,
output_dir=first_export_dir,
dry_run=False)
self.assertRaises(MissingResourceException, lambda: export_package_from_spec(
Path(CONTENT_PATH_RESOURCE_MISSING) / '.apkg-spec.yaml',
args_first))
def find_template(col: Collection, name: str):
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment