paperpush 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. {paperpush-0.2.0 → paperpush-0.2.1}/PKG-INFO +6 -3
  2. {paperpush-0.2.0 → paperpush-0.2.1}/README.md +4 -1
  3. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/__init__.py +1 -1
  4. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/cli.py +39 -2
  5. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/database.py +120 -5
  6. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/references.py +1 -1
  7. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/requirements.py +167 -4
  8. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/sensitive.py +1 -1
  9. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/iclr_2027.sub +1 -0
  10. paperpush-0.2.1/paperpush/venue_data.py +374 -0
  11. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues.json +1 -0
  12. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush.egg-info/PKG-INFO +6 -3
  13. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush.egg-info/SOURCES.txt +2 -0
  14. {paperpush-0.2.0 → paperpush-0.2.1}/pyproject.toml +2 -2
  15. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_login.py +501 -2
  16. paperpush-0.2.1/tests/test_venue_data.py +498 -0
  17. {paperpush-0.2.0 → paperpush-0.2.1}/LICENSE +0 -0
  18. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/__main__.py +0 -0
  19. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/_docs/agents.md +0 -0
  20. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/_logging.py +0 -0
  21. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/autofill.py +0 -0
  22. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/credentials.py +0 -0
  23. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/manuscript.py +0 -0
  24. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/manuscript_requirements.json +0 -0
  25. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/manuscript_requirements.schema.json +0 -0
  26. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/mcp_server.py +0 -0
  27. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/orcid.py +0 -0
  28. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/requirements_check.py +0 -0
  29. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/schema_models.py +0 -0
  30. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile.py +0 -0
  31. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/arxiv.sub +0 -0
  32. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/bioinformatics.sub +0 -0
  33. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/biorxiv.sub +0 -0
  34. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/bmc_bioinformatics.sub +0 -0
  35. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/cell.sub +0 -0
  36. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/cell_genomics.sub +0 -0
  37. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/cell_systems.sub +0 -0
  38. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/combinatorica.sub +0 -0
  39. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/discrete_mathematics.sub +0 -0
  40. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/genome_biology.sub +0 -0
  41. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/medrxiv.sub +0 -0
  42. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/nature.sub +0 -0
  43. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/nature_biotech.sub +0 -0
  44. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/nature_methods.sub +0 -0
  45. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/nucleic_acids_research.sub +0 -0
  46. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/plos_compbio.sub +0 -0
  47. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/science.sub +0 -0
  48. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/science_advances.sub +0 -0
  49. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/science_immunology.sub +0 -0
  50. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/science_robotics.sub +0 -0
  51. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/science_signaling.sub +0 -0
  52. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/subfile_templates/science_translational_medicine.sub +0 -0
  53. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/validate.py +0 -0
  54. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/__init__.py +0 -0
  55. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/aaai_2027_countries.txt +0 -0
  56. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/aaai_2027_topics.txt +0 -0
  57. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/arxiv_categories.txt +0 -0
  58. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/bioinformatics_keywords.txt +0 -0
  59. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/genome_biology_institution_countries.txt +0 -0
  60. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/iclr_2027_primary_areas.txt +0 -0
  61. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/nature_biotech_categories.json +0 -0
  62. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/nature_categories.json +0 -0
  63. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/nature_methods_categories.json +0 -0
  64. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/plos_compbio_classifications.txt +0 -0
  65. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/plos_compbio_funding_countries.txt +0 -0
  66. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/science_advances_subjects.txt +0 -0
  67. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/science_immunology_subjects.txt +0 -0
  68. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/science_robotics_subjects.txt +0 -0
  69. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/science_signaling_subjects.txt +0 -0
  70. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/science_subjects.txt +0 -0
  71. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/_assets/science_translational_medicine_subjects.txt +0 -0
  72. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/arxiv/__init__.py +0 -0
  73. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/arxiv/arxiv.py +0 -0
  74. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/base.py +0 -0
  75. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/common.py +0 -0
  76. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/discrete_mathematics/__init__.py +0 -0
  77. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/discrete_mathematics/discrete_mathematics.py +0 -0
  78. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editflow/__init__.py +0 -0
  79. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editflow/combinatorica.py +0 -0
  80. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editflow/main.py +0 -0
  81. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editorialmanager/__init__.py +0 -0
  82. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editorialmanager/cell.py +0 -0
  83. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editorialmanager/cell_genomics.py +0 -0
  84. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editorialmanager/cell_systems.py +0 -0
  85. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editorialmanager/main.py +0 -0
  86. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/editorialmanager/plos_compbio.py +0 -0
  87. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/login.py +0 -0
  88. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/nature/__init__.py +0 -0
  89. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/nature/main.py +0 -0
  90. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/nature/nature.py +0 -0
  91. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/nature/nature_biotech.py +0 -0
  92. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/nature/nature_methods.py +0 -0
  93. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openreview/__init__.py +0 -0
  94. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openreview/aaai_2027.py +0 -0
  95. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openreview/iclr_2027.py +0 -0
  96. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openreview/main.py +0 -0
  97. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openrxiv/__init__.py +0 -0
  98. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openrxiv/biorxiv.py +0 -0
  99. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openrxiv/main.py +0 -0
  100. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/openrxiv/medrxiv.py +0 -0
  101. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/scholarone/__init__.py +0 -0
  102. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/scholarone/bioinformatics.py +0 -0
  103. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/scholarone/main.py +0 -0
  104. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/scholarone/nucleic_acids_research.py +0 -0
  105. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/science/__init__.py +0 -0
  106. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/science/science.py +0 -0
  107. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/snapp/__init__.py +0 -0
  108. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/snapp/bmc_bioinformatics.py +0 -0
  109. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/snapp/genome_biology.py +0 -0
  110. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/snapp/main.py +0 -0
  111. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/template/__init__.py +0 -0
  112. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues/template/template.py +0 -0
  113. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush/venues.schema.json +0 -0
  114. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush.egg-info/dependency_links.txt +0 -0
  115. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush.egg-info/entry_points.txt +0 -0
  116. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush.egg-info/requires.txt +0 -0
  117. {paperpush-0.2.0 → paperpush-0.2.1}/paperpush.egg-info/top_level.txt +0 -0
  118. {paperpush-0.2.0 → paperpush-0.2.1}/setup.cfg +0 -0
  119. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_autofill.py +0 -0
  120. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_editorialmanager_login.py +0 -0
  121. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_internals.py +0 -0
  122. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_logging.py +0 -0
  123. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_mcp_server.py +0 -0
  124. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_portal_drift.py +0 -0
  125. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_references.py +0 -0
  126. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_requirements.py +0 -0
  127. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_sensitive.py +0 -0
  128. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_subfile.py +0 -0
  129. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_submit.py +0 -0
  130. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_validate.py +0 -0
  131. {paperpush-0.2.0 → paperpush-0.2.1}/tests/test_venue_interface.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paperpush
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A Python library for automating the submission of manuscripts to academic venues.
5
5
  Author-email: Joseph Rich <josephrich98@gmail.com>
