[project] name = "pyeuropepmc" version = "3.0.0" description = "A Python package for working with the Europe PMC API to search and retrieve scientific literature." license = "MIT" readme = "README.md" authors = [ { name = "Jonas Heinicke (HZI)", email = "jonas.heinicke@helmholtz-hzi.de" }, ] requires-python = ">=3.10,<4.0" # Dependencies are declared in [tool.poetry.dependencies] / [tool.poetry.extras] # and filled in by poetry-core at build time; PEP 621 requires saying so here. dynamic = ["dependencies", "optional-dependencies"] keywords = ["europe-pmc", "pubmed", "literature", "api", "scientific"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Bio-Informatics", ] urls = { Homepage = "https://github.com/JonasHeinickeBio/pyEuropePMC", Repository = "https://github.com/JonasHeinickeBio/pyEuropePMC" } [tool.poetry] packages = [{ include = "pyeuropepmc", from = "src" }] include = [{ path = "src/pyeuropepmc/py.typed", format = ["sdist", "wheel"] }] # PEP 735 dependency group. Both uv and Poetry 2.x read this; the previous # [tool.poetry.group.dev.dependencies] form was Poetry-only, so uv could not # see the dev toolchain at all and locked an incomplete graph. [dependency-groups] dev = [ "pytest>=9.0.3,<10.0.0", "pytest-cov>=6.0.0,<7.0.0", "pytest-xdist>=3.7.0,<4.0.0", "pytest-socket>=0.7,<1.0", "pytest-timeout>=2.3,<3.0", "pytest-asyncio>=1.0,<2.0", "pre-commit>=4.2.0,<5.0.0", "mypy>=1.16.1,<2.0.0", "types-PyYAML==6.0.12.20240311", "types-requests>=2.31.0,<3.0.0", "pytest-benchmark>=5.2.3,<6.0.0", "coverage>=7.11.3,<8.0.0", "bandit>=1.8.5,<2.0.0", "ruff>=0.14.4,<0.15.0", "typing-extensions>=4.14.0,<5.0.0", "memory-profiler>=0.61.0,<0.62.0", ] [tool.poetry.dependencies] python = ">=3.10,<4.0" # --- core runtime (installed by a bare `pip install pyeuropepmc`) --- backoff = ">=2.2.1,<3.0" beautifulsoup4 = ">=4.13.5,<5.0" cachetools = ">=7.1.8,<8.0" click = ">=8.0,<8.2" defusedxml = ">=0.7.1,<0.8" diskcache = ">=5.6.3,<6.0" mcp = ">=1.20.0,<2.0.0" pydantic = ">=2.7.4,<3.0" python-dotenv = ">=0.19" # Imported at module level by pyeuropepmc.mappers; it only ever arrived # through the `standard` and `agentic` extras. pyyaml = ">=6.0.1,<7.0" # 3.x has wheels for current Pythons; 2.15.2, the last 2.x, has none past # 3.11. text_match passes the 2.x default processor, so scores are unchanged. rapidfuzz = ">=2.15.0,<4.0" rdflib = ">=6.0" requests = ">=2.32.5,<3.0" search-query = ">=0.15.0,<0.16" tenacity = ">=9.1.4,<10.0" tqdm = ">=4.67.1,<5.0" typer = ">=0.12.5" # --- optional, each declared exactly ONCE --------------------------------- # This single-declaration form is what makes `poetry lock` finish. Repeating a # requirement across several [project.optional-dependencies] groups makes # Poetry treat it as a duplicate dependency, create an override and restart # version solving: pandas appeared in 5 groups and locking never terminated # (2,895 restarts / 1.4M lines of trace observed). Declared once and merely # *named* by the extras below, the same graph locks in ~30s. pandas = { version = ">=1.4,<3", optional = true } numpy = { version = ">=1.21,<3", optional = true } matplotlib = { version = ">=3.7.0,<4.0", optional = true } seaborn = { version = ">=0.12.0,<1.0", optional = true } xlsxwriter = { version = ">=3.2.9,<4.0", optional = true } tabulate = { version = ">=0.9.0,<1.0", optional = true } flask = { version = ">=3.1.1,<4.0", optional = true } tornado = { version = ">=6.5.1,<7.0", optional = true } cryptography = { version = ">=48.0.1,<51.0", optional = true } huggingface-hub = { version = ">=0.23,<2.0", optional = true } bibtexparser = { version = ">=1.4.0", optional = true } pyzotero = { version = ">=1.5.0", optional = true } langchain = { version = ">=1.3.9,<2.0", optional = true } langchain-openai = { version = ">=1.1.14,<2.0", optional = true } # langchain-openai 1.x requires openai >=2.26; the old <2 bound is what # blocked the security bump of the agentic stack. openai = { version = ">=2.26,<3", optional = true } langgraph = { version = ">=1.0.10,<2.0", optional = true } jinja2 = { version = ">=3.1.2,<4.0", optional = true } semanticscholar = { version = ">=0.12.0,<1.0", optional = true } requests-cache = { version = ">=1.2.1,<2.0", optional = true } rich = { version = ">=13.7.0", optional = true } ipython = { version = ">=7.30", optional = true } ipykernel = { version = ">=6.7", optional = true } ipywidgets = { version = ">=7.6", optional = true } jupyterlab = { version = ">=3.2", optional = true } notebook = { version = ">=6.4", optional = true } [tool.poetry.extras] analytics = ["numpy", "pandas"] visualization = ["matplotlib", "numpy", "pandas", "seaborn"] export = ["pandas", "tabulate", "xlsxwriter"] # rdflib (core) writes JSON-LD itself since 6.0, which made rdflib-jsonld # redundant. Kept empty so `pyeuropepmc[rdf]` still installs cleanly. rdf = [] ui = ["flask", "tornado"] signing = ["cryptography"] bibliography = ["bibtexparser"] zotero = ["pyzotero"] agentic = ["jinja2", "langchain", "langchain-openai", "langgraph", "openai"] semanticscholar = ["semanticscholar"] benchmark = ["huggingface-hub"] enrichment = ["semanticscholar"] standard = ["ipykernel", "ipython", "ipywidgets", "jupyterlab", "matplotlib", "notebook", "numpy", "pandas", "requests-cache", "rich", "seaborn", "tabulate", "xlsxwriter"] all = ["bibtexparser", "cryptography", "flask", "huggingface-hub", "ipykernel", "ipython", "ipywidgets", "jinja2", "jupyterlab", "langchain", "langchain-openai", "langgraph", "matplotlib", "notebook", "numpy", "openai", "pandas", "pyzotero", "requests-cache", "rich", "seaborn", "semanticscholar", "tabulate", "tornado", "xlsxwriter"] [tool.poetry.requires-plugins] poetry-plugin-export = ">=1.8" [project.scripts] pyeuropepmc = "pyeuropepmc.cli:app" pyeuropepmc-mcp = "pyeuropepmc.mcp.server:_main_entry" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] # The default run is fast and hermetic: every slow / network / benchmark / e2e # category is excluded, plus (when pytest-socket / pytest-timeout are installed, # see tests/conftest.py::pytest_configure) real sockets are blocked and each # test gets a 120s wall-clock cap. Categories are inferred from a test's path in # tests/conftest.py, so whole `functional/` and `integration/` directories are # excluded even when individual files carry no marker, and a test in no other # category is marked `unit`. # Run the excluded lanes explicitly: # pytest -m functional # or: pytest --run-real # pytest -m benchmark --benchmark-only # pytest tests/integration --run-integration # pytest -m slow addopts = "-ra -q --strict-markers --disable-socket --allow-unix-socket --timeout=120 -m 'not slow and not functional and not network and not benchmark and not e2e'" testpaths = ["tests"] asyncio_mode = "auto" markers = [ "slow: mark test as slow (use '-m \"not slow\"' to skip)", "integration: mark test as integration (requires --run-integration)", "unit: fast, hermetic test; inferred in tests/conftest.py for any test in no other category", "network: needs outbound network access; excluded by default", "functional: exercises real services / full pipelines; excluded by default", "benchmark: pytest-benchmark performance test; excluded by default", "e2e: end-to-end scenario; excluded by default", "gui: exercises the Flask web UI", "model: mark tests that require ML model dependencies (sentence-transformers, rapidfuzz, etc.)", ] [tool.ruff] line-length = 99 target-version = "py310" exclude = [ ".git", ".venv", "dist", "build", "docs", "__pycache__", ".pytest_cache", ".vscode", ".github", ".idea", "examples", ] [tool.ruff.lint] select = [ "E", # pycodestyle errors "F", # pyflakes "W", # pycodestyle warnings "C90", # mccabe complexity "I", # isort "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify # XML is parsed with defusedxml only: report the standard-library parsers # (S313-S319) and any lxml import (TID251, banned below). "S313", "S314", "S315", "S316", "S317", "S318", "S319", "TID251", ] ignore = ["E203", "E266", "E731"] [tool.ruff.lint.per-file-ignores] "tests/**" = ["E501"] # long XML/JSON fixture strings; code lines are wrapped by ruff format "__init__.py" = ["F401"] # Allow unused imports in __init__.py "**/cli/__init__.py" = ["E402"] # imports after the optional-typer guard "linkml_models.py" = ["E402", "E501"] # auto-generated by LinkML gen-python "conversion.py" = ["C901"] # CSL field mapping "normalization.py" = ["C901"] # author-name parsing rules "app.py" = ["C901"] # Flask route registration "rhetorical.py" = ["E501"] # long rhetorical-role regex patterns "**/claims/*.py" = ["E501"] # long LLM prompt / evidence strings "**/agentic/*.py" = ["E501"] # long LLM prompt strings "**/prompts/__init__.py" = ["E501"] # prompt templates "ftp_downloader.py" = ["C901"] # Allow complex FTP operations "fulltext_parser.py" = [ "C901", ] # Allow complex XML parsing/analysis methods "enricher.py" = [ "C901", ] # Allow complex merge operations "base.py" = [ "C901", "E501", ] # Allow complex request handling and long lines "annotations_to_rdf.py" = [ "C901", ] # Allow complex RDF mapping operations "error_codes.py" = [ "E501", ] # Allow long lines for error message documentation "exceptions.py" = [ "E501", ] # Allow long lines for error message documentation "semantic_scholar.py" = [ "E501", ] # Allow long lines for long URLs "*/cli/*.py" = [ "B006", # Typer uses list/dict literals as option defaults "B008", # Typer uses function calls in argument defaults "E501", # long help strings ] [tool.ruff.lint.flake8-tidy-imports.banned-api] # defusedxml is the only XML parser. S313-S319 catch the common standard-library # calls; these catch what those rules miss. "lxml".msg = "Parse XML with defusedxml, the only XML parser pyeuropepmc uses." "defusedxml.lxml".msg = "defusedxml.lxml imports lxml; use defusedxml.ElementTree." "xml.etree.ElementTree.XML".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." "xml.etree.ElementTree.XMLPullParser".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." "xml.etree.ElementTree.fromstringlist".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." "xml.etree.ElementInclude".msg = "Its default loader parses with the standard library." "xml.parsers.expat".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." "pyexpat".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." "xmltodict".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." "pandas.read_xml".msg = "Parse XML with pyeuropepmc.core.xml_parsing.parse_xml." [tool.ruff.lint.mccabe] max-complexity = 15 [tool.ruff.lint.isort] known-first-party = ["pyeuropepmc"] force-sort-within-sections = true combine-as-imports = true [tool.ruff.lint.flake8-quotes] inline-quotes = "double" multiline-quotes = "double" docstring-quotes = "double" [tool.coverage.run] branch = true source = ["pyeuropepmc"] [tool.coverage.report] show_missing = true skip_covered = true fail_under = 75 # matches the cdci.yml coverage gate (--fail-under=75) # The one bandit configuration: CI, `make quality` and the pre-commit hook all # pass `-c pyproject.toml`. B110/B112 (try/except/pass and /continue) stay # enabled; CI always checked them, and src/ has no findings for either. [tool.bandit] skips = ["B101", "B303"] exclude_dirs = [ "tests", "docs", "examples", ".venv", "dist", "build", ".git", "__pycache__", ".pytest_cache", ".vscode", ".github", ".idea", ] paths = ["src"] [tool.mypy] # NOT the minimum Python this package supports (that's 3.10 - see # `requires-python` above); this is the syntax mypy itself is allowed to # *parse*, including inside third-party stub files it follows imports into. # It was raised to 3.12 when poetry.lock resolved numpy 2.5.x on Python >=3.12: # those stubs use PEP 695 `type X = ...` / `class Foo[T]` syntax, which mypy # refuses to parse under python_version = "3.10", aborting the ENTIRE run. # follow_imports overrides don't help, because mypy always processes PEP 561 # inline-typed packages such as numpy normally. The lock now pins numpy 2.2.6 # for every Python (via the analytics/visualization/export extras), and mypy # passes under 3.10 with it; 3.12 is kept so a numpy update cannot bring the # abort back. python-compatibility.yml compiles every file under real # 3.10/3.11/3.12/3.13 interpreters, which guards src/ against syntax a # supported version lacks. python_version = "3.12" strict = true ignore_missing_imports = true exclude = "(tests/|examples/)" files = "src/" plugins = ["pydantic.mypy"] disable_error_code = ["misc", "import-untyped"] # The optional LLM / web-UI stack (extras `agentic`, `ui`) leans heavily on # langchain / langgraph / flask, whose own typing is incomplete — strict # checking there is high-noise, low-value. Tracked as tech-debt. [[tool.mypy.overrides]] module = [ "pyeuropepmc.agentic.*", "pyeuropepmc.claims.*", "pyeuropepmc.cli.claim", "pyeuropepmc.cli.agentic", "pyeuropepmc.ui.*", "pyeuropepmc.prompts.*", ] ignore_errors = true # pyzotero (extra `zotero`) ships no type info; the client is effectively Any. [[tool.mypy.overrides]] module = "pyeuropepmc.features.bibliography.zotero" warn_unused_ignores = false