From 14acfef0b55d79533615e114074d25d610d22ea7 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Fri, 12 Dec 2025 14:46:42 +0100 Subject: [PATCH 1/6] ignore development files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 12aece251..5498e452a 100644 --- a/.gitignore +++ b/.gitignore @@ -268,3 +268,5 @@ TagStudio.ini result result-* +/.vscode +uv.lock From bf477157657b13f0bd79e858b5de8bddbe912fef Mon Sep 17 00:00:00 2001 From: HEROgold Date: Fri, 12 Dec 2025 14:51:44 +0100 Subject: [PATCH 2/6] fix ignores --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5498e452a..6fbff9e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -173,7 +173,6 @@ poetry.toml .ruff_cache/ # LSP config files -pyrightconfig.json # Syncthing .stfolder/ @@ -235,7 +234,7 @@ compile_commands.json *_qmlcache.qrc ### VisualStudioCode ### -.vscode/* +/.vscode # !.vscode/settings.json # !.vscode/tasks.json # !.vscode/launch.json @@ -268,5 +267,4 @@ TagStudio.ini result result-* -/.vscode uv.lock From ff4232474a36153bdb088ca813ce9e659e7d1526 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Fri, 12 Dec 2025 14:52:01 +0100 Subject: [PATCH 3/6] Separate configs to their dedicated files --- mypy.ini | 21 +++++++++++++++++ pyproject.toml | 59 ---------------------------------------------- pyrightconfig.json | 24 +++++++++++++++++++ ruff.toml | 13 ++++++++++ 4 files changed, 58 insertions(+), 59 deletions(-) create mode 100644 mypy.ini create mode 100644 pyrightconfig.json create mode 100644 ruff.toml diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 000000000..bf2c4dfb0 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,21 @@ +[mypy] +mypy_path = src/tagstudio +disable_error_code = + annotation-unchecked, + func-returns-value, + import-untyped +explicit_package_bases = True +ignore_missing_imports = True +implicit_optional = True +strict_optional = False +warn_unused_ignores = True +exclude = build|dist + +[mypy-tagstudio.qt.main_window] +ignore_errors = True + +[mypy-tagstudio.qt.ui.home_ui] +ignore_errors = True + +[mypy-tagstudio.core.ts_core] +ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml index cb095d849..a923ddd97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,66 +55,7 @@ tagstudio = "tagstudio.main:main" [tool.hatch.build.targets.wheel] packages = ["src/tagstudio"] -[tool.mypy] -mypy_path = ["src/tagstudio"] -disable_error_code = [ - "annotation-unchecked", - "func-returns-value", - "import-untyped", -] -explicit_package_bases = true -ignore_missing_imports = true -implicit_optional = true -strict_optional = false -warn_unused_ignores = true -exclude = ["build", "dist"] - -[[tool.mypy.overrides]] -module = "tagstudio.qt.main_window" -ignore_errors = true - -[[tool.mypy.overrides]] -module = "tagstudio.qt.ui.home_ui" -ignore_errors = true - -[[tool.mypy.overrides]] -module = "tagstudio.core.ts_core" -ignore_errors = true - [tool.pytest.ini_options] #addopts = "-m 'not qt'" qt_api = "pyside6" -[tool.pyright] -ignore = [ - ".venv/**", - "src/tagstudio/core/library/json/", - "src/tagstudio/qt/previews/vendored/pydub/", -] -include = ["src/tagstudio", "tests"] -reportAny = false -reportIgnoreCommentWithoutRule = false -reportImplicitStringConcatenation = false -reportMissingTypeArgument = false -reportMissingTypeStubs = false -# reportOptionalMemberAccess = false -reportUnannotatedClassAttribute = false -reportUnknownArgumentType = false -reportUnknownLambdaType = false -reportUnknownMemberType = false -reportUnusedCallResult = false - -[tool.ruff] -exclude = ["home_ui.py", "resources.py", "resources_rc.py"] -line-length = 100 - -[tool.ruff.lint] -select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"] -ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"] - -[tool.ruff.lint.per-file-ignores] -"tests/**" = ["D", "E402"] -"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"] - -[tool.ruff.lint.pydocstyle] -convention = "google" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 000000000..4e0f11368 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/pyright/main/packages/vscode-pyright/schemas/pyrightconfig.schema.json", + "include": [ + "src/tagstudio", + "tests" + ], + "ignore": [ + ".venv/**", + "src/tagstudio/core/library/json/", + "src/tagstudio/qt/previews/vendored/pydub/" + ], + "diagnosticRuleOverrides": { + "reportAny": "none", + "reportIgnoreCommentWithoutRule": "none", + "reportImplicitStringConcatenation": "none", + "reportMissingTypeArgument": "none", + "reportMissingTypeStubs": "none", + "reportUnannotatedClassAttribute": "none", + "reportUnknownArgumentType": "none", + "reportUnknownLambdaType": "none", + "reportUnknownMemberType": "none", + "reportUnusedCallResult": "none" + } +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 000000000..7539b6a6a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,13 @@ +exclude = ["home_ui.py", "resources.py", "resources_rc.py"] +line-length = 100 + +[lint] +select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"] +ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"] + +[lint.per-file-ignores] +"tests/**" = ["D", "E402"] +"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"] + +[lint.pydocstyle] +convention = "google" From 58c7cc4ef1ccc5bd7b57b749a3a4a1cb6d497764 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Fri, 12 Dec 2025 15:17:43 +0100 Subject: [PATCH 4/6] Properly fix #1075 --- src/tagstudio/qt/mixed/field_containers.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index ae8df9107..24219c9e6 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -166,7 +166,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: "Character" -> "Johnny Bravo", "TV" -> Johnny Bravo" """ - loop_cutoff = 1024 # Used for stopping the while loop + visited_tags: set[int] = set() hierarchy_tags = self.lib.get_tag_hierarchy(t.id for t in tags) categories: dict[Tag | None, set[Tag]] = {None: set()} @@ -179,16 +179,15 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: has_category_parent = False parent_tags = tag.parent_tags - loop_counter = 0 - while len(parent_tags) > 0: - # NOTE: This is for preventing infinite loops in the event a tag is parented - # to itself cyclically. - loop_counter += 1 - if loop_counter >= loop_cutoff: - break + visited_tags.clear() + visited_tags.add(tag.id) + while len(parent_tags) > 0: grandparent_tags: set[Tag] = set() for parent_tag in parent_tags: + if parent_tag.id in visited_tags: + continue + visited_tags.add(parent_tag.id) if parent_tag in categories: categories[parent_tag].add(tag) has_category_parent = True From 1d0e021c6ff36c778962beb7b2409e344e79fd59 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Fri, 12 Dec 2025 15:26:39 +0100 Subject: [PATCH 5/6] Don't show tags that can cause cyclical dependency. Fixes #1074 Use set to avoid duplicate values. Also include all tag descendants in the exclusion, this avoids showing already parented tags. Bandaid fix for type error on TagSearchModal: converting to a list Ideally TagSearchModel can take any Iterable (future fix) --- src/tagstudio/core/library/alchemy/library.py | 61 ++++++++++++++++--- src/tagstudio/qt/mixed/build_tag.py | 16 +++-- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 1ce4fc85f..12b4aeef6 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -1685,12 +1685,47 @@ def get_tag_hierarchy(self, tag_ids: Iterable[int]) -> dict[int, Tag]: return all_tags - def add_parent_tag(self, parent_id: int, child_id: int) -> bool: + def get_tag_descendants(self, tag_id: int, session: Session | None = None) -> set[int]: + """Return ids of every tag that lists `tag_id` as an ancestor.""" + owns_session = False + if session is None: + session = Session(self.engine) + owns_session = True + + try: + descendants: set[int] = set() + frontier: set[int] = {tag_id} + + while frontier: + stmt = select(TagParent.child_id).where(TagParent.parent_id.in_(frontier)) + children = set(session.scalars(stmt).all()) + children -= descendants + children.discard(tag_id) + descendants.update(children) + frontier = children + + return descendants + finally: + if owns_session: + session.close() + + def _would_create_parent_cycle(self, parent_id: int, child_id: int, session: Session) -> bool: if parent_id == child_id: - return False + return True + + return parent_id in self.get_tag_descendants(child_id, session=session) + def add_parent_tag(self, parent_id: int, child_id: int) -> bool: # open session and save as parent tag with Session(self.engine) as session: + if self._would_create_parent_cycle(parent_id, child_id, session): + logger.warning( + "[Library][add_parent_tag] Prevented cyclical parent assignment", + parent_id=parent_id, + child_id=child_id, + ) + return False + parent_tag = TagParent( parent_id=parent_id, child_id=child_id, @@ -1814,10 +1849,10 @@ def update_aliases( session.add(alias) def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session: Session): - if tag.id in parent_ids: - parent_ids.remove(tag.id) + new_parent_ids: set[int] = set(parent_ids) + new_parent_ids.discard(tag.id) - if tag.disambiguation_id not in parent_ids: + if tag.disambiguation_id not in new_parent_ids: tag.disambiguation_id = None # load all tag's parent tags to know which to remove @@ -1826,14 +1861,22 @@ def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session ).all() for parent_tag in prev_parent_tags: - if parent_tag.parent_id not in parent_ids: + if parent_tag.parent_id not in new_parent_ids: session.delete(parent_tag) else: # no change, remove from list - parent_ids.remove(parent_tag.parent_id) + new_parent_ids.remove(parent_tag.parent_id) + + # create remaining items + for parent_id in list(new_parent_ids): + if self._would_create_parent_cycle(parent_id, tag.id, session): + logger.warning( + "[Library][update_parent_tags] Prevented cyclical parent assignment", + parent_id=parent_id, + child_id=tag.id, + ) + continue - # create remaining items - for parent_id in parent_ids: # add new parent tag parent_tag = TagParent( parent_id=parent_id, diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index cfffb6595..17c364c26 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -163,11 +163,12 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.parent_tags_add_button.setText("+") self.parent_tags_layout.addWidget(self.parent_tags_add_button) - exclude_ids: list[int] = list() - if tag is not None: - exclude_ids.append(tag.id) + exclude_ids: set[int] = set() + if tag is not None and tag.id is not None: + exclude_ids.add(tag.id) + exclude_ids.update(self.lib.get_tag_descendants(tag.id)) - self.add_tag_modal = TagSearchModal(self.lib, exclude_ids) + self.add_tag_modal = TagSearchModal(self.lib, list(exclude_ids)) self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) @@ -562,6 +563,13 @@ def set_tag(self, tag: Tag): logger.info("[BuildTagPanel] Setting Tag", tag=tag) self.tag = tag + if tag.id is not None: + exclude_ids: set[int] = {tag.id} + exclude_ids.update(self.lib.get_tag_descendants(tag.id)) + self.add_tag_modal.tsp.exclude = list(exclude_ids) + else: + self.add_tag_modal.tsp.exclude = [] + self.name_field.setText(tag.name) self.shorthand_field.setText(tag.shorthand or "") From 8ecadb09117615355f047b2284be0d4c1d7502c2 Mon Sep 17 00:00:00 2001 From: HEROgold Date: Fri, 12 Dec 2025 15:39:44 +0100 Subject: [PATCH 6/6] Revert "Separate configs to their dedicated files" This reverts commit ff4232474a36153bdb088ca813ce9e659e7d1526. --- mypy.ini | 21 ----------------- pyproject.toml | 59 ++++++++++++++++++++++++++++++++++++++++++++++ pyrightconfig.json | 24 ------------------- ruff.toml | 13 ---------- 4 files changed, 59 insertions(+), 58 deletions(-) delete mode 100644 mypy.ini delete mode 100644 pyrightconfig.json delete mode 100644 ruff.toml diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index bf2c4dfb0..000000000 --- a/mypy.ini +++ /dev/null @@ -1,21 +0,0 @@ -[mypy] -mypy_path = src/tagstudio -disable_error_code = - annotation-unchecked, - func-returns-value, - import-untyped -explicit_package_bases = True -ignore_missing_imports = True -implicit_optional = True -strict_optional = False -warn_unused_ignores = True -exclude = build|dist - -[mypy-tagstudio.qt.main_window] -ignore_errors = True - -[mypy-tagstudio.qt.ui.home_ui] -ignore_errors = True - -[mypy-tagstudio.core.ts_core] -ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml index a923ddd97..cb095d849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,66 @@ tagstudio = "tagstudio.main:main" [tool.hatch.build.targets.wheel] packages = ["src/tagstudio"] +[tool.mypy] +mypy_path = ["src/tagstudio"] +disable_error_code = [ + "annotation-unchecked", + "func-returns-value", + "import-untyped", +] +explicit_package_bases = true +ignore_missing_imports = true +implicit_optional = true +strict_optional = false +warn_unused_ignores = true +exclude = ["build", "dist"] + +[[tool.mypy.overrides]] +module = "tagstudio.qt.main_window" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "tagstudio.qt.ui.home_ui" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "tagstudio.core.ts_core" +ignore_errors = true + [tool.pytest.ini_options] #addopts = "-m 'not qt'" qt_api = "pyside6" +[tool.pyright] +ignore = [ + ".venv/**", + "src/tagstudio/core/library/json/", + "src/tagstudio/qt/previews/vendored/pydub/", +] +include = ["src/tagstudio", "tests"] +reportAny = false +reportIgnoreCommentWithoutRule = false +reportImplicitStringConcatenation = false +reportMissingTypeArgument = false +reportMissingTypeStubs = false +# reportOptionalMemberAccess = false +reportUnannotatedClassAttribute = false +reportUnknownArgumentType = false +reportUnknownLambdaType = false +reportUnknownMemberType = false +reportUnusedCallResult = false + +[tool.ruff] +exclude = ["home_ui.py", "resources.py", "resources_rc.py"] +line-length = 100 + +[tool.ruff.lint] +select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"] +ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D", "E402"] +"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"] + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 4e0f11368..000000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/microsoft/pyright/main/packages/vscode-pyright/schemas/pyrightconfig.schema.json", - "include": [ - "src/tagstudio", - "tests" - ], - "ignore": [ - ".venv/**", - "src/tagstudio/core/library/json/", - "src/tagstudio/qt/previews/vendored/pydub/" - ], - "diagnosticRuleOverrides": { - "reportAny": "none", - "reportIgnoreCommentWithoutRule": "none", - "reportImplicitStringConcatenation": "none", - "reportMissingTypeArgument": "none", - "reportMissingTypeStubs": "none", - "reportUnannotatedClassAttribute": "none", - "reportUnknownArgumentType": "none", - "reportUnknownLambdaType": "none", - "reportUnknownMemberType": "none", - "reportUnusedCallResult": "none" - } -} diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 7539b6a6a..000000000 --- a/ruff.toml +++ /dev/null @@ -1,13 +0,0 @@ -exclude = ["home_ui.py", "resources.py", "resources_rc.py"] -line-length = 100 - -[lint] -select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"] -ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"] - -[lint.per-file-ignores] -"tests/**" = ["D", "E402"] -"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"] - -[lint.pydocstyle] -convention = "google"