6
6
  Maintainer-email: Joseph Rich <josephrich98@gmail.com>
7
- License-Expression: MIT
7
+ License-Expression: BSD-2-Clause
8
8
  Project-URL: Homepage, https://github.com/pachterlab/paperpush
9
9
  Project-URL: Repository, https://github.com/pachterlab/paperpush
10
10
  Project-URL: Documentation, https://github.com/pachterlab/paperpush#readme
@@ -51,7 +51,7 @@ playwright install chromium
51
51
  ## Use with AI
52
52
 
53
53
  ```bash
54
- git clone https://github.com/paperpush/paperpush.git
54
+ git clone https://github.com/pachterlab/paperpush.git
55
55
  cd paperpush
56
56
  ```
57
57
 
@@ -97,6 +97,7 @@ the tool list and the contract agents should follow.
97
97
 
98
98
  Helper commands:
99
99
  - `paperpush requirements VENUE`: print VENUE's author guidelines, if available.
100
+ - `paperpush update-venues`: Update venues.json and manuscript_requirements.json from the remote venue-data branch.
100
101
  - `paperpush options VENUE.FIELD`: print the allowed values for FIELD in VENUE's submission form.
101
102
 
102
103
  See the YouTube tutorial [PaperPush Tutorial](https://youtu.be/ChCYoud0uBk)
@@ -123,6 +124,8 @@ See `python scripts/paperpush_pipeline.py --help` for the full list of options,
123
124
 
124
125
  View the list on the command line with `paperpush --venues`.
125
126
 
127
+ Venue details (field options, limits, author-guideline rules) are updated independently of releases: paperpush fetches the latest published data at most once a day, and `paperpush update-venues` fetches it right away. New venues arrive with new releases (`pip install -U paperpush`).
128
+
126
129
  For more details, see [`venues.md`](venues.md)
127
130
 
128
131
  ## Documentation
@@ -19,7 +19,7 @@ playwright install chromium
19
19
  ## Use with AI
20
20
 
21
21
  ```bash
22
- git clone https://github.com/paperpush/paperpush.git
22
+ git clone https://github.com/pachterlab/paperpush.git
23
23
  cd paperpush
24
24
  ```
25
25
 
@@ -65,6 +65,7 @@ the tool list and the contract agents should follow.
65
65
 
66
66
  Helper commands:
67
67
  - `paperpush requirements VENUE`: print VENUE's author guidelines, if available.
68
+ - `paperpush update-venues`: Update venues.json and manuscript_requirements.json from the remote venue-data branch.
68
69
  - `paperpush options VENUE.FIELD`: print the allowed values for FIELD in VENUE's submission form.
69
70
 
70
71
  See the YouTube tutorial [PaperPush Tutorial](https://youtu.be/ChCYoud0uBk)
@@ -91,6 +92,8 @@ See `python scripts/paperpush_pipeline.py --help` for the full list of options,
91
92
 
92
93
  View the list on the command line with `paperpush --venues`.
93
94
 
95
+ Venue details (field options, limits, author-guideline rules) are updated independently of releases: paperpush fetches the latest published data at most once a day, and `paperpush update-venues` fetches it right away. New venues arrive with new releases (`pip install -U paperpush`).
96
+
94
97
  For more details, see [`venues.md`](venues.md)
95
98
 
96
99
  ## Documentation
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.2.1"
6
6
  __url__ = "https://github.com/pachterlab/paperpush"
7
7
 
8
8
  # Standard-library convention: a library should not configure logging output.
@@ -20,6 +20,7 @@ Implemented so far:
20
20
  paperpush login --orcid <j> store an ORCID iD/password for a journal that
21
21
  offers "Sign in with ORCID"
22
22
  paperpush submit <subfile> open bioRxiv and run the submission
23
+ paperpush update-venues fetch the latest published venue data now
23
24
  paperpush --agent-guide print the guide for AI agents (AGENTS.md)
24
25
 
25
26
  ``submit`` drives the bioRxiv wizard, typing in the field values read from the
@@ -40,7 +41,7 @@ from pathlib import Path
40
41
 
41
42
  from pydantic import ConfigDict, validate_call
42
43
 
43
- from . import __url__, __version__, credentials
44
+ from . import __url__, __version__, credentials, venue_data
44
45
  from ._logging import configure_logging
45
46
  from .database import get_venue, list_venues
46
47
  from .subfile import default_filename, write_template
@@ -112,6 +113,37 @@ def _print_venues() -> int:
112
113
  print(f" {venue.name} ({venue.slug})")
113
114
 
114
115
  print(f"\nFor more details, see {_VENUES_URL}")
116
+ print(f"Venue data: {venue_data.active_source().describe()} (refresh with 'paperpush update-venues')")
117
+ return 0
118
+
119
+
120
+ @_validate
121
+ def _cmd_update_venues(args: argparse.Namespace) -> int:
122
+ """Fetch the published venue data now, or clear the cached copy.
123
+
124
+ The data is otherwise refreshed on its own at most once a day; this is for
125
+ picking up a fix right away, and for seeing which copy is in use.
126
+ """
127
+ from .database import reload
128
+
129
+ if args.clear:
130
+ venue_data.clear_cache()
131
+ print(f"Removed the cached venue data; using the data {venue_data.bundled_source().describe()}.")
132
+ return 0
133
+
134
+ if not venue_data.uses_published():
135
+ print("note: published venue data is not used here (PAPERPUSH_VENUE_DATA, or a source " "checkout); fetching it into the cache anyway.", file=sys.stderr)
136
+ try:
137
+ result = venue_data.refresh(force=True)
138
+ except Exception as exc:
139
+ print(f"error: could not fetch the venue data from {venue_data.data_url()}: {exc}", file=sys.stderr)
140
+ return 1
141
+ reload()
142
+ print(result.message if result.status != "incompatible" else f"warning: {result.message}")
143
+ source = venue_data.active_source()
144
+ print(f"In use: {source.describe()}")
145
+ if source.kind != "bundled":
146
+ print(f"Files: {source.root}")
115
147
  return 0
116
148
 
117
149
 
@@ -946,6 +978,7 @@ def _cmd_requirements(args: argparse.Namespace) -> int:
946
978
  print(f" {url}")
947
979
  if reqs.inherits:
948
980
  print(f" (starts from {reqs.inherits}'s rules)")
981
+ print(f" venue data: {venue_data.active_source().describe()}")
949
982
  if article_types and not args.article_type:
950
983
  print(f" article types with their own rules: {', '.join(article_types)} (use --article-type)")
951
984
  for section in ("manuscript", "title_page", "abstract", "keywords", "sections", "statements", "figures", "tables", "supplementary", "references", "cover_letter", "upload"):
@@ -1050,7 +1083,7 @@ def build_parser() -> argparse.ArgumentParser:
1050
1083
 
1051
1084
  # metavar lists only the public commands; the internal 'schema' command is
1052
1085
  # registered below but deliberately left out so it does not appear in --help.
1053
- sub = parser.add_subparsers(dest="command", metavar="{subfile,options,autofill,validate,requirements,login,submit}")
1086
+ sub = parser.add_subparsers(dest="command", metavar="{subfile,options,autofill,validate,requirements,login,submit,update-venues}")
1054
1087
 
1055
1088
  p_subfile = sub.add_parser("subfile", parents=[verbosity], help="create a <venue>.sub submission template")
1056
1089
  p_subfile.add_argument("venue", help="venue slug, e.g. biorxiv")
@@ -1172,6 +1205,10 @@ def build_parser() -> argparse.ArgumentParser:
1172
1205
  )
1173
1206
  p_submit.set_defaults(func=_cmd_submit)
1174
1207
 
1208
+ p_update = sub.add_parser("update-venues", parents=[verbosity], help="fetch the latest published venue data (otherwise refreshed daily)")
1209
+ p_update.add_argument("--clear", action="store_true", help="delete the cached copy and use the data bundled with this version")
1210
+ p_update.set_defaults(func=_cmd_update_venues)
1211
+
1175
1212
  # Internal command: the autofill front-ends (the Claude skill and the API
1176
1213
  # engine) read field roles from here. Hidden from --help (use 'subfile' to
1177
1214
  # inspect a venue's fields by hand).
@@ -3,6 +3,11 @@
3
3
  The database is a single JSON file (``venues.json``) shipped with the
4
4
  package. Each top-level key is a venue slug (e.g. ``"biorxiv"``) mapping to a
5
5
  venue definition that lists the fields required for submission.
6
+
7
+ A newer copy may also be published separately from releases and cached locally
8
+ (see :mod:`paperpush.venue_data`). The bundled file is always the baseline: a
9
+ published entry replaces a bundled one only when the installed runner can
10
+ drive it (see :func:`merge_published`).
6
11
  """
7
12
 
8
13
  from __future__ import annotations
@@ -18,8 +23,12 @@ from typing import Annotated, Any, Literal, Optional
18
23
 
19
24
  from pydantic import Field as PField
20
25
 
26
+ from . import venue_data
27
+
21
28
  logger = logging.getLogger(__name__)
22
29
 
30
+ # The copies shipped with the package. The files actually read may come from a
31
+ # published copy instead; see paperpush.venue_data.
23
32
  DATABASE_PATH = Path(__file__).with_name("venues.json")
24
33
 
25
34
  # Directory holding shared keyword/vocabulary lists that a field can pull in via
@@ -36,7 +45,10 @@ def _load_options_file(name: str) -> list[str]:
36
45
  portal keywords or the PLOS Computational Biology classifications) lives in
37
46
  one shared file rather than being inlined in ``venues.json``.
38
47
  """
39
- path = ASSETS_DIR / name
48
+ # A published copy may add or update a vocabulary; the bundled one backs it.
49
+ path = venue_data.active_source().assets / name
50
+ if not path.is_file():
51
+ path = ASSETS_DIR / name
40
52
  logger.debug("Loading field options from %s", path)
41
53
  lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines()]
