Skip to content

Commit

Permalink
Merge pull request #321 from pyinat/data-dir
Browse files Browse the repository at this point in the history
Allow config to be read from an alternate path
  • Loading branch information
JWCook committed May 30, 2023
2 parents 25bccfc + ba3284c commit 9babc88
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 120 deletions.
Binary file added assets/demo_images/78513963_b.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions naturtag/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from naturtag.app.settings_menu import SettingsMenu
from naturtag.app.style import fa_icon, set_theme
from naturtag.app.threadpool import ThreadPool
from naturtag.constants import APP_DIR, APP_ICON, APP_LOGO, ASSETS_DIR, DOCS_URL, REPO_URL
from naturtag.client import ImageSession, iNatDbClient
from naturtag.constants import APP_ICON, APP_LOGO, ASSETS_DIR, DOCS_URL, REPO_URL
from naturtag.controllers import ImageController, ObservationController, TaxonController
from naturtag.settings import Settings, setup
from naturtag.widgets import VerticalLayout, init_handler
Expand Down Expand Up @@ -50,9 +51,11 @@ def __init__(self, settings: Settings):
)

# Run any first-time setup steps, if needed
setup(settings)
self.settings = settings
self.user_dirs = UserDirs(settings)
setup(settings)
self.client = iNatDbClient(settings.db_path)
self.img_session = ImageSession(settings.image_cache_path)

# Controllers
self.settings_menu = SettingsMenu(self.settings)
Expand Down Expand Up @@ -187,7 +190,7 @@ def open_about(self):
repo_link = f"<a href='{REPO_URL}'>{REPO_URL}</a>"
license_link = f"<a href='{REPO_URL}/LICENSE'>MIT License</a>"
attribution = f'Ⓒ {datetime.now().year} Jordan Cook, {license_link}'
app_dir_link = f"<a href='{APP_DIR}'>{APP_DIR}</a>"
app_dir_link = f"<a href='{self.settings.data_dir}'>{self.settings.data_dir}</a>"

