From 531968801b4d70a35dad9007780307bd0eb72904 Mon Sep 17 00:00:00 2001 From: Alex Lancaster Date: Sun, 17 Nov 2024 23:17:24 -0500 Subject: [PATCH] Add new command-line option `--citation` for installed version (#228) * add `--citation` flag to print out CITATION.cff * generate different citation formats at build-time: apalike, bibtex, endnote, ris, codemeta, zenodo, schema.org * allow this to be chosen at runtime, default to simple `apalike`: fallback to top-level `CITATION.cff` * use customized fork of `cffconvert` * Remove `cffconvert` from build-system requires in pyproject.toml: generate citations outside `cibuildwheel` --- .github/workflows/build_wheels.yml | 39 ++++++++++++++-- CITATION.cff | 6 +-- MANIFEST.in | 1 + README.rst | 74 ++++++++++++++++++++---------- pyproject.toml | 23 ++++++---- setup.py | 41 +++++++++++++++-- src/PyPop/CommandLineInterface.py | 38 ++++++++++++++- src/PyPop/citation.py | 72 +++++++++++++++++++++++++++++ 8 files changed, 248 insertions(+), 46 deletions(-) create mode 100644 src/PyPop/citation.py diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 646232e25..d0c5a142d 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -19,7 +19,7 @@ on: - '.github/workflows/documentation.yaml' - '.github/workflows/buildjet_arm64.yml' - '.github/workflows/release-drafter.yml' - - '.github/workflows/codeql.yml' + - '.github/workflows/codeql.yml' - '.gitattributes' push: paths-ignore: @@ -34,7 +34,7 @@ on: - '.github/workflows/documentation.yaml' - '.github/workflows/buildjet_arm64.yml' - '.github/workflows/release-drafter.yml' - - '.github/workflows/codeql.yml' + - '.github/workflows/codeql.yml' - '.gitattributes' release: types: @@ -178,12 +178,45 @@ jobs: if: runner.os == 'Linux' uses: docker/setup-qemu-action@v3 with: - platforms: all + platforms: all + - name: Install a recent stable Python to handle Python deps + uses: actions/setup-python@v4 + with: + python-version: 3.12 + - name: Query version with setuptools_scm + id: version + shell: bash + run: | + python -m pip install setuptools_scm + VERSION=$(python -c "from src.PyPop import __version_scheme__; import setuptools_scm; print(setuptools_scm.get_version(version_scheme=__version_scheme__))") + echo "VERSION=${VERSION}" + echo "VERSION=${VERSION}" >> $GITHUB_ENV + - name: Install toml and remove cffconvert from pyproject.toml + run: | + python -m pip install toml + python -c " + import toml + with open('pyproject.toml', 'r') as f: + config = toml.load(f) + if 'build-system' in config and 'requires' in config['build-system']: + config['build-system']['requires'] = [ + dep for dep in config['build-system']['requires'] if 'cffconvert' not in dep.lower() + ] + with open('pyproject.toml', 'w') as f: + toml.dump(config, f) + " + - name: Generate citation formats + run: | + python --version + python -m pip install git+https://github.com/alexlancaster/cffconvert.git@combine_features#egg=cffconvert + python src/PyPop/citation.py - name: Build and test wheels uses: pypa/cibuildwheel@v2.21.3 env: # FIXME: only run the slow tests when doing regular pushes, or manual - not for PRs CIBW_TEST_COMMAND: "pytest -v {package}/tests ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '--runslow' || '' }}" + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ env.VERSION }} + CIBW_ENVIRONMENT_PASS_LINUX: SETUPTOOLS_SCM_PRETEND_VERSION with: only: ${{ matrix.only }} package-dir: . diff --git a/CITATION.cff b/CITATION.cff index 1d0a479fc..d60f9ee2b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -73,6 +73,9 @@ url: http://pypop.org/ repository-artifact: https://pypi.org/project/pypop-genomics/ repository-code: https://github.com/alexlancaster/pypop type: software +license: GPL-2.0-or-later +version: v1.1.1 +doi: 10.5281/zenodo.13742984 keywords: - population genetics - population genomics @@ -83,6 +86,3 @@ keywords: - Major histocompatibility complex - HLA - MHC -license: GPL-2.0-or-later -version: v1.1.1 -doi: 10.5281/zenodo.13742984 diff --git a/MANIFEST.in b/MANIFEST.in index 425215a1a..1e3f3c3df 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ include DEV_NOTES.md include LICENSE include NEWS.rst include MANIFEST.in +include CITATION.cff prune .github prune website prune data/* diff --git a/README.rst b/README.rst index a01fc5061..0782d760f 100644 --- a/README.rst +++ b/README.rst @@ -27,31 +27,55 @@ If you write a paper that uses PyPop in your analysis, please cite `10.3389/fimmu.2024.1378512 `__ -* **and** the `Zenodo record `__ - for the software. To cite the correct version, follow these steps: - - 1) First visit the DOI for the overall Zenodo record: - `10.5281/zenodo.10080667 - `__. This DOI - represents **all versions**, and will always resolve to the - latest one. - - 2) When you are viewing the record, look for the **Versions** box - in the right-sidebar. Here are listed all versions (including - older versions). - - 3) Select and click the version-specific DOI that matches the - specific version of PyPop that you used for your analysis. - - 4) Once you are visiting the Zenodo record for the specific version, - under the **Citation** box in the right-sidebar, select the - citation format you wish to use and click to copy the citation. - It will contain link to the version-specific DOI, and be of the - form: - - Lancaster, AK et al. (YYYY) "PyPop: Python for Population - Genomics" (Version X.Y.Z) [Computer - software]. Zenodo. https://doi.org/10.5281/zenodo.XXXXX +* **and** a citation to the `Zenodo record + `__ which includes a DOI for + the version of the software you used in your analyses. Citing this + record and DOI supports reproducibility by allowing researchers to + to determine the exact version of PyPop used in any particular + analysis. In addition, it allows retrieval of long-term software + source-code archives, independent of the original developers. + + Here's how to cite the correct version: + + * If you have PyPop version 1.1.2 or later, currently installed, you + can run: + + .. code-block:: shell + + pypop --citation + + which outputs the Zenodo record citation in the simple "APA" + format (you can also choose from BibTeX, EndNote, RIS and other + formats, see the section on `command-line interfaces + `_ + in the *User Guide* for more details). + + * If you do not have PyPop installed, have a release of PyPop + earlier than 1.1.2, or otherwise want to obtain the DOI and + citation for specific versions, follow these steps: + + 1) First visit the DOI for the overall Zenodo record: + `10.5281/zenodo.10080667 + `__. This DOI + represents **all versions**, and will always resolve to the + latest one. + + 2) When you are viewing the record, look for the **Versions** box + in the right-sidebar. Here are listed all versions (including + older versions). + + 3) Select and click the version-specific DOI that matches the + specific version of PyPop that you used for your analysis. + + 4) Once you are visiting the Zenodo record for the specific version, + under the **Citation** box in the right-sidebar, select the + citation format you wish to use and click to copy the citation. + It will contain link to the version-specific DOI, and be of the + form: + + Lancaster, AK et al. (YYYY) "PyPop: Python for Population + Genomics" (Version X.Y.Z) [Computer + software]. Zenodo. https://doi.org/10.5281/zenodo.XXXXX Note that citation metadata for the current Zenodo record is also stored in `CITATION.cff diff --git a/pyproject.toml b/pyproject.toml index 7fa0544e7..982350209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,16 +17,18 @@ skip = ["*-win32", "*_i686", # skip 32-bit builds "cp313-musllinux_x86_64", # problem with this version "cp36-musllinux_*", "cp37-musllinux_*", "cp38-musllinux_*"] # older musllinux missing numpy wheels test-extras = ["test"] -test-command = "pytest -v {package}/tests" -# FIXME: add below test-command unit tests need to be saved -# "&& echo {package} && ls && tar zcvf unit_tests_output.tar.gz run_test_* && cp unit_tests_output.tar.gz {package}/wheelhouse/" - -# Skip trying to test arm64 builds on Intel Macs as per -# https://cibuildwheel.readthedocs.io/en/stable/faq/#apple-silicon -# test-skip = "*-macosx_arm64 *-macosx_universal2:arm64" -# don't try and install pypi packages and build from source + +# FIXME: can add "test-command" that would allow unit test output to be saved +# "pytest -v {package}/tests && echo {package} && ls && tar zcvf unit_tests_output.tar.gz run_test_* && cp unit_tests_output.tar.gz {package}/wheelhouse/" + +# don't try and install pypi packages that need build from source +# this is mainly import during the testing phase environment = { PIP_ONLY_BINARY=":all:" } +# use pip and override the PIP_ONLY_BINARY=:all: during wheel generation +# so that certain source-only build deps (like cffconvert) install +build-frontend = { name = "pip", args = ["--only-binary=:none:"] } + [[tool.cibuildwheel.overrides]] # for latest CPython use newer manylinux image select = "cp312-*linux*" @@ -93,10 +95,15 @@ select ="*-win_*" inherit.environment="append" environment = { CPATH="gsl-msvc14-x64.2.3.0.2779\\\\build\\\\native", LIBRARY_PATH="gsl-msvc14-x64.2.3.0.2779\\\\build\\\\native\\\\static" } +[tool.setuptools_scm] +write_to = "src/PyPop/_version.py" # matches the path where version will be written + [build-system] build-backend = "setuptools.build_meta:__legacy__" requires = ["setuptools>=42", "setuptools_scm[toml]>=6.2", + "cffconvert @ git+https://github.com/alexlancaster/cffconvert.git@combine_features#egg=cffconvert", "importlib-metadata; python_version <= '3.8'" ] + diff --git a/setup.py b/setup.py index ade18baaa..3097aff54 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ from glob import glob from setuptools import setup from setuptools.extension import Extension +from setuptools.command.build_py import build_py as _build_py +from setuptools.command.install import install as _install from distutils.command import clean from sysconfig import _PREFIX, get_config_vars, get_config_var from src.PyPop import __pkgname__, __version_scheme__ @@ -210,10 +212,36 @@ def path_to_src(source_path_list): # don't include HWEEnum # extensions.append(ext_HweEnum) -data_file_paths = [] +xslt_data_file_paths = [] # xslt files are in a subdirectory xslt_files = [f + '.xsl' for f in ['text', 'html', 'lib', 'common', 'filter', 'hardyweinberg', 'homozygosity', 'emhaplofreq', 'meta-to-tsv', 'sort-by-locus', 'haplolist-by-group', 'phylip-allele', 'phylip-haplo']] -data_file_paths.extend(xslt_files) +xslt_data_file_paths.extend(xslt_files) + +citation_data_file_paths = [] +# citation files are in a subdirectory of PyPop, but not a separate module +from src.PyPop.citation import citation_output_formats, convert_citation_formats +citation_files = [os.path.join("citation", 'CITATION.' + suffix) for suffix in citation_output_formats] +citation_data_file_paths.extend(citation_files) + +# currently disabled (these are built in a github action) +class CustomBuildPy(_build_py): + def run(self): + + # do standard build process + super().run() + + # if not running from a CIBUILDWHEEL environment variable + # we need to create the citations + if os.environ.get('CIBUILDWHEEL') != '1': + + # source citation path (single-source of truth) + citation_path = "CITATION.cff" + + # then copy CITATION.cff to temp build directory + # use setuptools' temp build directory + build_lib = self.get_finalized_command('build').build_lib + + convert_citation_formats(build_lib, citation_path) # read the contents of your README file from pathlib import Path @@ -250,7 +278,8 @@ def path_to_src(source_path_list): ], package_dir = {"": src_dir}, packages = ["PyPop", "PyPop.xslt"], - package_data={"PyPop.xslt": data_file_paths}, + package_data = {"PyPop.xslt": xslt_data_file_paths, + "PyPop": citation_data_file_paths}, install_requires = ["numpy <= 2.1.3", "lxml <= 5.3.0", "importlib-resources; python_version <= '3.8'", @@ -265,6 +294,8 @@ def path_to_src(source_path_list): 'pypop-interactive=PyPop.pypop:main_interactive'] }, ext_modules=extensions, - cmdclass={'clean': CleanCommand,}, + cmdclass={'clean': CleanCommand, + # enable the custom build + 'build_py': CustomBuildPy, + }, ) - diff --git a/src/PyPop/CommandLineInterface.py b/src/PyPop/CommandLineInterface.py index f92a0d571..41a253a4e 100644 --- a/src/PyPop/CommandLineInterface.py +++ b/src/PyPop/CommandLineInterface.py @@ -34,9 +34,10 @@ # UPDATES, ENHANCEMENTS, OR MODIFICATIONS. import os, sys -from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter, RawDescriptionHelpFormatter, FileType +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter, RawDescriptionHelpFormatter, FileType, Action from pathlib import Path -from PyPop import platform_info # global info +from PyPop import platform_info # global info +from PyPop.citation import citation_output_formats # and citation formats """Command-line interface for PyPop scripts """ @@ -45,6 +46,37 @@ class PyPopFormatter(ArgumentDefaultsHelpFormatter, RawDescriptionHelpFormatter): pass +class CitationAction(Action): + + def __call__(self, parser, namespace, values, option_string=None): + + citation_format = values or 'apalike' + citation_file_name = f'citation/CITATION.{citation_format}' + + try: # looking in installed package + from importlib.resources import files + citation_file = files('PyPop').joinpath(citation_file_name) + citation_text = citation_file.read_text() + except (ModuleNotFoundError, ImportError, FileNotFoundError): # fallback to using backport if not found + try: + from importlib_resources import files + citation_file = files('PyPop').joinpath(citation_file_name) + citation_text = citation_file.read_text() + except (ModuleNotFoundError, ImportError, FileNotFoundError): # fallback to looking in top-level directory if running from repo + top_level_dir = Path(__file__).resolve().parent.parent.parent + citation_file = top_level_dir / 'CITATION.cff' # only output CFF + + if citation_file.exists(): + print("only CITATION.cff is available") + print() + citation_text = citation_file.read_text() + else: + print("could not locate the specified citation format.") + parser.exit() + + print(citation_text) + parser.exit() # exit after printing the file + def get_parent_cli(version="", copyright_message=""): # options common to both scripts parent_parser = ArgumentParser(add_help=False) @@ -52,6 +84,8 @@ def get_parent_cli(version="", copyright_message=""): # define function arguments as signatures - need to be added in child parser as part of the selection logic common_args = [ (["-h", "--help"], {'action': "help", 'help': "show this help message and exit"}), + (["--citation"], {'help': "generate citation to PyPop for this version of PyPop", + 'action': CitationAction, 'nargs':'?', 'choices': citation_output_formats, 'default':'apalike'}), (["-o", "--outputdir"], {'help':"put output in directory OUTPUTDIR", 'required':False, 'type':Path, 'default':None}), (["-V", "--version"], {'action':'version', diff --git a/src/PyPop/citation.py b/src/PyPop/citation.py new file mode 100644 index 000000000..7a8632e38 --- /dev/null +++ b/src/PyPop/citation.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python + +# This file is part of PyPop + +# Copyright (C) 2024. +# All Rights Reserved. + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2, or (at your option) +# any later version. + +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA +# 02111-1307, USA. + +# IN NO EVENT SHALL REGENTS BE LIABLE TO ANY PARTY FOR DIRECT, +# INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING +# LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +# DOCUMENTATION, EVEN IF REGENTS HAS BEEN ADVISED OF THE POSSIBILITY +# OF SUCH DAMAGE. + +# REGENTS SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE. THE SOFTWARE AND ACCOMPANYING +# DOCUMENTATION, IF ANY, PROVIDED HEREUNDER IS PROVIDED "AS +# IS". REGENTS HAS NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, +# UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +import os +import shutil + +citation_output_formats = ['apalike', 'bibtex', 'endnote', 'ris', 'codemeta', 'cff', 'schema.org', 'zenodo'] + +def convert_citation_formats(build_lib, citation_path): + + from cffconvert import Citation + + # target directory for the CITATION file within the build directory + target_dir = os.path.join(build_lib, "PyPop", "citation") + + # create the citation directory if it doesn’t exist + os.makedirs(target_dir, exist_ok=True) + shutil.copy(citation_path, target_dir) + + # load the CITATION.cff content + cff = Citation(cffstr=open(citation_path).read()) + + # remove 'cff' from generated list - since we don't generate that + citation_output_formats.remove('cff') + + for fmt in citation_output_formats: + # use getattr to get the method based on the format string + convert_method = getattr(cff, f"as_{fmt}", None) + if callable(convert_method): + converted_content = convert_method() + else: + print(f"Conversion format '{fmt}' not supported.") + + # save the converted output (e.g., as CITATION.json) + with open(os.path.join(target_dir, "CITATION." + fmt), "w") as f: + f.write(converted_content) + +if __name__ == "__main__": + + convert_citation_formats("src", "CITATION.cff")