42
54
  return [line for line in lines if line]
@@ -444,15 +456,118 @@ class Venue:
444
456
  )
445
457
 
446
458
 
459
+ def _read_database(path: Path) -> dict[str, Any]:
460
+ logger.debug("Loading venue database from %s", path)
461
+ with path.open(encoding="utf-8") as fh:
462
+ return json.load(fh)
463
+
464
+
447
465
  @lru_cache(maxsize=1)
448
466
  def _load_raw() -> dict[str, Any]:
449
- logger.debug("Loading venue database from %s", DATABASE_PATH)
450
- with DATABASE_PATH.open(encoding="utf-8") as fh:
451
- data = json.load(fh)
452
- logger.info("Loaded %d venue entr(y/ies) from the database", len(data))
467
+ source = venue_data.active_source()
468
+ bundled = _read_database(DATABASE_PATH)
469
+ if source.kind == "bundled":
470
+ data = bundled
471
+ else:
472
+ try:
473
+ published = _read_database(source.path(venue_data.VENUES_FILE))
474
+ except (OSError, ValueError) as exc:
475
+ logger.warning("Could not read the venue data from %s (%s); using the bundled copy", source.root, exc)
476
+ published = bundled
477
+ # A local override is the author's own data, used as is; a published
478
+ # copy is merged venue by venue onto what this version can drive.
479
+ data = published if source.kind == "override" else merge_published(bundled, published)
480
+ logger.info("Loaded %d venue entr(y/ies) from the database (%s)", len(data), source.describe())
453
481
  return data
