diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b31298326..de1d67d78 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,8 @@ This document provides guidance for GitHub Copilot when working on the Whyis project. +> **Keep instructions in sync.** This file and [`CLAUDE.md`](../CLAUDE.md) (in the repository root) are companion instruction files — one for GitHub Copilot, one for Claude — and share the same project guidance. Whenever you change the guidance in this file, apply the equivalent change to `CLAUDE.md` so both assistants stay aligned. Only the tool-specific framing (title, intro, and this sync note) should differ between them. + ## Project Overview Whyis is a nano-scale knowledge graph publishing, management, and analysis framework built with Python and Flask. It manages knowledge as nanopublications, which are the smallest publishable units of knowledge graphs with associated provenance and publication information. diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4a4aae2fe..9c84941fd 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -51,13 +51,14 @@ jobs: - name: Install Python dependencies timeout-minutes: 15 run: | - python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade pip wheel + pip install "setuptools<80" # Install test dependencies first (lightweight) pip install -r requirements-test.txt # Install core dependencies needed for unit tests (without full whyis package) # Use --no-deps where possible to avoid dependency resolution loops pip install rdflib rdflib-jsonld Flask Flask-Security-Too Flask-Script Flask-PluginEngine - pip install filedepot Markdown + pip install filedepot Markdown pytz # Optional dependencies - skip if they cause issues pip install celery eventlet redislite nltk || true pip install sadi setlr sdd2rdf oxrdflib || true diff --git a/setup.py b/setup.py index 627593f5f..f6957b897 100644 --- a/setup.py +++ b/setup.py @@ -155,6 +155,12 @@ def run(self): 'requests' ], install_requires = [ + # Python 3.12 no longer bundles setuptools, and setuptools>=81 has + # removed pkg_resources. Several pinned dependencies (e.g. + # Flask-Security 3.0.0) still import pkg_resources, so pin to a + # setuptools that still ships it. Whyis's own code uses whyis._resources + # instead and does not need pkg_resources. + 'setuptools<81', 'beautifulsoup4==4.7.1', 'bibtexparser==1.1.0', 'celery<6.0.0', diff --git a/whyis/_resources.py b/whyis/_resources.py new file mode 100644 index 000000000..7d981e6d7 --- /dev/null +++ b/whyis/_resources.py @@ -0,0 +1,66 @@ +"""Dependency-free replacements for the ``pkg_resources`` resource helpers. + +``pkg_resources`` (shipped with setuptools) is deprecated and is no longer +guaranteed to be importable on Python 3.12+, where setuptools is not part of a +default environment. Whyis is always installed as a regular directory on disk +(never as a zipped egg), so we can resolve package resources directly from the +filesystem via :mod:`importlib`. These functions mirror the small subset of the +``pkg_resources`` API that Whyis relies on. +""" + +import importlib.util +import os + +__all__ = [ + "resource_filename", + "resource_listdir", + "resource_string", + "resource_exists", + "resource_stream", +] + + +def _base_dir(package): + """Return the on-disk directory associated with a package or module name. + + For a package (e.g. ``"whyis"``) this is the package directory. For a + module (e.g. ``"whyis.fuseki.fuseki"``) it is the directory containing the + module file, matching ``pkg_resources`` resource-resolution semantics. + """ + spec = importlib.util.find_spec(package) + if spec is None: + raise ModuleNotFoundError(f"No module named {package!r}") + if spec.submodule_search_locations: + return list(spec.submodule_search_locations)[0] + if spec.origin and spec.origin not in ("built-in", "frozen"): + return os.path.dirname(spec.origin) + raise ValueError(f"Cannot determine a resource directory for {package!r}") + + +def resource_filename(package, resource=""): + """Return the filesystem path to ``resource`` within ``package``.""" + base = _base_dir(package) + if not resource: + return base + return os.path.join(base, *resource.split("/")) + + +def resource_listdir(package, resource): + """List the contents of a directory ``resource`` within ``package``.""" + return os.listdir(resource_filename(package, resource)) + + +def resource_string(package, resource): + """Return the contents of ``resource`` within ``package`` as bytes.""" + with open(resource_filename(package, resource), "rb") as handle: + return handle.read() + + +def resource_exists(package, resource): + """Return whether ``resource`` exists within ``package``.""" + return os.path.exists(resource_filename(package, resource)) + + +def resource_stream(package, resource): + """Open ``resource`` within ``package`` as a binary stream.""" + return open(resource_filename(package, resource), "rb") diff --git a/whyis/app.py b/whyis/app.py index 7a707344e..8f5bb5070 100644 --- a/whyis/app.py +++ b/whyis/app.py @@ -53,7 +53,7 @@ from whyis.nanopub import NanopublicationManager from whyis.authenticator import SingleUserAuthenticator # from flask_login.config import EXEMPT_METHODS -from pkg_resources import resource_filename +from whyis._resources import resource_filename rdflib.plugin.register('sparql', Result, 'rdflib.plugins.sparql.processor', 'SPARQLResult') diff --git a/whyis/commands/configure.py b/whyis/commands/configure.py index 1041f1016..c9cbdd271 100644 --- a/whyis/commands/configure.py +++ b/whyis/commands/configure.py @@ -5,7 +5,7 @@ from base64 import b64encode import os from cookiecutter.main import cookiecutter -from pkg_resources import resource_filename, resource_listdir +from whyis._resources import resource_filename, resource_listdir try: from pip._internal.operations import freeze diff --git a/whyis/fuseki/fuseki.py b/whyis/fuseki/fuseki.py index 7de9c9118..97c46d61a 100644 --- a/whyis/fuseki/fuseki.py +++ b/whyis/fuseki/fuseki.py @@ -1,4 +1,4 @@ -from pkg_resources import resource_filename, resource_listdir, resource_string +from whyis._resources import resource_filename, resource_listdir, resource_string import subprocess import os import sys diff --git a/whyis/manager.py b/whyis/manager.py index 947e66601..7d4ad6c7f 100644 --- a/whyis/manager.py +++ b/whyis/manager.py @@ -10,7 +10,7 @@ import sys import os from cookiecutter.main import cookiecutter -from pkg_resources import resource_filename, resource_listdir +from whyis._resources import resource_filename, resource_listdir from whyis.config.utils import import_config_module, UnconfiguredAppException import json diff --git a/whyis/plugin.py b/whyis/plugin.py index 4ffa35a72..fbdaba4bf 100644 --- a/whyis/plugin.py +++ b/whyis/plugin.py @@ -1,7 +1,7 @@ from flask import render_template from flask_pluginengine import Plugin as PluginBase from flask_pluginengine import PluginBlueprint, current_plugin -from pkg_resources import resource_exists, resource_stream, resource_filename +from whyis._resources import resource_exists, resource_stream, resource_filename import rdflib class Listener: