diff --git a/naturtag/app/__init__.py b/naturtag/app/__init__.py
index e69de29b..4d77b373 100644
--- a/naturtag/app/__init__.py
+++ b/naturtag/app/__init__.py
@@ -0,0 +1,2 @@
+# flake8: noqa: F401
+from naturtag.app.app import NaturtagApp
diff --git a/naturtag/app/app.py b/naturtag/app/app.py
index 99623684..f3cf986d 100644
--- a/naturtag/app/app.py
+++ b/naturtag/app/app.py
@@ -39,29 +39,41 @@
logger = getLogger(__name__)
-# TODO: Global access to Settings object instead of passing it around everywhere?
-class MainWindow(QMainWindow):
- def __init__(self, settings: Settings):
- super().__init__()
- self.setWindowTitle('Naturtag')
- self.resize(*settings.window_size)
- self.threadpool = ThreadPool()
- log_handler = init_handler(
- settings.log_level, root_level=settings.log_level_external, logfile=settings.logfile
- )
+class NaturtagApp(QApplication):
+ def __init__(self, *args, settings: Settings, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setApplicationName('Naturtag')
+ self.setOrganizationName('pyinat')
+ self.setApplicationVersion(pkg_version('naturtag'))
# Run any first-time setup steps, if needed
setup(settings)
+
+ # Globally available application objects
self.settings = settings
- self.user_dirs = UserDirs(settings)
self.client = iNatDbClient(settings.db_path)
self.img_session = ImageSession(settings.image_cache_path)
+ self.log_handler = init_handler(
+ self.settings.log_level,
+ root_level=self.settings.log_level_external,
+ logfile=self.settings.logfile,
+ )
+ self.threadpool = ThreadPool()
+ self.user_dirs = UserDirs(self.settings)
+
+
+class MainWindow(QMainWindow):
+ def __init__(self, app: NaturtagApp):
+ super().__init__()
+ self.setWindowTitle('Naturtag')
+ self.resize(*app.settings.window_size)
+ self.app = app
# Controllers
- self.settings_menu = SettingsMenu(self.settings)
- self.image_controller = ImageController(self.settings, self.threadpool)
- self.taxon_controller = TaxonController(self.settings, self.threadpool)
- self.observation_controller = ObservationController(self.settings, self.threadpool)
+ self.settings_menu = SettingsMenu()
+ self.image_controller = ImageController()
+ self.taxon_controller = TaxonController()
+ self.observation_controller = ObservationController()
# Connect controllers and their widgets to statusbar info
self.settings_menu.on_message.connect(self.info)
@@ -108,12 +120,14 @@ def __init__(self, settings: Settings):
self.root_widget = QWidget()
self.root = VerticalLayout(self.root_widget)
self.root.addWidget(self.tabs)
- self.root.addWidget(self.threadpool.progress)
+ self.root.addWidget(self.app.threadpool.progress)
self.setCentralWidget(self.root_widget)
# Optionally show Logs tab
- self.log_tab_idx = self.tabs.addTab(log_handler.widget, fa_icon('fa.file-text-o'), 'Logs')
- self.tabs.setTabVisible(self.log_tab_idx, self.settings.show_logs)
+ self.log_tab_idx = self.tabs.addTab(
+ self.app.log_handler.widget, fa_icon('fa.file-text-o'), 'Logs'
+ )
+ self.tabs.setTabVisible(self.log_tab_idx, self.app.settings.show_logs)
# Switch to differet tab if requested from Photos tab
self.image_controller.on_select_taxon_tab.connect(
@@ -124,11 +138,11 @@ def __init__(self, settings: Settings):
)
# Connect file picker <--> recent/favorite dirs
- self.image_controller.gallery.on_load_images.connect(self.user_dirs.add_recent_dirs)
- self.user_dirs.on_dir_open.connect(self.image_controller.gallery.load_file_dialog)
+ self.image_controller.gallery.on_load_images.connect(self.app.user_dirs.add_recent_dirs)
+ self.app.user_dirs.on_dir_open.connect(self.image_controller.gallery.load_file_dialog)
# Toolbar actions
- self.toolbar = Toolbar(self, self.user_dirs)
+ self.toolbar = Toolbar(self, self.app.user_dirs)
self.toolbar.run_button.triggered.connect(self.image_controller.run)
self.toolbar.refresh_tags_button.triggered.connect(self.image_controller.refresh)
self.toolbar.open_button.triggered.connect(self.image_controller.gallery.load_file_dialog)
@@ -154,7 +168,7 @@ def __init__(self, settings: Settings):
)
# Debug
- if settings.debug:
+ if self.app.settings.debug:
QShortcut(QKeySequence('F9'), self).activated.connect(self.reload_qss)
demo_images = list((ASSETS_DIR / 'demo_images').glob('*.jpg'))
self.image_controller.gallery.load_images(demo_images) # type: ignore
@@ -162,7 +176,7 @@ def __init__(self, settings: Settings):
def closeEvent(self, _):
"""Save settings before closing the app"""
- self.settings.write()
+ self.app.settings.write()
self.taxon_controller.user_taxa.write()
def info(self, message: str):
@@ -191,7 +205,7 @@ def open_about(self):
repo_link = f"{REPO_URL}"
license_link = f"MIT License"
attribution = f'Ⓒ {datetime.now().year} Jordan Cook, {license_link}'
- app_dir_link = f"{self.settings.data_dir}"
+ app_dir_link = f"{self.app.settings.data_dir}"
about.setText(
f'Naturtag v{version}
'
@@ -203,7 +217,7 @@ def open_about(self):
def reload_qss(self):
"""Reload Qt stylesheet"""
- set_theme(dark_mode=self.settings.dark_mode)
+ set_theme(dark_mode=self.app.settings.dark_mode)
# TODO: progress spinner
def reset_db(self):
@@ -215,7 +229,7 @@ def reset_db(self):
)
if response == QMessageBox.Yes:
self.info('Resetting database...')
- setup(self.settings, overwrite=True)
+ setup(self.app.settings, overwrite=True)
self.info('Database reset complete')
def show_settings(self):
@@ -240,14 +254,14 @@ def toggle_log_tab(self, checked: bool = True):
def main():
- app = QApplication(sys.argv)
+ settings = Settings.read()
+ app = NaturtagApp(sys.argv, settings=settings)
splash = QSplashScreen(QPixmap(str(APP_LOGO)).scaledToHeight(512))
splash.show()
app.setWindowIcon(QIcon(QPixmap(str(APP_ICON))))
- settings = Settings.read()
set_theme(dark_mode=settings.dark_mode)
- window = MainWindow(settings)
+ window = MainWindow(app)
window.show()
splash.finish(window)
sys.exit(app.exec())
diff --git a/naturtag/app/settings_menu.py b/naturtag/app/settings_menu.py
index fcc61966..bbdb0bc1 100644
--- a/naturtag/app/settings_menu.py
+++ b/naturtag/app/settings_menu.py
@@ -25,78 +25,116 @@
class SettingsMenu(BaseController):
"""Application settings menu, with input widgets connected to values in settings file"""
- def __init__(self, settings: Settings):
- super().__init__(settings)
- self.settings = settings
+ def __init__(self):
+ super().__init__()
self.settings_layout = VerticalLayout(self)
# Dictionary of locale codes and display names
self.locales = {k: f'{v} ({k})' for k, v in read_locales().items()}
# iNaturalist settings
inat = self.add_group('iNaturalist', self.settings_layout)
- inat.addLayout(TextSetting(settings, icon_str='fa.user', setting_attr='username'))
+ inat.addLayout(
+ TextSetting(
+ self.app.settings,
+ icon_str='fa.user',
+ setting_attr='username',
+ )
+ )
inat.addLayout(
ChoiceAltDisplaySetting(
- settings,
+ self.app.settings,
icon_str='fa.globe',
setting_attr='locale',
choices=self.locales,
)
)
inat.addLayout(
- ToggleSetting(settings, icon_str='fa.language', setting_attr='search_locale')
+ ToggleSetting(
+ self.app.settings,
+ icon_str='fa.language',
+ setting_attr='search_locale',
+ )
)
inat.addLayout(
IntSetting(
- settings, icon_str='mdi.home-city-outline', setting_attr='preferred_place_id'
+ self.app.settings,
+ icon_str='mdi.home-city-outline',
+ setting_attr='preferred_place_id',
)
)
inat.addLayout(
- ToggleSetting(settings, icon_str='mdi6.cat', setting_attr='casual_observations')
+ ToggleSetting(
+ self.app.settings,
+ icon_str='mdi6.cat',
+ setting_attr='casual_observations',
+ )
)
self.all_ranks = ToggleSetting(
- settings, icon_str='fa.chevron-circle-up', setting_attr='all_ranks'
+ self.app.settings,
+ icon_str='fa.chevron-circle-up',
+ setting_attr='all_ranks',
)
inat.addLayout(self.all_ranks)
# Metadata settings
metadata = self.add_group('Metadata', self.settings_layout)
metadata.addLayout(
- ToggleSetting(settings, icon_str='fa.language', setting_attr='common_names')
+ ToggleSetting(
+ self.app.settings,
+ icon_str='fa.language',
+ setting_attr='common_names',
+ )
)
metadata.addLayout(
- ToggleSetting(settings, icon_str='mdi.file-tree', setting_attr='hierarchical')
+ ToggleSetting(
+ self.app.settings,
+ icon_str='mdi.file-tree',
+ setting_attr='hierarchical',
+ )
)
metadata.addLayout(
- ToggleSetting(settings, icon_str='fa5s.file-code', setting_attr='sidecar')
+ ToggleSetting(
+ self.app.settings,
+ icon_str='fa5s.file-code',
+ setting_attr='sidecar',
+ )
)
metadata.addLayout(
ToggleSetting(
- settings, icon_str='fa5s.file-alt', setting_attr='exif', setting_title='EXIF'
+ self.app.settings,
+ icon_str='fa5s.file-alt',
+ setting_attr='exif',
+ setting_title='EXIF',
)
)
metadata.addLayout(
ToggleSetting(
- settings, icon_str='fa5s.file-alt', setting_attr='iptc', setting_title='IPTC'
+ self.app.settings,
+ icon_str='fa5s.file-alt',
+ setting_attr='iptc',
+ setting_title='IPTC',
)
)
metadata.addLayout(
ToggleSetting(
- settings, icon_str='fa5s.file-alt', setting_attr='xmp', setting_title='XMP'
+ self.app.settings,
+ icon_str='fa5s.file-alt',
+ setting_attr='xmp',
+ setting_title='XMP',
)
)
# User data settings
user_data = self.add_group('User Data', self.settings_layout)
use_last_dir = ToggleSetting(
- settings,
+ self.app.settings,
icon_str='mdi.folder-clock-outline',
setting_attr='use_last_dir',
setting_title='Use last directory',
)
user_data.addLayout(use_last_dir)
self.default_image_dir = PathSetting(
- settings,
+ self.app.settings,
icon_str='fa5.images',
setting_attr='default_image_dir',
setting_title='Default image directory',
@@ -105,7 +143,7 @@ def __init__(self, settings: Settings):
user_data.addLayout(self.default_image_dir)
# Disable default_image_dir option when use_last_dir is enabled
- self.default_image_dir.setEnabled(not settings.use_last_dir)
+ self.default_image_dir.setEnabled(not self.app.settings.use_last_dir)
use_last_dir.on_click.connect(
lambda checked: self.default_image_dir.setEnabled(not checked)
)
@@ -113,7 +151,7 @@ def __init__(self, settings: Settings):
# Display settings
display = self.add_group('Display', self.settings_layout)
self.dark_mode = ToggleSetting(
- settings,
+ self.app.settings,
icon_str='mdi.theme-light-dark',
setting_attr='dark_mode',
)
@@ -122,13 +160,13 @@ def __init__(self, settings: Settings):
# Debug settings
debug = self.add_group('Debug', self.settings_layout)
self.show_logs = ToggleSetting(
- settings,
+ self.app.settings,
icon_str='fa.file-text-o',
setting_attr='show_logs',
)
debug.addLayout(self.show_logs)
self.log_level = ChoiceSetting(
- settings,
+ self.app.settings,
icon_str='fa.thermometer-2',
setting_attr='log_level',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
@@ -145,7 +183,7 @@ def add_group(self, *args, **kwargs):
def closeEvent(self, event):
"""Save settings when closing the window"""
- self.settings.write()
+ self.app.settings.write()
self.on_message.emit('Settings saved')
event.accept()
diff --git a/naturtag/cli.py b/naturtag/cli.py
index dccc4d25..17b17d1c 100644
--- a/naturtag/cli.py
+++ b/naturtag/cli.py
@@ -21,9 +21,9 @@
from rich.table import Column, Table
from naturtag.constants import CLI_COMPLETE_DIR
-from naturtag.metadata import KeywordMetadata, MetaMetadata, refresh_tags, strip_url, tag_images
+from naturtag.metadata import KeywordMetadata, MetaMetadata, refresh_tags, tag_images
from naturtag.settings import Settings, setup
-from naturtag.utils.image_glob import get_valid_image_paths
+from naturtag.utils import get_valid_image_paths, strip_url
CODE_BLOCK = re.compile(r'```\n\s*(.+?)```\n', re.DOTALL)
CODE_INLINE = re.compile(r'`([^`]+?)`')
diff --git a/naturtag/client.py b/naturtag/client.py
index 9136bfbe..1f82d0dd 100644
--- a/naturtag/client.py
+++ b/naturtag/client.py
@@ -30,6 +30,34 @@ def __init__(self, db_path: Path = DB_PATH, **kwargs):
self.taxa = TaxonDbController(self)
self.observations = ObservationDbController(self, taxon_controller=self.taxa)
+ def from_ids(
+ self, observation_id: Optional[int] = None, taxon_id: Optional[int] = None
+ ) -> Optional[Observation]:
+ """Get an iNaturalist observation and/or taxon matching the specified ID(s). If only a taxon ID
+ is provided, the observation will be a placeholder with only the taxon field populated.
+ """
+ # Get observation record, if available
+ if observation_id:
+ observation = self.observations(observation_id, refresh=True)
+ taxon_id = observation.taxon.id
+ # Otherwise, use an empty placeholder observation
+ else:
+ observation = Observation()
+
+ # Observation.taxon doesn't include ancestors, so we always need to fetch the full taxon record
+ observation.taxon = self.taxa(taxon_id)
+ if not observation.taxon:
+ logger.warning(f'No taxon found: {taxon_id}')
+ return None
+
+ # If there's a taxon only (no observation), check for any taxonomy changes
+ # TODO: Add this to pyinat: https://github.com/pyinat/pyinaturalist/issues/444
+ synonyms = observation.taxon.current_synonymous_taxon_ids
+ if not observation_id and not observation.taxon.is_active and len(synonyms or []) == 1:
+ observation.taxon = self.taxa(synonyms[0], refresh=True)
+
+ return observation
+
# TODO: Expiration?
class ObservationDbController(ObservationController):
@@ -240,8 +268,3 @@ def get_url_hash(url: str) -> str:
thumbnail_hash = md5(url.encode()).hexdigest()
ext = Photo(url=url).ext
return f'{thumbnail_hash}.{ext}'
-
-
-# TODO: Refactor to depend on app.client and session objects instead of these module-level globals
-INAT_CLIENT = iNatDbClient()
-IMG_SESSION = ImageSession()
diff --git a/naturtag/controllers/__init__.py b/naturtag/controllers/__init__.py
index ee1c17c4..b2d04f5c 100644
--- a/naturtag/controllers/__init__.py
+++ b/naturtag/controllers/__init__.py
@@ -1,5 +1,16 @@
# flake8: noqa: F401
# isort: skip_file
+from typing import TYPE_CHECKING
+from PySide6.QtWidgets import QApplication
+
+
+if TYPE_CHECKING:
+ from naturtag.app import NaturtagApp
+
+
+def get_app() -> 'NaturtagApp':
+ return QApplication.instance()
+
from naturtag.controllers.base_controller import BaseController
from naturtag.controllers.image_gallery import ImageGallery
diff --git a/naturtag/controllers/base_controller.py b/naturtag/controllers/base_controller.py
index 00cb6d39..7090707f 100644
--- a/naturtag/controllers/base_controller.py
+++ b/naturtag/controllers/base_controller.py
@@ -1,18 +1,19 @@
-from typing import Optional
+from typing import TYPE_CHECKING
from PySide6.QtCore import Signal
+from PySide6.QtWidgets import QApplication
-from naturtag.app.threadpool import ThreadPool
-from naturtag.settings import Settings
from naturtag.widgets.layouts import StylableWidget
+if TYPE_CHECKING:
+ from naturtag.app import NaturtagApp
+
class BaseController(StylableWidget):
"""Base class for controllers, typically in charge of a single tab/screen"""
on_message = Signal(str) #: Forward a message to status bar
- def __init__(self, settings: Settings, threadpool: Optional[ThreadPool] = None):
- super().__init__()
- self.settings = settings
- self.threadpool: ThreadPool = threadpool # type: ignore
+ @property
+ def app(self) -> 'NaturtagApp':
+ return QApplication.instance()
diff --git a/naturtag/controllers/image_controller.py b/naturtag/controllers/image_controller.py
index 5a3e9196..474807a8 100644
--- a/naturtag/controllers/image_controller.py
+++ b/naturtag/controllers/image_controller.py
@@ -6,7 +6,8 @@
from PySide6.QtWidgets import QApplication, QGroupBox, QLabel, QSizePolicy
from naturtag.controllers import BaseController, ImageGallery
-from naturtag.metadata import MetaMetadata, _refresh_tags, get_ids_from_url, tag_images
+from naturtag.metadata import MetaMetadata, _refresh_tags, tag_images
+from naturtag.utils import get_ids_from_url
from naturtag.widgets import (
HorizontalLayout,
IdInput,
@@ -28,8 +29,8 @@ class ImageController(BaseController):
on_select_observation_id = Signal(int) #: An observation ID was entered
on_select_observation_tab = Signal() #: Request to switch to observation tab
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ def __init__(self):
+ super().__init__()
photo_layout = VerticalLayout(self)
top_section_layout = HorizontalLayout()
top_section_layout.setAlignment(Qt.AlignLeft)
@@ -68,7 +69,7 @@ def __init__(self, *args, **kwargs):
self.input_taxon_id.on_clear.connect(self.data_source_card.clear)
# Image gallery
- self.gallery = ImageGallery(self.settings, self.threadpool)
+ self.gallery = ImageGallery()
self.gallery.on_select_observation.connect(self.on_select_observation_tab)
self.gallery.on_select_taxon.connect(self.on_select_taxon_tab)
photo_layout.addWidget(self.gallery)
@@ -89,10 +90,16 @@ def run(self):
logger.info(f'Tagging {len(image_paths)} images with metadata for {selected_id}')
def tag_image(image_path):
- return tag_images([image_path], obs_id, taxon_id, settings=self.settings)[0]
+ return tag_images(
+ [image_path],
+ obs_id,
+ taxon_id,
+ client=self.app.client,
+ settings=self.app.settings,
+ )[0]
for image_path in image_paths:
- future = self.threadpool.schedule(tag_image, image_path=image_path)
+ future = self.app.threadpool.schedule(tag_image, image_path=image_path)
future.on_result.connect(self.update_metadata)
self.info(f'{len(image_paths)} images tagged with metadata for {selected_id}')
@@ -110,8 +117,8 @@ def refresh(self):
return
for image in images:
- future = self.threadpool.schedule(
- lambda: _refresh_tags(image.metadata, self.settings),
+ future = self.app.threadpool.schedule(
+ lambda: _refresh_tags(image.metadata, self.app.client, self.app.settings),
)
future.on_result.connect(self.update_metadata)
self.info(f'{len(images)} images updated')
diff --git a/naturtag/controllers/image_gallery.py b/naturtag/controllers/image_gallery.py
index 48e779a3..936649d8 100644
--- a/naturtag/controllers/image_gallery.py
+++ b/naturtag/controllers/image_gallery.py
@@ -52,8 +52,8 @@ class ImageGallery(BaseController):
on_select_taxon = Signal(int) #: A taxon was selected from context menu
on_select_observation = Signal(int) #: An observation was selected from context menu
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ def __init__(self):
+ super().__init__()
self.setAcceptDrops(True)
self.images: dict[Path, ThumbnailCard] = {}
self.image_window = ImageWindow()
@@ -82,7 +82,7 @@ def load_file_dialog(self, start_dir: Optional[PathOrStr] = None):
image_paths, _ = QFileDialog.getOpenFileNames(
self,
caption='Open image files:',
- dir=str(start_dir or self.settings.start_image_dir),
+ dir=str(start_dir or self.app.settings.start_image_dir),
filter=f'Image files ({" ".join(IMAGE_FILETYPES)})',
)
self.load_images(image_paths)
@@ -100,7 +100,7 @@ def load_images(self, image_paths: Iterable[PathOrStr]):
# Then load actual images
for thumbnail_card in filter(None, cards):
- thumbnail_card.load_image_async(self.threadpool)
+ thumbnail_card.load_image_async(self.app.threadpool)
self.on_load_images.emit(new_images)
diff --git a/naturtag/controllers/observation_controller.py b/naturtag/controllers/observation_controller.py
index b3aa1f24..058eee3e 100644
--- a/naturtag/controllers/observation_controller.py
+++ b/naturtag/controllers/observation_controller.py
@@ -6,7 +6,6 @@
from PySide6.QtWidgets import QLabel, QPushButton
from naturtag.app.style import fa_icon
-from naturtag.client import INAT_CLIENT
from naturtag.constants import DEFAULT_PAGE_SIZE
from naturtag.controllers import BaseController, ObservationInfoSection
from naturtag.widgets import HorizontalLayout, ObservationInfoCard, ObservationList, VerticalLayout
@@ -17,14 +16,14 @@
class ObservationController(BaseController):
on_select = Signal(Observation) #: An observation was selected
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ def __init__(self):
+ super().__init__()
self.root = HorizontalLayout(self)
self.root.setAlignment(Qt.AlignLeft)
self.selected_observation: Observation = None
# Search inputs
- # self.search = ObservationSearch(self.settings)
+ # self.search = ObservationSearch(self.app.settings)
# self.search.autocomplete.on_select.connect(self.select_taxon)
# self.search.on_results.connect(self.set_search_results)
# self.on_select.connect(self.search.set_taxon)
@@ -37,7 +36,7 @@ def __init__(self, *args, **kwargs):
# self.pages: dict[int, list[ObservationInfoCard]] = {}
# User observations
- self.user_observations = ObservationList(self.threadpool)
+ self.user_observations = ObservationList()
user_obs_group_box = self.add_group(
'My Observations',
self.root,
@@ -67,7 +66,7 @@ def __init__(self, *args, **kwargs):
button_layout.addWidget(self.next_button)
# Selected observation info
- self.obs_info = ObservationInfoSection(self.threadpool)
+ self.obs_info = ObservationInfoSection()
self.obs_info.on_select.connect(self.display_observation)
obs_layout = VerticalLayout()
obs_layout.addLayout(self.obs_info)
@@ -90,8 +89,8 @@ def select_observation(self, observation_id: int):
return
logger.info(f'Selecting observation {observation_id}')
- future = self.threadpool.schedule(
- lambda: INAT_CLIENT.observations(observation_id, taxonomy=True),
+ future = self.app.threadpool.schedule(
+ lambda: self.app.client.observations(observation_id, taxonomy=True),
priority=QThread.HighPriority,
)
future.on_result.connect(self.display_observation)
@@ -99,7 +98,9 @@ def select_observation(self, observation_id: int):
def load_user_observations(self):
"""Fetch and display a single page of user observations"""
logger.info('Fetching user observations')
- future = self.threadpool.schedule(self.get_user_observations, priority=QThread.LowPriority)
+ future = self.app.threadpool.schedule(
+ self.get_user_observations, priority=QThread.LowPriority
+ )
future.on_result.connect(self.display_user_observations)
def next_page(self):
@@ -151,20 +152,20 @@ def bind_selection(self, obs_cards: Iterable[ObservationInfoCard]):
# TODO: Store a Paginator object instead of page number?
def get_user_observations(self) -> list[Observation]:
"""Fetch a single page of user observations"""
- if not self.settings.username:
+ if not self.app.settings.username:
return []
- updated_since = self.settings.last_obs_check
- self.settings.set_obs_checkpoint()
+ updated_since = self.app.settings.last_obs_check
+ self.app.settings.set_obs_checkpoint()
# TODO: Depending on order of operations, this could be counted from the db instead of API.
# Maybe do that except on initial observation load?
- total_results = INAT_CLIENT.observations.count(username=self.settings.username)
+ total_results = self.app.client.observations.count(username=self.app.settings.username)
self.total_pages = (total_results // DEFAULT_PAGE_SIZE) + 1
logger.debug('Total user observations: %s (%s pages)', total_results, self.total_pages)
- observations = INAT_CLIENT.observations.get_user_observations(
- username=self.settings.username,
+ observations = self.app.client.observations.get_user_observations(
+ username=self.app.settings.username,
updated_since=updated_since,
limit=DEFAULT_PAGE_SIZE,
page=self.page,
diff --git a/naturtag/controllers/observation_search.py b/naturtag/controllers/observation_search.py
index 077fc81a..90a92c60 100644
--- a/naturtag/controllers/observation_search.py
+++ b/naturtag/controllers/observation_search.py
@@ -4,15 +4,13 @@
from pyinaturalist import Observation
from PySide6.QtCore import Qt
-from naturtag.settings import Settings
from naturtag.widgets import VerticalLayout
logger = getLogger(__name__)
class ObservationSearch(VerticalLayout):
- def __init__(self, settings: Settings):
+ def __init__(self):
super().__init__()
self.selected_observation: Observation = None
- self.settings = settings
self.setAlignment(Qt.AlignTop)
diff --git a/naturtag/controllers/observation_view.py b/naturtag/controllers/observation_view.py
index 31d24bf4..6bc99f01 100644
--- a/naturtag/controllers/observation_view.py
+++ b/naturtag/controllers/observation_view.py
@@ -10,7 +10,6 @@
from PySide6.QtWidgets import QGroupBox, QLabel, QPushButton
from naturtag.app.style import fa_icon
-from naturtag.app.threadpool import ThreadPool
from naturtag.constants import SIZE_SM
from naturtag.widgets import (
GridLayout,
@@ -19,6 +18,7 @@
ObservationImageWindow,
ObservationPhoto,
VerticalLayout,
+ set_pixmap_async,
)
from naturtag.widgets.observation_images import GEOPRIVACY_ICONS, QUALITY_GRADE_ICONS
@@ -30,9 +30,8 @@ class ObservationInfoSection(HorizontalLayout):
on_select = Signal(Observation) #: An observation was selected
- def __init__(self, threadpool: ThreadPool):
+ def __init__(self):
super().__init__()
- self.threadpool = threadpool
self.hist_prev: deque[Observation] = deque() # Viewing history for current session only
self.hist_next: deque[Observation] = deque() # Set when loading from history
self.history_observation: Observation = None # Set when loading from history, to avoid loop
@@ -121,9 +120,9 @@ def load(self, obs: Observation):
self.group_box.setTitle(obs.taxon.full_name)
self.image.hover_event = True
self.image.observation = obs
- self.image.set_pixmap_async(
- self.threadpool,
- photo=obs.photos[0], # TODO: add Observation.default_photo in pyinat
+ set_pixmap_async(
+ self.image,
+ photo=Observation.default_photo,
priority=QThread.HighPriority,
)
self._update_nav_buttons()
@@ -134,7 +133,7 @@ def load(self, obs: Observation):
thumb = ObservationPhoto(observation=obs, idx=i + 1, rounded=True)
thumb.setFixedSize(*SIZE_SM)
thumb.on_click.connect(self.image_window.display_observation_fullscreen)
- thumb.set_pixmap_async(self.threadpool, photo=photo, size='thumbnail')
+ set_pixmap_async(thumb, photo=photo, size='thumbnail')
self.thumbnails.addWidget(thumb)
# Load observation details
diff --git a/naturtag/controllers/taxon_controller.py b/naturtag/controllers/taxon_controller.py
index 467d19fc..9fb5ae66 100644
--- a/naturtag/controllers/taxon_controller.py
+++ b/naturtag/controllers/taxon_controller.py
@@ -6,11 +6,15 @@
from PySide6.QtWidgets import QTabWidget, QWidget
from naturtag.app.style import fa_icon
-from naturtag.app.threadpool import ThreadPool
-from naturtag.client import INAT_CLIENT
from naturtag.constants import MAX_DISPLAY_OBSERVED
-from naturtag.controllers import BaseController, TaxonInfoSection, TaxonomySection, TaxonSearch
-from naturtag.settings import Settings, UserTaxa
+from naturtag.controllers import (
+ BaseController,
+ TaxonInfoSection,
+ TaxonomySection,
+ TaxonSearch,
+ get_app,
+)
+from naturtag.settings import UserTaxa
from naturtag.widgets import HorizontalLayout, TaxonInfoCard, TaxonList, VerticalLayout
logger = getLogger(__name__)
@@ -21,33 +25,33 @@ class TaxonController(BaseController):
on_select = Signal(Taxon) #: A taxon was selected
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.user_taxa = UserTaxa.read(self.settings.user_taxa_path)
+ def __init__(self):
+ super().__init__()
+ self.user_taxa = UserTaxa.read(self.app.settings.user_taxa_path)
self.root = HorizontalLayout(self)
self.root.setAlignment(Qt.AlignLeft)
self.selected_taxon: Taxon = None
# Search inputs
- self.search = TaxonSearch(self.settings)
+ self.search = TaxonSearch()
self.search.autocomplete.on_select.connect(self.select_taxon)
self.search.on_results.connect(self.set_search_results)
self.on_select.connect(self.search.set_taxon)
self.root.addLayout(self.search)
# Search results & user taxa
- self.tabs = TaxonTabs(self.settings, self.threadpool, self.user_taxa)
+ self.tabs = TaxonTabs(self.user_taxa)
self.tabs.on_load.connect(self.bind_selection)
self.root.addWidget(self.tabs)
self.on_select.connect(self.tabs.update_history)
self.search.on_reset.connect(self.tabs.results.clear)
# Selected taxon info
- self.taxon_info = TaxonInfoSection(self.threadpool)
+ self.taxon_info = TaxonInfoSection()
self.taxon_info.on_select_id.connect(self.select_taxon)
self.taxon_info.on_select.connect(self.display_taxon)
- self.taxonomy = TaxonomySection(self.threadpool, self.user_taxa)
+ self.taxonomy = TaxonomySection(self.user_taxa)
taxon_layout = VerticalLayout()
taxon_layout.addLayout(self.taxon_info)
taxon_layout.addLayout(self.taxonomy)
@@ -71,10 +75,11 @@ def select_taxon(self, taxon_id: int):
# Fetch taxon record
logger.info(f'Selecting taxon {taxon_id}')
+ client = self.app.client
if self.tabs._init_complete:
- self.threadpool.cancel()
- future = self.threadpool.schedule(
- lambda: INAT_CLIENT.taxa(taxon_id, locale=self.settings.locale),
+ self.app.threadpool.cancel()
+ future = self.app.threadpool.schedule(
+ lambda: client.taxa(taxon_id, locale=self.app.settings.locale),
priority=QThread.HighPriority,
)
future.on_result.connect(lambda taxon: self.display_taxon(taxon))
@@ -111,8 +116,6 @@ class TaxonTabs(QTabWidget):
def __init__(
self,
- settings: Settings,
- threadpool: ThreadPool,
user_taxa: UserTaxa,
parent: Optional[QWidget] = None,
):
@@ -121,25 +124,32 @@ def __init__(
self.setIconSize(QSize(32, 32))
self.setMinimumWidth(240)
self.setMaximumWidth(510)
- self.settings = settings
- self.threadpool = threadpool
self.user_taxa = user_taxa
self._init_complete = False
self.results = self.add_tab(
- TaxonList(threadpool, user_taxa), 'mdi6.layers-search', 'Results', 'Search results'
+ TaxonList(user_taxa),
+ 'mdi6.layers-search',
+ 'Results',
+ 'Search results',
)
self.recent = self.add_tab(
- TaxonList(threadpool, user_taxa), 'fa5s.history', 'Recent', 'Recently viewed taxa'
+ TaxonList(user_taxa),
+ 'fa5s.history',
+ 'Recent',
+ 'Recently viewed taxa',
)
self.frequent = self.add_tab(
- TaxonList(threadpool, user_taxa),
+ TaxonList(user_taxa),
'ri.bar-chart-fill',
'Frequent',
'Frequently viewed taxa',
)
self.observed = self.add_tab(
- TaxonList(threadpool, user_taxa), 'fa5s.binoculars', 'Observed', 'Taxa observed by you'
+ TaxonList(user_taxa),
+ 'fa5s.binoculars',
+ 'Observed',
+ 'Taxa observed by you',
)
# Add a delay before loading user taxa on startup
@@ -152,31 +162,34 @@ def add_tab(self, taxon_list: TaxonList, icon_str: str, label: str, tooltip: str
def load_user_taxa(self):
logger.info('Fetching user-observed taxa')
+ app = get_app()
+ client = app.client
display_ids = self.user_taxa.display_ids
def get_recent_taxa():
logger.info(f'Loading {len(display_ids)} user taxa')
- return INAT_CLIENT.taxa.from_ids(
- *display_ids, locale=self.settings.locale, accept_partial=True
+ return client.taxa.from_ids(
+ *display_ids, locale=app.settings.locale, accept_partial=True
).all()
- future = self.threadpool.schedule(get_recent_taxa, priority=QThread.LowPriority)
+ future = app.threadpool.schedule(get_recent_taxa, priority=QThread.LowPriority)
future.on_result.connect(self.display_recent)
- future = self.threadpool.schedule(self.get_user_observed_taxa, priority=QThread.LowPriority)
+ future = app.threadpool.schedule(self.get_user_observed_taxa, priority=QThread.LowPriority)
future.on_result.connect(self.display_observed)
self._init_complete = True
def get_user_observed_taxa(self) -> TaxonCounts:
"""Get counts of taxa observed by the user, ordered by number of observations descending"""
- if not self.settings.username:
+ app = get_app()
+ if not app.settings.username:
return []
# False will return *only* casual observations
- verifiable = None if self.settings.casual_observations else True
+ verifiable = None if app.settings.casual_observations else True
- taxon_counts = INAT_CLIENT.observations.species_counts(
- user_login=self.settings.username,
+ taxon_counts = app.client.observations.species_counts(
+ user_login=app.settings.username,
verifiable=verifiable,
)
logger.info(f'{len(taxon_counts)} user-observed taxa found')
diff --git a/naturtag/controllers/taxon_search.py b/naturtag/controllers/taxon_search.py
index cfe1dd33..b1c4bf84 100644
--- a/naturtag/controllers/taxon_search.py
+++ b/naturtag/controllers/taxon_search.py
@@ -8,9 +8,8 @@
from PySide6.QtWidgets import QApplication, QComboBox, QLabel, QPushButton, QWidget
from naturtag.app.style import fa_icon
-from naturtag.client import INAT_CLIENT
from naturtag.constants import COMMON_RANKS, RANKS, SELECTABLE_ICONIC_TAXA
-from naturtag.settings import Settings
+from naturtag.controllers import get_app
from naturtag.widgets import (
GridLayout,
HorizontalLayout,
@@ -28,14 +27,13 @@ class TaxonSearch(VerticalLayout):
on_results = Signal(list) #: New search results were loaded
on_reset = Signal() #: Input fields were reset
- def __init__(self, settings: Settings):
+ def __init__(self):
super().__init__()
self.selected_taxon: Taxon = None
- self.settings = settings
self.setAlignment(Qt.AlignTop)
# Taxon name autocomplete
- self.autocomplete = TaxonAutocomplete(settings)
+ self.autocomplete = TaxonAutocomplete()
search_group = self.add_group('Search', self, width=400)
search_group.addWidget(self.autocomplete)
self.autocomplete.returnPressed.connect(self.search)
@@ -86,18 +84,20 @@ def __init__(self, settings: Settings):
def search(self):
"""Search for taxa with the currently selected filters"""
+ client = QApplication.instance().client
taxon_ids = self.iconic_taxon_filters.selected_iconic_taxa
if self.search_children_switch.isChecked():
taxon_ids.append(self.selected_taxon.id)
- taxa = INAT_CLIENT.taxa.search(
+ settings = get_app().settings
+ taxa = client.taxa.search(
q=self.autocomplete.text(),
taxon_id=taxon_ids,
rank=self.exact_rank.text,
min_rank=self.min_rank.text,
max_rank=self.max_rank.text,
- preferred_place_id=self.settings.preferred_place_id,
- locale=self.settings.locale,
+ preferred_place_id=settings.preferred_place_id,
+ locale=settings.locale,
limit=30,
).all()
@@ -113,13 +113,10 @@ def reset(self):
self.on_reset.emit()
def reset_ranks(self):
- self.exact_rank = RankList('Exact', 'fa5s.equals', all_ranks=self.settings.all_ranks)
- self.min_rank = RankList(
- 'Minimum', 'fa5s.greater-than-equal', all_ranks=self.settings.all_ranks
- )
- self.max_rank = RankList(
- 'Maximum', 'fa5s.less-than-equal', all_ranks=self.settings.all_ranks
- )
+ settings = get_app().settings
+ self.exact_rank = RankList('Exact', 'fa5s.equals', all_ranks=settings.all_ranks)
+ self.min_rank = RankList('Minimum', 'fa5s.greater-than-equal', all_ranks=settings.all_ranks)
+ self.max_rank = RankList('Maximum', 'fa5s.less-than-equal', all_ranks=settings.all_ranks)
self.ranks.clear()
self.ranks.addLayout(self.exact_rank)
self.ranks.addLayout(self.min_rank)
diff --git a/naturtag/controllers/taxon_view.py b/naturtag/controllers/taxon_view.py
index fad6b421..668ef40d 100644
--- a/naturtag/controllers/taxon_view.py
+++ b/naturtag/controllers/taxon_view.py
@@ -9,7 +9,6 @@
from PySide6.QtWidgets import QGroupBox, QPushButton
from naturtag.app.style import fa_icon
-from naturtag.app.threadpool import ThreadPool
from naturtag.constants import SIZE_SM
from naturtag.settings import UserTaxa
from naturtag.widgets import (
@@ -18,6 +17,7 @@
TaxonImageWindow,
TaxonInfoCard,
TaxonList,
+ set_pixmap_async,
)
from naturtag.widgets.layouts import VerticalLayout
from naturtag.widgets.taxon_images import TaxonPhoto
@@ -31,9 +31,8 @@ class TaxonInfoSection(HorizontalLayout):
on_select = Signal(Taxon) #: A taxon object was selected (from nav or another screen)
on_select_id = Signal(int) #: A taxon ID was selected (from 'parent' button)
- def __init__(self, threadpool: ThreadPool):
+ def __init__(self):
super().__init__()
- self.threadpool = threadpool
self.hist_prev: deque[Taxon] = deque() # Viewing history for current session only
self.hist_next: deque[Taxon] = deque() # Set when loading from history
self.history_taxon: Taxon = None # Set when loading from history, to avoid loop
@@ -111,11 +110,7 @@ def load(self, taxon: Taxon):
self.group_box.setTitle(taxon.full_name)
self.image.hover_event = True
self.image.taxon = taxon
- self.image.set_pixmap_async(
- self.threadpool,
- photo=taxon.default_photo,
- priority=QThread.HighPriority,
- )
+ set_pixmap_async(self.image, photo=taxon.default_photo, priority=QThread.HighPriority)
self._update_nav_buttons()
# Load additional thumbnails
@@ -124,7 +119,7 @@ def load(self, taxon: Taxon):
thumb = TaxonPhoto(taxon=taxon, idx=i + 1, rounded=True)
thumb.setFixedSize(*SIZE_SM)
thumb.on_click.connect(self.image_window.display_taxon_fullscreen)
- thumb.set_pixmap_async(self.threadpool, photo=photo, size='thumbnail')
+ set_pixmap_async(thumb, photo=photo, size='thumbnail')
self.thumbnails.addWidget(thumb)
def prev(self):
@@ -165,19 +160,19 @@ def _update_nav_buttons(self):
class TaxonomySection(HorizontalLayout):
"""Section to display ancestors and children of selected taxon"""
- def __init__(self, threadpool: ThreadPool, user_taxa: UserTaxa):
+ def __init__(self, user_taxa: UserTaxa):
super().__init__()
self.ancestors_group = self.add_group(
'Ancestors', min_width=400, max_width=500, policy_min_height=False
)
- self.ancestors_list = TaxonList(threadpool, user_taxa)
+ self.ancestors_list = TaxonList(user_taxa)
self.ancestors_group.addWidget(self.ancestors_list.scroller)
self.children_group = self.add_group(
'Children', min_width=400, max_width=500, policy_min_height=False
)
- self.children_list = TaxonList(threadpool, user_taxa)
+ self.children_list = TaxonList(user_taxa)
self.children_group.addWidget(self.children_list.scroller)
def load(self, taxon: Taxon):
diff --git a/naturtag/metadata/__init__.py b/naturtag/metadata/__init__.py
index 427209ba..f32c59e5 100644
--- a/naturtag/metadata/__init__.py
+++ b/naturtag/metadata/__init__.py
@@ -7,10 +7,8 @@
from naturtag.metadata.keyword_metadata import KeywordMetadata
from naturtag.metadata.meta_metadata import MetaMetadata
from naturtag.metadata.inat_metadata import (
- get_ids_from_url,
- get_inat_metadata,
+ observation_to_metadata,
_refresh_tags,
refresh_tags,
- strip_url,
tag_images,
)
diff --git a/naturtag/metadata/inat_metadata.py b/naturtag/metadata/inat_metadata.py
index 08ab6381..a26e0b04 100644
--- a/naturtag/metadata/inat_metadata.py
+++ b/naturtag/metadata/inat_metadata.py
@@ -5,16 +5,15 @@
# TODO: Include eol:dataObject info (metadata for an individual observation photo)
from logging import getLogger
from typing import Iterable, Optional
-from urllib.parse import urlparse
from pyinaturalist import Observation, Taxon
from pyinaturalist_convert import to_dwc
-from naturtag.client import INAT_CLIENT
-from naturtag.constants import COMMON_NAME_IGNORE_TERMS, COMMON_RANKS, IntTuple, PathOrStr
+from naturtag.client import iNatDbClient
+from naturtag.constants import COMMON_NAME_IGNORE_TERMS, COMMON_RANKS, PathOrStr
from naturtag.metadata import MetaMetadata
from naturtag.settings import Settings
-from naturtag.utils import get_valid_image_paths
+from naturtag.utils import get_valid_image_paths, quote
DWC_NAMESPACES = ['dcterms', 'dwc']
logger = getLogger().getChild(__name__)
@@ -26,6 +25,7 @@ def tag_images(
taxon_id: Optional[int] = None,
recursive: bool = False,
include_sidecars: bool = False,
+ client: Optional[iNatDbClient] = None,
settings: Optional[Settings] = None,
) -> list[MetaMetadata]:
"""
@@ -56,16 +56,18 @@ def tag_images(
Updated image metadata for each image
"""
settings = settings or Settings.read()
- inat_metadata = get_inat_metadata(
- observation_id=observation_id,
- taxon_id=taxon_id,
+ client = client or iNatDbClient(settings.db_path)
+
+ observation = client.from_ids(observation_id, taxon_id)
+ if not observation:
+ return []
+
+ inat_metadata = observation_to_metadata(
+ observation,
common_names=settings.common_names,
hierarchical=settings.hierarchical,
)
-
- if not inat_metadata:
- return []
- elif not image_paths:
+ if not image_paths:
return [inat_metadata]
def _tag_image(
@@ -91,62 +93,116 @@ def _tag_image(
]
-def get_inat_metadata(
- observation_id: Optional[int] = None,
- taxon_id: Optional[int] = None,
- common_names: bool = False,
- hierarchical: bool = False,
- metadata: Optional[MetaMetadata] = None,
-) -> Optional[MetaMetadata]:
- """Create or update image metadata based on an iNaturalist observation and/or taxon"""
- metadata = metadata or MetaMetadata()
- observation, taxon = None, None
+def refresh_tags(
+ image_paths: Iterable[PathOrStr],
+ recursive: bool = False,
+ client: Optional[iNatDbClient] = None,
+ settings: Optional[Settings] = None,
+) -> list[MetaMetadata]:
+ """Refresh metadata for previously tagged images with latest observation and/or taxon data.
- # Get observation and/or taxon records
- if observation_id:
- observation = INAT_CLIENT.observations(observation_id, refresh=True)
- taxon_id = observation.taxon.id
+ Example:
- # Observation.taxon doesn't include ancestors, so we always need to fetch the full taxon record
- taxon = INAT_CLIENT.taxa(taxon_id, refresh=True)
- if not taxon:
- logger.warning(f'No taxon found: {taxon_id}')
+ >>> # Refresh previously tagged images with latest observation and taxonomy metadata
+ >>> from naturtag import refresh_tags
+ >>> refresh_tags(['~/observations/'], recursive=True)
+
+ Args:
+ image_paths: Paths to images to tag
+ recursive: Recursively search subdirectories for valid image files
+ settings: Settings for metadata types to generate
+
+ Returns:
+ Updated metadata objects for updated images only
+ """
+ settings = settings or Settings.read()
+ client = client or iNatDbClient(settings.db_path)
+ metadata_objs = [
+ _refresh_tags(MetaMetadata(image_path), client, settings)
+ for image_path in get_valid_image_paths(
+ image_paths, recursive, create_sidecars=settings.sidecar
+ )
+ ]
+ return [m for m in metadata_objs if m]
+
+
+def _refresh_tags(
+ metadata: MetaMetadata, client: iNatDbClient, settings: Settings
+) -> Optional[MetaMetadata]:
+ """Refresh existing metadata for a single image
+
+ Returns:
+ Updated metadata if existing IDs were found, otherwise ``None``
+ """
+ if not metadata.has_observation and not metadata.has_taxon:
+ logger.debug(f'No IDs found in {metadata.image_path}')
return None
- # If there's a taxon only (no observation), check for any taxonomy changes
- if (
- not observation_id
- and not taxon.is_active
- and len(taxon.current_synonymous_taxon_ids or []) == 1
- ):
- taxon = INAT_CLIENT.taxa(taxon.current_synonymous_taxon_ids[0], refresh=True)
+ logger.debug(f'Refreshing tags for {metadata.image_path}')
+ settings = settings or Settings.read()
+ observation = client.from_ids(metadata.observation_id, metadata.taxon_id)
+ metadata = observation_to_metadata(
+ observation,
+ common_names=settings.common_names,
+ hierarchical=settings.hierarchical,
+ metadata=metadata,
+ )
+
+ metadata.write(
+ write_exif=settings.exif,
+ write_iptc=settings.iptc,
+ write_xmp=settings.xmp,
+ write_sidecar=settings.sidecar,
+ )
+ return metadata
+
+
+def observation_to_metadata(
+ observation: Observation,
+ metadata: Optional[MetaMetadata] = None,
+ common_names: bool = False,
+ hierarchical: bool = False,
+) -> MetaMetadata:
+ """Get image metadata from an Observation object"""
+ metadata = metadata or MetaMetadata()
# Get all specified keyword categories
- keywords = _get_taxonomy_keywords(taxon)
+ keywords = _get_taxonomy_keywords(observation.taxon)
if hierarchical:
- keywords.extend(_get_taxon_hierarchical_keywords(taxon))
+ keywords.extend(_get_taxon_hierarchical_keywords(observation.taxon))
if common_names:
- common_keywords = _get_common_keywords(taxon)
+ common_keywords = _get_common_keywords(observation.taxon)
keywords.extend(common_keywords)
if hierarchical:
keywords.extend(_get_hierarchical_keywords(common_keywords))
- keywords.extend(_get_id_keywords(observation_id, taxon_id))
+ keywords.extend(_get_id_keywords(observation.id, observation.taxon.id))
logger.debug(f'{len(keywords)} total keywords generated')
metadata.update_keywords(keywords)
# Convert and add coordinates
+ # TODO: Add other metdata like title, description, tags, etc.
if observation:
metadata.update_coordinates(observation.location, observation.positional_accuracy)
+ def _format_key(k):
+ """Get DwC terms as XMP tags.
+ Note: exiv2 will automatically add recognized XML namespace URLs when adding properties
+ """
+ namespace, term = k.split(':')
+ return f'Xmp.{namespace}.{term}' if namespace in DWC_NAMESPACES else None
+
# Convert and add DwC metadata
- metadata.update(_get_dwc_terms(observation, taxon))
+ dwc = to_dwc(observations=[observation], taxa=[observation.taxon])[0]
+ dwc_xmp = {_format_key(k): v for k, v in dwc.items() if _format_key(k)}
+ metadata.update(dwc_xmp)
+
return metadata
def _get_taxonomy_keywords(taxon: Taxon) -> list[str]:
"""Format a list of taxa into rank keywords"""
- return [_quote(f'taxonomy:{t.rank}={t.name}') for t in taxon.ancestors + [taxon]]
+ return [quote(f'taxonomy:{t.rank}={t.name}') for t in taxon.ancestors + [taxon]]
def _get_id_keywords(
@@ -173,7 +229,7 @@ def _get_common_keywords(taxon: Taxon) -> list[str]:
def is_ignored(kw):
return any([ignore_term in kw.lower() for ignore_term in COMMON_NAME_IGNORE_TERMS])
- return [_quote(kw) for kw in keywords if kw and not is_ignored(kw)]
+ return [quote(kw) for kw in keywords if kw and not is_ignored(kw)]
def _get_taxon_hierarchical_keywords(taxon: Taxon) -> list[str]:
@@ -188,111 +244,3 @@ def _get_hierarchical_keywords(keywords: list[str]) -> list[str]:
for k in keywords[1:]:
hier_keywords.append(f'{hier_keywords[-1]}|{k}')
return hier_keywords
-
-
-def _get_dwc_terms(
- observation: Optional[Observation] = None, taxon: Optional[Taxon] = None
-) -> dict[str, str]:
- """Convert either an observation or taxon into XMP-formatted Darwin Core terms"""
-
- # Get terms only for specific namespaces
- # Note: exiv2 will automatically add recognized XML namespace URLs when adding properties
- def format_key(k):
- namespace, term = k.split(':')
- return f'Xmp.{namespace}.{term}' if namespace in DWC_NAMESPACES else None
-
- # Convert to DwC, then to XMP tags
- dwc = to_dwc(observations=observation, taxa=taxon)[0]
- return {format_key(k): v for k, v in dwc.items() if format_key(k)}
-
-
-def get_ids_from_url(url: str) -> IntTuple:
- """If a URL is provided containing an ID, return the taxon or observation ID.
-
- Returns:
- ``(observation_id, taxon_id)``
- """
- observation_id, taxon_id = None, None
- id = strip_url(url)
-
- if 'observation' in url:
- observation_id = id
- elif 'taxa' in url:
- taxon_id = id
-
- return observation_id, taxon_id
-
-
-def refresh_tags(
- image_paths: Iterable[PathOrStr],
- recursive: bool = False,
- settings: Optional[Settings] = None,
-) -> list[MetaMetadata]:
- """Refresh metadata for previously tagged images
-
- Example:
-
- >>> # Refresh previously tagged images with latest observation and taxonomy metadata
- >>> from naturtag import refresh_tags
- >>> refresh_tags(['~/observations/'], recursive=True)
-
- Args:
- image_paths: Paths to images to tag
- recursive: Recursively search subdirectories for valid image files
- settings: Settings for metadata types to generate
-
- Returns:
- Updated metadata objects for updated images only
- """
- settings = settings or Settings.read()
- metadata_objs = [
- _refresh_tags(MetaMetadata(image_path), settings)
- for image_path in get_valid_image_paths(
- image_paths, recursive, create_sidecars=settings.sidecar
- )
- ]
- return [m for m in metadata_objs if m]
-
-
-def _refresh_tags(
- metadata: MetaMetadata, settings: Optional[Settings] = None
-) -> Optional[MetaMetadata]:
- """Refresh existing metadata for a single image with latest observation and/or taxon data.
-
- Returns:
- Updated metadata if existing IDs were found, otherwise ``None``
- """
- if not metadata.has_observation and not metadata.has_taxon:
- logger.debug(f'No IDs found in {metadata.image_path}')
- return None
-
- logger.debug(f'Refreshing tags for {metadata.image_path}')
- settings = settings or Settings.read()
- metadata = get_inat_metadata( # type: ignore
- observation_id=metadata.observation_id,
- taxon_id=metadata.taxon_id,
- common_names=settings.common_names,
- hierarchical=settings.hierarchical,
- metadata=metadata,
- )
- metadata.write(
- write_exif=settings.exif,
- write_iptc=settings.iptc,
- write_xmp=settings.xmp,
- write_sidecar=settings.sidecar,
- )
- return metadata
-
-
-def strip_url(value: str) -> Optional[int]:
- """If a URL is provided containing an ID, return just the ID"""
- try:
- path = urlparse(value).path
- return int(path.split('/')[-1].split('-')[0])
- except (TypeError, ValueError):
- return None
-
-
-def _quote(s: str) -> str:
- """Surround keyword in quotes if it contains whitespace"""
- return f'"{s}"' if ' ' in s else s
diff --git a/naturtag/utils/__init__.py b/naturtag/utils/__init__.py
index bf7b780a..e5d6a17f 100644
--- a/naturtag/utils/__init__.py
+++ b/naturtag/utils/__init__.py
@@ -1,4 +1,5 @@
# flake8: noqa: F401
from naturtag.utils.i18n import read_locales
from naturtag.utils.image_glob import get_valid_image_paths
+from naturtag.utils.parsing import get_ids_from_url, quote, strip_url
from naturtag.utils.thumbnails import generate_thumbnail
diff --git a/naturtag/utils/parsing.py b/naturtag/utils/parsing.py
new file mode 100644
index 00000000..24c8e6e2
--- /dev/null
+++ b/naturtag/utils/parsing.py
@@ -0,0 +1,38 @@
+"""Misc URL and string parsing utilities"""
+
+
+from typing import Optional
+from urllib.parse import urlparse
+
+from naturtag.constants import IntTuple
+
+
+def get_ids_from_url(url: str) -> IntTuple:
+ """If a URL is provided containing an ID, return the taxon or observation ID.
+
+ Returns:
+ ``(observation_id, taxon_id)``
+ """
+ observation_id, taxon_id = None, None
+ id = strip_url(url)
+
+ if 'observation' in url:
+ observation_id = id
+ elif 'taxa' in url:
+ taxon_id = id
+
+ return observation_id, taxon_id
+
+
+def strip_url(value: str) -> Optional[int]:
+ """If a URL is provided containing an ID, return just the ID"""
+ try:
+ path = urlparse(value).path
+ return int(path.split('/')[-1].split('-')[0])
+ except (TypeError, ValueError):
+ return None
+
+
+def quote(s: str) -> str:
+ """Surround keyword in quotes if it contains whitespace"""
+ return f'"{s}"' if ' ' in s else s
diff --git a/naturtag/widgets/__init__.py b/naturtag/widgets/__init__.py
index 670c308f..39ab827d 100644
--- a/naturtag/widgets/__init__.py
+++ b/naturtag/widgets/__init__.py
@@ -19,6 +19,8 @@
InfoCardList,
ImageWindow,
PixmapLabel,
+ set_pixmap,
+ set_pixmap_async,
)
from naturtag.widgets.inputs import IdInput
from naturtag.widgets.logger import QtRichHandler, init_handler
diff --git a/naturtag/widgets/autocomplete.py b/naturtag/widgets/autocomplete.py
index a9878edb..ed6d0427 100644
--- a/naturtag/widgets/autocomplete.py
+++ b/naturtag/widgets/autocomplete.py
@@ -5,7 +5,7 @@
from PySide6.QtWidgets import QCompleter, QLineEdit, QToolButton
from naturtag.app.style import fa_icon
-from naturtag.settings import Settings
+from naturtag.controllers import get_app
logger = getLogger(__name__)
@@ -18,11 +18,10 @@ class TaxonAutocomplete(QLineEdit):
on_select = Signal(int) #: An autocomplete result was selected
on_tab = Signal() #: Tab key was pressed
- def __init__(self, settings: Settings):
+ def __init__(self):
super().__init__()
self.setClearButtonEnabled(True)
self.findChild(QToolButton).setIcon(fa_icon('mdi.backspace'))
- self.settings = settings
self.taxa: dict[str, int] = {}
completer = QCompleter()
@@ -32,7 +31,7 @@ def __init__(self, settings: Settings):
self.on_tab.connect(self.next_result)
# Results are fetched from FTS5, and passed to the completer via an intermediate model
- self.taxon_completer = TaxonAutocompleter(settings.db_path)
+ self.taxon_completer = TaxonAutocompleter(get_app().settings.db_path)
self.textChanged.connect(self.search)
self.model = QStringListModel()
completer.activated.connect(self.select_taxon)
@@ -53,7 +52,8 @@ def next_result(self):
# TODO: Input delay
def search(self, q: str):
if len(q) > 1 and q not in self.taxa:
- language = self.settings.locale if self.settings.search_locale else None
+ app = get_app()
+ language = app.settings.locale if app.settings.search_locale else None
results = self.taxon_completer.search(q, language=language)
self.taxa = {t.name: t.id for t in results}
self.model.setStringList(self.taxa.keys())
diff --git a/naturtag/widgets/images.py b/naturtag/widgets/images.py
index a7c412f8..d981b73d 100644
--- a/naturtag/widgets/images.py
+++ b/naturtag/widgets/images.py
@@ -5,20 +5,16 @@
from pathlib import Path
from typing import TYPE_CHECKING, Iterator, Optional, TypeAlias, Union
-from pyinaturalist import Photo
from PySide6.QtCore import QSize, Qt, QThread, Signal
from PySide6.QtGui import QBrush, QFont, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import QLabel, QLayout, QScrollArea, QSizePolicy, QWidget
from naturtag.app.style import fa_icon
-from naturtag.client import IMG_SESSION
from naturtag.constants import SIZE_ICON, SIZE_ICON_SM, SIZE_SM, IconDimensions, IntOrStr, PathOrStr
from naturtag.widgets import StylableWidget, VerticalLayout
from naturtag.widgets.layouts import GridLayout, HorizontalLayout
if TYPE_CHECKING:
- from naturtag.app.threadpool import ThreadPool
-
MIXIN_BASE: TypeAlias = QWidget
else:
MIXIN_BASE = object
@@ -26,6 +22,28 @@
logger = getLogger(__name__)
+def set_pixmap_async(
+ pixmap_label: QLabel,
+ priority: QThread.Priority = QThread.NormalPriority,
+ **kwargs,
+):
+ """Fetch an image from a separate thread, and render it in the main thread when complete"""
+ from naturtag.controllers import get_app
+
+ app = get_app()
+ future = app.threadpool.schedule(app.img_session.get_pixmap, priority=priority, **kwargs)
+ future.on_result.connect(pixmap_label.setPixmap)
+
+
+def set_pixmap(pixmap_label: QLabel, *args, **kwargs):
+ """Fetch an image from either a local path or remote URL."""
+ from naturtag.controllers import get_app
+
+ app = get_app()
+ pixmap = app.img_session.get_pixmap(*args, **kwargs)
+ pixmap_label.setPixmap(pixmap)
+
+
class FAIcon(QLabel):
"""A QLabel for displaying a FontAwesome icon"""
@@ -131,42 +149,14 @@ def __init__(
self.rounded = rounded
self.scale = scale
self.xform = Qt.SmoothTransformation if resample else Qt.FastTransformation
- if path or url:
- pixmap = self.get_pixmap(path=path, url=url)
self.setPixmap(pixmap)
-
- # TODO: Need quite a bit of refactoring to not depend on global session object
- # Pass app.img_session as a parameter instead
- def get_pixmap(self, *args, **kwargs) -> 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.
- """
- return IMG_SESSION.get_pixmap(*args, **kwargs)
+ if path or url:
+ set_pixmap(self, path=path, url=url)
def setPixmap(self, pixmap: QPixmap):
self._pixmap = pixmap
super().setPixmap(self.scaledPixmap())
- def set_pixmap_async(
- self,
- threadpool: 'ThreadPool',
- priority: QThread.Priority = QThread.NormalPriority,
- path: Optional[PathOrStr] = None,
- photo: Optional[Photo] = None,
- size: str = 'medium',
- url: Optional[str] = None,
- ):
- """Fetch a photo from a separate thread, and render it in the main thread when complete"""
- future = threadpool.schedule(
- self.get_pixmap,
- priority=priority,
- path=path,
- photo=photo,
- url=url,
- size=size,
- )
- future.on_result.connect(self.setPixmap)
-
def clear(self):
self.setPixmap(QPixmap())
@@ -370,9 +360,8 @@ def mouseReleaseEvent(self, event):
class InfoCardList(StylableWidget):
"""A scrollable list of InfoCards"""
- def __init__(self, threadpool: 'ThreadPool', parent: Optional[QWidget] = None):
+ def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
- self.threadpool = threadpool
self.root = VerticalLayout(self)
self.root.setAlignment(Qt.AlignTop)
self.root.setContentsMargins(0, 5, 5, 0)
@@ -394,7 +383,7 @@ def add_card(self, card: InfoCard, thumbnail_url: str, idx: Optional[int] = None
self.root.insertWidget(idx, card)
else:
self.root.addWidget(card)
- card.thumbnail.set_pixmap_async(self.threadpool, url=thumbnail_url)
+ set_pixmap_async(card.thumbnail, url=thumbnail_url)
def clear(self):
self.root.clear()
diff --git a/naturtag/widgets/logger.py b/naturtag/widgets/logger.py
index 1331725d..93f4b8b0 100644
--- a/naturtag/widgets/logger.py
+++ b/naturtag/widgets/logger.py
@@ -22,7 +22,7 @@ def init_handler(
root.setLevel(root_level)
root.addHandler(qt_handler)
- # iI a logfile is specified, add a FileHandler with a separate formatter
+ # If a logfile is specified, add a FileHandler with a separate formatter
if logfile:
handler = FileHandler(filename=str(logfile))
handler.setFormatter(
diff --git a/naturtag/widgets/observation_images.py b/naturtag/widgets/observation_images.py
index cfafaebb..980253ed 100644
--- a/naturtag/widgets/observation_images.py
+++ b/naturtag/widgets/observation_images.py
@@ -7,7 +7,14 @@
from PySide6.QtCore import Qt
from naturtag.constants import SIZE_ICON_SM
-from naturtag.widgets.images import HoverPhoto, IconLabel, ImageWindow, InfoCard, InfoCardList
+from naturtag.widgets.images import (
+ HoverPhoto,
+ IconLabel,
+ ImageWindow,
+ InfoCard,
+ InfoCardList,
+ set_pixmap,
+)
from naturtag.widgets.layouts import HorizontalLayout
logger = getLogger(__name__)
@@ -41,8 +48,7 @@ def __init__(self, obs: Observation, delayed_load: bool = True):
self.observation = obs
if not delayed_load:
- pixmap = self.thumbnail.get_pixmap(url=obs.default_photo.thumbnail_url)
- self.thumbnail.setPixmap(pixmap)
+ set_pixmap(self.thumbnail, url=obs.default_photo.thumbnail_url)
# Title: Taxon name
if obs.taxon:
@@ -173,7 +179,7 @@ def select_image_idx(self, idx: int):
self.set_photo(self.selected_photo)
def set_photo(self, photo: Photo):
- self.image.setPixmap(self.image.get_pixmap(url=photo.original_url))
+ set_pixmap(self.image, url=photo.original_url)
def remove_image(self):
pass
diff --git a/naturtag/widgets/taxon_images.py b/naturtag/widgets/taxon_images.py
index 299a4a03..e5dc8624 100644
--- a/naturtag/widgets/taxon_images.py
+++ b/naturtag/widgets/taxon_images.py
@@ -16,10 +16,10 @@
ImageWindow,
InfoCard,
InfoCardList,
+ set_pixmap,
)
if TYPE_CHECKING:
- from naturtag.app.threadpool import ThreadPool
from naturtag.settings import UserTaxa
ATTRIBUTION_STRIP_PATTERN = re.compile(r',?\s+uploaded by.*')
@@ -43,8 +43,7 @@ def __init__(self, taxon: Taxon, user_observations_count: int = 0, delayed_load:
self.setFixedHeight(90)
self.taxon = taxon
if not delayed_load:
- pixmap = self.thumbnail.get_pixmap(url=taxon.default_photo.thumbnail_url)
- self.thumbnail.setPixmap(pixmap)
+ set_pixmap(self.thumbnail, url=taxon.default_photo.thumbnail_url)
# Details
self.title.setText(f'{taxon.rank.title()}: {taxon.name}')
@@ -65,8 +64,8 @@ def __init__(self, taxon: Taxon, user_observations_count: int = 0, delayed_load:
class TaxonList(InfoCardList):
"""A scrollable list of TaxonInfoCards"""
- def __init__(self, threadpool: 'ThreadPool', user_taxa: 'UserTaxa', **kwargs):
- super().__init__(threadpool, **kwargs)
+ def __init__(self, user_taxa: 'UserTaxa', **kwargs):
+ super().__init__(**kwargs)
self.user_taxa = user_taxa
def add_taxon(self, taxon: Taxon, idx: Optional[int] = None) -> TaxonInfoCard:
@@ -129,7 +128,7 @@ def select_image_idx(self, idx: int):
self.set_photo(self.selected_photo)
def set_photo(self, photo: Photo):
- self.image.setPixmap(self.image.get_pixmap(url=photo.original_url))
+ set_pixmap(self.image, url=photo.original_url)
attribution = (
ATTRIBUTION_STRIP_PATTERN.sub('', photo.attribution or '')
.replace('(c)', '©')