454
482
 
455
483
 
484
+ def _field_shape(slug: str, raw: dict[str, Any]) -> list[tuple[str, str]]:
485
+ """The (id, type) of each field of ``slug``, after inheritance, in order.
486
+
487
+ This is what a venue's runner is written against: it reads fields by id and
488
+ fills each according to its type. Everything else about a field -- label,
489
+ help, options, limits, whether it is required -- is data the runner does not
490
+ hard-code, so it can change without a code release.
491
+ """
492
+ return [(f.get("id", ""), f.get("type", "text")) for f in _resolve_entry(slug, raw).get("fields", [])]
493
+
494
+
495
+ def merge_published(bundled: dict[str, Any], published: dict[str, Any]) -> dict[str, Any]:
496
+ """Overlay a published venue database onto the bundled one, venue by venue.
497
+
498
+ A published entry replaces the bundled entry only when
499
+
500
+ * the bundled database has the venue (a new venue needs its runner, which
501
+ only a release can bring),
502
+ * its fields keep the bundled shape (:func:`_field_shape`) -- adding,
503
+ removing, renaming, reordering or retyping a field needs runner changes,
504
+ * it resolves (e.g. its ``inherits`` base exists), and
505
+ * any venue it inherits from is itself taken from the published copy, so an
506
+ entry is never resolved against a mix of old and new bases.
507
+
508
+ Every other venue keeps its bundled entry, and a note is logged so a user on
509
+ an old version knows an upgrade would bring more.
510
+ """
511
+ accepted: set[str] = set()
512
+ for slug in bundled:
513
+ if slug not in published:
514
+ continue
515
+ try:
516
+ if _field_shape(slug, published) == _field_shape(slug, bundled):
517
+ accepted.add(slug)
518
+ except KeyError as exc:
519
+ logger.warning("Published venue data: %s does not resolve (%s); keeping the bundled entry", slug, exc)
520
+
521
+ # Drop entries whose base was not accepted, until nothing changes (a chain
522
+ # of inheritance can take several passes).
523
+ changed = True
524
+ while changed:
525
+ changed = False
526
+ for slug in sorted(accepted):
527
+ base = str(published[slug].get("inherits", "") or "")
528
+ if base and base not in accepted and base.lower() not in accepted:
529
+ accepted.discard(slug)
530
+ changed = True
531
+
532
+ held_back = sorted(set(published) - accepted)
533
+ if held_back:
534
+ logger.info("Published venue data for %s needs a newer paperpush; using the bundled entries. " "Run 'pip install -U paperpush' to get them.", ", ".join(held_back))
535
+ return {slug: published[slug] if slug in accepted else entry for slug, entry in bundled.items()}
536
+
537
+
538
+ def check_venue_data(root: Path) -> None:
539
+ """Raise if the venue data in directory ``root`` does not load.
540
+
541
+ Run on a downloaded copy before it replaces the cache: every venue must
542
+ resolve and build, every ``options_file`` it names must exist, and the
543
+ manuscript requirements must load too.
544
+ """
545
+ from .requirements import check_requirements_file
546
+
547
+ raw = _read_database(root / venue_data.VENUES_FILE)
548
+ if not isinstance(raw, dict) or not raw:
549
+ raise ValueError("venues.json holds no venues")
550
+ for slug in raw:
551
+ entry = _resolve_entry(slug, raw)
552
+ Venue.from_dict(slug, {**entry, "fields": []})
553
+ for f in entry.get("fields", []):
554
+ name = f.get("options_file")
555
+ if name and not (root / venue_data.ASSETS_SUBDIR / name).is_file() and not (ASSETS_DIR / name).is_file():
556
+ raise ValueError(f"{slug}.{f.get('id')} names a missing options_file {name!r}")
557
+ Field.from_dict({k: v for k, v in f.items() if k != "options_file"})
558
+ check_requirements_file(root / venue_data.REQUIREMENTS_FILE)
559
+
560
+
561
+ def reload() -> None:
562
+ """Re-resolve the venue data source and drop every cached load of it."""
563
+ from . import requirements
564
+
565
+ venue_data.reset()
566
+ _load_raw.cache_clear()
567
+ _load_options_file.cache_clear()
568
+ requirements._load_raw.cache_clear()
569
+
570
+
456
571
  # Keys an inheriting entry uses to declare how it derives from its base rather