about.setText(
f'<b>Naturtag v{version}</b><br/>'
Expand Down
9 changes: 5 additions & 4 deletions naturtag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
from rich.logging import RichHandler
from rich.table import Column, Table

from naturtag.constants import APP_DIR, CLI_COMPLETE_DIR, DB_PATH
from naturtag.constants import CLI_COMPLETE_DIR
from naturtag.metadata import KeywordMetadata, MetaMetadata, refresh_tags, strip_url, tag_images
from naturtag.settings import setup
from naturtag.settings import Settings, setup
from naturtag.utils.image_glob import get_valid_image_paths

CODE_BLOCK = re.compile(r'```\n\s*(.+?)```\n', re.DOTALL)
Expand All @@ -36,7 +36,8 @@ class TaxonParam(click.ParamType):
name = 'taxon'

def shell_complete(self, ctx, param, incomplete):
results = TaxonAutocompleter(DB_PATH).search(incomplete)
db_path = Settings.read().db_path
results = TaxonAutocompleter(db_path).search(incomplete)
grouped_results = defaultdict(list)
for taxon in results:
grouped_results[taxon.id].append(taxon.name)
Expand Down Expand Up @@ -76,7 +77,7 @@ def main(ctx, verbose, version):
if version:
v = pkg_version('naturtag')
click.echo(f'naturtag v{v}')
click.echo(f'User data directory: {APP_DIR}')
click.echo(f'User data directory: {Settings.read().data_dir}')
ctx.exit()
elif not ctx.invoked_subcommand:
click.echo(ctx.get_help())
Expand Down
86 changes: 33 additions & 53 deletions naturtag/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@
from hashlib import md5
from itertools import chain
from logging import getLogger
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, Iterable, Iterator, Optional
from typing import TYPE_CHECKING, Optional

from pyinaturalist import ClientSession, Observation, Photo, Taxon, WrapperPaginator, iNatClient
from pyinaturalist.controllers import ObservationController, TaxonController
from pyinaturalist.converters import format_file_size
from pyinaturalist_convert.db import (
DbObservation,
DbUser,
get_db_taxa,
get_session,
save_observations,
save_taxa,
)

# from pyinaturalist_convert.db import get_db_observations
from pyinaturalist_convert.db import get_db_observations, get_db_taxa, save_observations, save_taxa
from requests_cache import SQLiteDict

from naturtag.constants import DB_PATH, IMAGE_CACHE, ROOT_TAXON_ID, PathOrStr
Expand All @@ -31,8 +23,10 @@
class iNatDbClient(iNatClient):
"""API client class that uses a local SQLite database to cache observations and taxa (when searched by ID)"""

def __init__(self, **kwargs):
def __init__(self, db_path: Path = DB_PATH, **kwargs):
kwargs.setdefault('cache_control', False)
super().__init__(**kwargs)
self.db_path = db_path
self.taxa = TaxonDbController(self)
self.observations = ObservationDbController(self, taxon_controller=self.taxa)

Expand All @@ -50,7 +44,10 @@ def from_ids(
"""Get observations by ID; first from the database, then from the API"""
# Get any observations saved in the database (unless refreshing)
start = time()
observations = [] if refresh else list(get_db_observations(DB_PATH, ids=observation_ids))
if refresh:
observations = []
else:
observations = list(get_db_observations(self.client.db_path, ids=observation_ids))
logger.debug(f'{len(observations)} observations found in database')

# Get remaining observations from the API and save to the database
Expand All @@ -59,7 +56,7 @@ def from_ids(
logger.debug(f'Fetching remaining {len(remaining_ids)} observations from API')
api_results = super().from_ids(*remaining_ids, **params).all()
observations.extend(api_results)
save_observations(api_results, DB_PATH)
save_observations(api_results, self.client.db_path)

# Add full taxonomy to observations, if specified
if taxonomy:
Expand All @@ -71,7 +68,7 @@ def from_ids(
def search(self, **params) -> WrapperPaginator[Observation]:
"""Search observations, and save results to the database (for future reference by ID)"""
results = super().search(**params).all()
save_observations(results, DB_PATH)
save_observations(results, self.client.db_path)
return WrapperPaginator(results)

def get_user_observations(
Expand Down Expand Up @@ -114,13 +111,15 @@ def from_ids(
logger.debug(f'Fetching remaining {len(remaining_ids)} taxa from API')
api_results = super().from_ids(*remaining_ids, **params).all() if remaining_ids else []
taxa.extend(api_results)
save_taxa(api_results, DB_PATH)
save_taxa(api_results, self.client.db_path)

logger.debug(f'Finished in {time()-start:.2f} seconds')
return WrapperPaginator(taxa)

def _get_db_taxa(self, taxon_ids: list[int], accept_partial: bool = False):
db_results = list(get_db_taxa(DB_PATH, ids=taxon_ids, accept_partial=accept_partial))
db_results = list(
get_db_taxa(self.client.db_path, ids=taxon_ids, accept_partial=accept_partial)
)
if not accept_partial:
db_results = self._get_taxonomy(db_results)
return db_results
Expand Down Expand Up @@ -156,9 +155,9 @@ def search(self, **params) -> WrapperPaginator[Taxon]:

# TODO: Set expiration on 'original' and 'large' size images using URL patterns
class ImageSession(ClientSession):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.image_cache = SQLiteDict(IMAGE_CACHE, 'images', no_serializer=True)
def __init__(self, *args, cache_path: Path = IMAGE_CACHE, **kwargs):
super().__init__(*args, per_second=5, per_minute=400, **kwargs)
self.image_cache = SQLiteDict(cache_path, 'images', no_serializer=True)

def get_image(
self, photo: Photo, url: Optional[str] = None, size: Optional[str] = None
Expand All @@ -179,10 +178,20 @@ def get_image(
return data

def get_pixmap(
self, photo: Optional[Photo] = None, url: Optional[str] = None, size: Optional[str] = None
self,
path: Optional[PathOrStr] = None,
photo: Optional[Photo] = None,
url: Optional[str] = None,
size: Optional[str] = None,
) -> 'QPixmap':
"""Fetch a pixmap from either a local path or remote URL.
This does not render the image, so it is safe to run from any thread.
"""
from PySide6.QtGui import QPixmap

if path:
return QPixmap(str(path))

if url and not photo:
photo = Photo(url=url)
pixmap = QPixmap()
Expand All @@ -195,36 +204,6 @@ def cache_size(self) -> str:
return f'{size} ({len(self.image_cache)} files)'


# TODO: Update this in pyinaturalist_convert.db
def get_db_observations(
db_path: PathOrStr = DB_PATH,
ids: Optional[Iterable[int]] = None,
username: Optional[str] = None,
limit: Optional[int] = None,
order_by_date: bool = False,
) -> Iterator[Observation]:
"""Load observation records and associated taxa from SQLite"""
from sqlalchemy import select

stmt = (
select(DbObservation)
.join(DbObservation.taxon, isouter=True)
.join(DbObservation.user, isouter=True)
)
if ids:
stmt = stmt.where(DbObservation.id.in_(list(ids))) # type: ignore
if username:
stmt = stmt.where(DbUser.login == username)
if limit:
stmt = stmt.limit(limit)
if order_by_date:
stmt = stmt.order_by(DbObservation.observed_on.desc())

with get_session(db_path) as session:
for obs in session.execute(stmt):
yield obs[0].to_model()


def get_url_hash(url: str) -> str:
"""Generate a hash to use as a cache key from an image URL, appended with the file extension
Expand All @@ -236,5 +215,6 @@ def get_url_hash(url: str) -> str:
return f'{thumbnail_hash}.{ext}'


INAT_CLIENT = iNatDbClient(cache_control=False)
IMG_SESSION = ImageSession(expire_after=-1, per_second=5, per_minute=400)
# TODO: Refactoring to not depend on global session and client objects
INAT_CLIENT = iNatDbClient()
IMG_SESSION = ImageSession()
2 changes: 1 addition & 1 deletion naturtag/controllers/taxon_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class TaxonController(BaseController):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user_taxa = UserTaxa.read()
self.user_taxa = UserTaxa.read(self.settings.user_taxa_path)

self.root = HorizontalLayout(self)
self.root.setAlignment(Qt.AlignLeft)
Expand Down
2 changes: 1 addition & 1 deletion naturtag/controllers/taxon_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def __init__(self, settings: Settings):
self.setAlignment(Qt.AlignTop)

# Taxon name autocomplete
self.autocomplete = TaxonAutocomplete()
self.autocomplete = TaxonAutocomplete(settings)
search_group = self.add_group('Search', self, width=400)
search_group.addWidget(self.autocomplete)
self.autocomplete.returnPressed.connect(self.search)
Expand Down
Loading

0 comments on commit 9babc88

Please sign in to comment.