457
572
  # than to describe a key of the resulting venue; they are consumed by
458
573
  # _resolve_entry and not copied verbatim onto the merged venue.
@@ -637,7 +637,7 @@ def _resolve_doi(doi: str, *, timeout: float = DOI_CHECK_TIMEOUT) -> tuple[str,
637
637
  },
638
638
  )
639
639
  try:
640
- with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 - scheme is a literal https above
640
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # scheme is a literal https above # nosec B310
641
641
  payload = resp.read(MAX_TEXT_BYTES)
642
642
  except urllib.error.HTTPError as exc:
643
643
  if exc.code in _MISSING_STATUS:
@@ -40,9 +40,14 @@ from pathlib import Path
40
40
  from typing import Annotated, Any, Literal, Optional
41
41
 
42
42
  from pydantic import Field as PField
43
+ from pydantic import TypeAdapter, ValidationError
44
+
45
+ from . import venue_data
43
46
 
44
47
  logger = logging.getLogger(__name__)
45
48
 
49
+ # The copy shipped with the package; the file actually read may be a newer
50
+ # published copy (see paperpush.venue_data).
46
51
  REQUIREMENTS_PATH = Path(__file__).with_name("manuscript_requirements.json")
47
52
 
48
53
  # Where figures/tables sit for the initial submission.
@@ -383,14 +388,34 @@ def _section_to_dict(section) -> dict[str, Any]:
383
388
  return out
384
389
 
385
390
 
391
+ def _read_requirements(path: Path) -> dict[str, Any]:
392
+ logger.debug("Loading manuscript requirements from %s", path)
393
+ with path.open(encoding="utf-8") as fh:
394
+ data = json.load(fh)
395
+ if not isinstance(data, dict):
396
+ raise ValueError(f"{path} is not a JSON object")
397
+ return data
398
+
399
+
386
400
  @lru_cache(maxsize=1)
387
401
  def _load_raw() -> dict[str, Any]:
388
- logger.debug("Loading manuscript requirements from %s", REQUIREMENTS_PATH)
402
+ source = venue_data.active_source()
389
403
  if not REQUIREMENTS_PATH.exists():
390
404
  logger.warning("No manuscript requirements database at %s", REQUIREMENTS_PATH)
391
- return {}
392
- with REQUIREMENTS_PATH.open(encoding="utf-8") as fh:
393
- data = json.load(fh)
405
+ bundled: dict[str, Any] = {}
406
+ else:
407
+ bundled = _read_requirements(REQUIREMENTS_PATH)
408
+ data = bundled
409
+ if source.kind != "bundled":
410
+ path = source.path(venue_data.REQUIREMENTS_FILE)
411
+ try:
412
+ published = _read_requirements(path)
413
+ except (OSError, ValueError) as exc:
414
+ logger.warning("Could not read %s (%s); using the bundled copy", path, exc)
415
+ else:
416
+ # A local override is the author's own data, used as is; a published
417
+ # copy is merged entry by entry onto what this version can read.
418
+ data = published if source.kind == "override" else merge_published(bundled, published)
394
419
  logger.info("Loaded %d manuscript requirement entr(y/ies)", sum(1 for k in data if not k.startswith("$")))
395
420
  return data
396
421
 
@@ -462,6 +487,144 @@ def _resolve_entry(slug: str, raw: dict[str, Any], seen: tuple[str, ...] = ()) -
462
487
  return merged
463
488
 
464
489
 
490
+ # Keys an entry may carry at its top level (``slug`` is the database key, not a
491
+ # key inside the entry), and keys an ``article_types`` override may carry.
492
+ _ENTRY_KEYS = frozenset(f.name for f in dataclass_fields(ManuscriptRequirements)) - {"slug"}
493
+ _OVERRIDE_KEYS = frozenset(SECTION_TYPES) | {"notes"}
494
+ _ALIASES_ADAPTER = TypeAdapter(dict[str, dict[str, list[str]]])
495
+
496
+
497
+ @lru_cache(maxsize=None)
498
+ def _adapter(typ: type) -> TypeAdapter:
499
+ return TypeAdapter(typ)
500
+
501
+
502
+ def _without_nulls(section: dict[str, Any]) -> dict[str, Any]:
503
+ # A null drops an inherited key (see merge_entries), so it is always allowed.
504
+ return {k: v for k, v in section.items() if v is not None}
505
+
506
+
507
+ def _rules_problem(rules: Any, allowed: frozenset[str], where: str) -> Optional[str]:
508
+ """Why this version cannot read ``rules`` (an entry or an override), or None."""
509
+ if not isinstance(rules, dict):
510
+ return f"{where} is not an object"
511
+ unknown = sorted(set(rules) - allowed)
512
+ if unknown:
513
+ return f"{where} has key(s) this version does not know: {', '.join(unknown)}"
514
+ for key, typ in SECTION_TYPES.items():
515
+ section = rules.get(key)
516
+ if section is None:
517
+ continue
518
+ if not isinstance(section, dict):
519
+ return f"{where}.{key} is not an object"
520
+ known = {f.name for f in dataclass_fields(typ)}
521
+ unknown = sorted(set(section) - known)
522
+ if unknown:
523
+ return f"{where}.{key} has rule(s) this version does not know: {', '.join(unknown)}"
524
+ try:
525
+ _adapter(typ).validate_python(_without_nulls(section))
526
+ except ValidationError as exc:
527
+ return f"{where}.{key} does not match this version's rule types ({exc.errors()[0]['msg']})"
528
+ if not isinstance(rules.get("notes") or [], list):
529
+ return f"{where}.notes is not a list"
530
+ return None
531
+
532
+
533
+ def _entry_problem(slug: str, entry: Any) -> Optional[str]:
534
+ """Why this version cannot apply the published ``entry``, or None if it can.
535
+
536
+ The rules are only data, but ``validate`` is code: it measures the uploads
537
+ against the keys and value types the installed section dataclasses define. A
538
+ newer copy may add a rule this version would silently skip, or change a
539
+ value's type in a way that would break the check at validation time, so
540
+ such an entry is caught here, when the copy is loaded, instead.
541
+ """
542
+ if slug.startswith("$"):
543
+ if slug == "$aliases":
544
+ try:
545
+ _ALIASES_ADAPTER.validate_python(entry)
546
+ except ValidationError as exc:
547
+ return f"$aliases does not match this version's shape ({exc.errors()[0]['msg']})"
548
+ return None
549
+ problem = _rules_problem(entry, _ENTRY_KEYS, slug)
550
+ if problem:
551
+ return problem
552
+ top = {k: entry[k] for k in ("source_urls", "retrieved", "inherits", "article_type_field", "notes") if entry.get(k) is not None}
553
+ try:
554
+ _adapter(ManuscriptRequirements).validate_python({"slug": slug, **top})
555
+ except ValidationError as exc:
556
+ return f"{slug} does not match this version's entry types ({exc.errors()[0]['msg']})"
557
+ overrides = entry.get("article_types") or {}
558
+ if not isinstance(overrides, dict):
559
+ return f"{slug}.article_types is not an object"
560
+ for name, override in overrides.items():
561
+ problem = _rules_problem(override, _OVERRIDE_KEYS, f"{slug}.article_types[{name!r}]")
562
+ if problem:
563
+ return problem
564
+ return None
565
+
566
+
567
+ def merge_published(bundled: dict[str, Any], published: dict[str, Any]) -> dict[str, Any]:
568
+ """Overlay a published requirements database onto the bundled one, entry by entry.
569
+
570
+ The published copy is authoritative for the data: it may add, change, or
571
+ drop an entry (no runner depends on these rules, so, unlike a venue in
572
+ ``venues.json``, a new entry needs no release). Each entry is still checked
573
+ against this version's rule vocabulary (:func:`_entry_problem`). An entry
574
+ this version cannot read keeps its bundled version, if there is one. So does
575
+ an entry that inherits from a held-back or missing base, so an entry is never
576
+ resolved against a mix of old and new rules.
577
+ """
578
+ held_back: dict[str, str] = {}
579
+ accepted: set[str] = set()
580
+ for slug, entry in published.items():
581
+ problem = _entry_problem(slug, entry)
582
+ if problem:
583
+ held_back[slug] = problem
584
+ else:
585
+ accepted.add(slug)
586
+
587
+ # Drop entries whose base was not accepted, until nothing changes (a chain
588
+ # of inheritance can take several passes).
589
+ lowered = {s.lower(): s for s in accepted}
590
+ changed = True
591
+ while changed:
592
+ changed = False
593
+ for slug in sorted(accepted):
594
+ base = str(published[slug].get("inherits", "") or "") if not slug.startswith("$") else ""
595
+ if base and base.lower() not in lowered:
596
+ accepted.discard(slug)
597
+ lowered.pop(slug.lower(), None)
598
+ held_back[slug] = f"{slug} inherits from {base!r}, which is held back or missing"
599
+ changed = True
600
+
601
+ for slug, problem in sorted(held_back.items()):
602
+ logger.info("Published manuscript requirements: %s; using the bundled entry. " "Run 'pip install -U paperpush' to get it.", problem)
603
+
604
+ merged: dict[str, Any] = {}
605
+ for slug, entry in published.items():
606
+ if slug in accepted:
607
+ merged[slug] = entry
608
+ elif slug in bundled:
609
+ merged[slug] = bundled[slug]
610
+ return merged
611
+
612
+
613
+ def check_requirements_file(path: Path) -> None:
614
+ """Raise if the requirements database at ``path`` does not load.
615
+
616
+ Checks the database this version would actually use -- the file merged onto
617
+ the bundled copy by :func:`merge_published` -- so every entry must resolve
618
+ and build.
619
+ """
620
+ published = _read_requirements(path)
621
+ bundled = _read_requirements(REQUIREMENTS_PATH) if REQUIREMENTS_PATH.exists() else {}
622
+ raw = merge_published(bundled, published)
623
+ for slug in raw:
624
+ if not slug.startswith("$"):
625
+ ManuscriptRequirements.from_dict(slug, _resolve_entry(slug, raw))
626
+
627
+
465
628
  def list_requirements() -> list[ManuscriptRequirements]:
466
629
  """Every venue entry in the database, sorted by slug."""
467
630
  raw = _load_raw()
@@ -694,7 +694,7 @@ def _url_is_reachable(url: str, *, timeout: float = LINK_CHECK_TIMEOUT) -> bool
694
694
  for method in ("HEAD", "GET"):
695
695
  req = urllib.request.Request(url, method=method, headers={"User-Agent": "paperpush-validate"})
696
696
  try:
697
- with urllib.request.urlopen(req, timeout=timeout): # nosec B310 - scheme constrained to http(s) by _URL_RE
697
+ with urllib.request.urlopen(req, timeout=timeout): # scheme constrained to http(s) by _URL_RE # nosec B310
698
698
  return True
699
699
  except urllib.error.HTTPError as exc:
700
700
  if exc.code in _BROKEN_STATUS:
@@ -22,6 +22,7 @@ keywords:
22
22
 
23
23
  # TL;DR (text, optional)
24
24
  # Optional. A one-sentence summary of the paper ("too long; didn't read").
25
+ # at most 250 characters
25
26
  tldr:
26
27
 
27
28
  # Abstract (textarea, REQUIRED)