"""Conservative, deterministic applicability checks for linked policy options. Source verification and a permission for a particular manuscript/repository are different facts. Never combine the licence from one option with another option's location or embargo. Missing context remains a manual check. """ from __future__ import annotations from datetime import date, datetime, timezone import re from typing import Any SLOTS = ("postprint", "preprint", "published_version") _EMPTY = {"", "not stated", "not specified", "unknown", "unclear", "none stated"} def _text(value: Any) -> str: return " ".join(str(value or "").split()) def normalise_version(value: str) -> str: value = re.sub(r"[^a-z0-9]+", " ", _text(value).casefold()) if not value or value == "all versions": return "" if any(term in value for term in ("postprint", "post print", "accepted", "aam")): return "postprint" if any(term in value for term in ("preprint", "pre print", "submitted")): return "preprint" if any(term in value for term in ("published", "version of record", "publisher version", "vor")): return "published_version" return "unknown" def _values(value: Any) -> list[str]: if isinstance(value, list): return [text for item in value if (text := _text(item)) and text.casefold() not in _EMPTY] text = _text(value) return [part.strip() for part in re.split(r"[;,]", text) if part.strip().casefold() not in _EMPTY] def _location(value: str) -> str: text = re.sub(r"[-_]+", " ", _text(value).casefold()) text = " ".join(text.split()) if text.startswith("subject repository (e.g."): text = "subject repository" aliases = {"institutional repositories": "institutional repository", "khazna": "institutional repository", "ku repository": "institutional repository", "personal website": "author website", "author's website": "author website", "non commercial subject repositories": "non commercial subject repository", "subject repositories": "subject repository"} return aliases.get(text, text) def _location_match(target: str, locations: list[str]) -> str: desired = _location(target) known = [_location(value) for value in locations] if not desired or desired in {"unsure", "other", "any", "not sure"} or not known: return "unknown" if desired in known: return "yes" if desired.endswith("repository") and "any repository" in known: return "yes" if desired == "non commercial subject repository" and "subject repository" in known: return "yes" if desired == "subject repository" and "non commercial subject repository" in known: return "unknown" # The user has not established the required non-commercial status. return "no" def _licence(value: str) -> str: text = _text(value).casefold().replace("creative commons", "cc") match = re.search(r"creativecommons\.org/(?:licenses|publicdomain)/([^/]+)", text) if match: text = "cc " + ("0" if match.group(1) == "zero" else match.group(1)) text = re.sub(r"\b\d+\.\d+\b|\binternational\b|\bunported\b", "", text) text = text.replace("attribution", "by").replace("noncommercial", "nc") text = text.replace("non-commercial", "nc").replace("no derivatives", "nd") text = text.replace("noderivatives", "nd").replace("sharealike", "sa") return re.sub(r"[^a-z0-9]", "", text) def embargo_release(option: dict, today: date | None = None) -> dict: """Interpret an explicit article end date; never derive one from publication date.""" today = today or datetime.now(timezone.utc).date() end = _text(option.get("embargo_end")) months = option.get("embargo_months", option.get("_embargo_months")) status = _text(option.get("embargo_status") or option.get("_embargo_status")).lower() result = {"embargo_status": status if status in {"zero", "stated", "expired"} else "unknown", "embargo_months": months, "embargo_end": end, "embargo_date_valid": None} if end: try: # A date is an inclusive release date. Offset timestamps are first # converted to UTC; future time on today's date is not called expired. if re.fullmatch(r"\d{4}-\d{2}-\d{2}", end): release = date.fromisoformat(end) expired = release <= today else: stamp = datetime.fromisoformat(end.replace("Z", "+00:00")) if stamp.tzinfo is None: raise ValueError("article release timestamp requires a timezone") utc_stamp = stamp.astimezone(timezone.utc) # With a date-only evaluation clock, use the start of that UTC day. expired = utc_stamp <= datetime.combine(today, datetime.min.time(), timezone.utc) result.update(embargo_status="expired" if expired else "stated", embargo_date_valid=True) except (ValueError, TypeError, OverflowError): result.update(embargo_status="unknown", embargo_date_valid=False) return result def policy_options(green: dict) -> list[dict]: options = [] for slot in SLOTS: data = green.get(slot) if not isinstance(data, dict): continue entries = data.get("options") # Older snapshots have a single flat slot. An explicitly empty options # list means no policy was listed and must not become a permission. if not isinstance(entries, list): stated = _text(data.get("permission_status")).lower() in {"allowed", "conditional", "denied"} stated = stated or _text(data.get("allowed")).lower() in {"yes", "no", "conditional"} entries = [data] if stated else [] for entry in entries: if isinstance(entry, dict): options.append({**entry, "version": slot}) return options def _evaluate_single(green: dict, manuscript_version: str = "Accepted manuscript (AAM / postprint)", intended_repository: str = "Institutional repository", intended_licence: str = "", funder: str = "", today: date | None = None) -> dict: """Evaluate each complete option and return one applicable option, if any. ``meets`` establishes a sharing option for the supplied selection, not compliance with an unseen funder mandate. Free-text conditions always need review. Different options are never merged into an apparent permission. """ context = {"manuscript_version": manuscript_version, "intended_repository": intended_repository, "intended_licence": intended_licence, "funder": funder} base = {"state": "unknown", "permission_status": "unknown", "selected_option": None, "unresolved_conditions": [], "option_checks": [], "context": context, "embargo_status": "unknown", "embargo_months": None, "embargo_end": ""} if not isinstance(green, dict) or green.get("policy_status") != "Confirmed": base["unresolved_conditions"] = ["Verify the policy at Open Policy Finder before depositing."] return base version = normalise_version(manuscript_version) options = [o for o in policy_options(green) if not version or o["version"] == version] if not options: base["unresolved_conditions"] = ["No verified policy option was found for the selected manuscript version."] return base assessed = [] for option in options: reasons, mismatches = [], [] permission = _text(option.get("permission_status")).casefold() allowed = _text(option.get("allowed")).casefold() if not permission: permission = {"yes": "allowed", "no": "denied", "conditional": "conditional"}.get(allowed, "unknown") location = _location_match(intended_repository, _values(option.get("locations") or option.get("where"))) if location == "no": mismatches.append("This option does not list the selected deposit location.") elif location == "unknown": reasons.append("Confirm that this option permits the selected deposit location.") requested_licence = _licence(intended_licence) unspecified_licences = {"unsure", "notsure", "other", "notspecified", "unknown", "publishersbespokelicence"} if requested_licence in unspecified_licences: reasons.append("Confirm which licence you intend to apply before opening the file.") if requested_licence and requested_licence not in unspecified_licences: licences = _values(option.get("licences") or option.get("licence")) if not licences: reasons.append("The option does not establish permission to apply the selected licence.") elif requested_licence not in {_licence(value) for value in licences}: mismatches.append("This option does not list the selected licence.") conditions = _values(option.get("conditions")) reasons.extend(conditions) funders = _values(option.get("funders")) if funders: if not _text(funder): reasons.append("Confirm eligibility for the funder-specific policy option: " + "; ".join(funders)) elif _text(funder).casefold() not in {_text(value).casefold() for value in funders}: mismatches.append("The selected funder does not match this funder-specific option.") if _text(funder): reasons.append("Check the selected funder's current mandate; publisher permission alone does not establish funder compliance.") if permission == "conditional" and not conditions: reasons.append("The policy marks this permission as conditional; verify its eligibility conditions.") if permission not in {"allowed", "conditional", "denied"}: reasons.append("Permission is not established for this option.") timing = embargo_release(option, today) applicability_reasons = list(reasons) if timing["embargo_status"] == "unknown" and permission != "denied": reasons.append("Verify the release timing; this option does not state a usable embargo or article release date.") state = ("does_not_meet" if mismatches or permission == "denied" else "unknown" if permission not in {"allowed", "conditional"} else "conditional" if reasons else "meets") selected_permission = ("denied" if permission == "denied" else "unknown" if mismatches else "unknown" if permission not in {"allowed", "conditional"} else "conditional" if applicability_reasons else permission) assessed.append({"state": state, "permission_status": selected_permission, "option": option, "unresolved_conditions": list(dict.fromkeys(reasons)), "mismatches": mismatches, **timing}) base["option_checks"] = assessed viable = [item for item in assessed if item["state"] in {"meets", "conditional"}] if viable: order = {"zero": 0, "expired": 0, "stated": 1, "unknown": 2} winner = min(viable, key=lambda item: (item["state"] != "meets", len(item["unresolved_conditions"]), order[item["embargo_status"]], SLOTS.index(item["option"]["version"]))) # A denial for the same target is not silently overridden by an allowance. conflicts = [item for item in assessed if item["permission_status"] == "denied" and not item["mismatches"] and item["option"]["version"] == winner["option"]["version"]] if conflicts: winner = {**winner, "state": "conditional", "permission_status": "conditional", "unresolved_conditions": winner["unresolved_conditions"] + ["Conflicting permission options require source review."]} def route_key(item): record = item["option"] return (record["version"], tuple(sorted(_location(value) for value in _values(record.get("locations") or record.get("where")))), tuple(sorted(_licence(value) for value in _values(record.get("licences") or record.get("licence")))), tuple(sorted(_values(record.get("funders")))), tuple(sorted(_values(record.get("conditions"))))) def timing_key(item): if item["embargo_status"] in {"zero", "expired"}: return ("released",) return (item["embargo_status"], item["embargo_months"], item["embargo_end"]) # Two indistinguishable routes with contradictory timing cannot be # resolved by choosing whichever embargo happens to be shortest. timing_conflict = any(route_key(item) == route_key(winner) and timing_key(item) != timing_key(winner) for item in viable) if timing_conflict: winner = {**winner, "state": "conditional", "permission_status": "conditional", "unresolved_conditions": winner["unresolved_conditions"] + ["Otherwise matching policy options state different embargo timing; verify the applicable record."]} return {**base, **{key: value for key, value in winner.items() if key not in {"option", "mismatches"}}, "selected_option": winner["option"]} base["state"] = "unknown" if any(item["state"] == "unknown" for item in assessed) else "does_not_meet" # A location/licence mismatch is not a general prohibition on self-archiving. base["permission_status"] = "denied" if all(item["permission_status"] == "denied" for item in assessed) else "unknown" base["unresolved_conditions"] = list(dict.fromkeys(reason for item in assessed for reason in item["mismatches"] + item["unresolved_conditions"])) if not base["unresolved_conditions"]: base["unresolved_conditions"] = ["The verified record does not permit the selected version at the selected location."] return base def evaluate_policy(green: dict, manuscript_version: str = "Accepted manuscript (AAM / postprint)", intended_repository: str = "Institutional repository", intended_licence: str = "", funder: str = "", today: date | None = None) -> dict: """Evaluate one destination or require separate complete options for both.""" if _text(intended_repository).casefold() not in {"both institutional & subject", "both institutional and subject"}: return _evaluate_single(green, manuscript_version, intended_repository, intended_licence, funder, today) version = normalise_version(manuscript_version) versions = [manuscript_version] if version else list(SLOTS) groups = [] for selected_version in versions: results = [_evaluate_single(green, selected_version, destination, intended_licence, funder, today) for destination in ("Institutional repository", "Subject repository")] states = [item["state"] for item in results] state = ("does_not_meet" if "does_not_meet" in states else "unknown" if "unknown" in states else "conditional" if "conditional" in states else "meets") permissions = [item["permission_status"] for item in results] permission = ("allowed" if all(value == "allowed" for value in permissions) else "denied" if all(value == "denied" for value in permissions) else "conditional" if state == "conditional" else "unknown") reasons = list(dict.fromkeys(f"{item['context']['intended_repository']}: {reason}" for item in results for reason in item["unresolved_conditions"])) options = [item["selected_option"] for item in results if item["selected_option"]] statuses = [item["embargo_status"] for item in results] timing_same = len({(item["embargo_status"], item["embargo_months"], item["embargo_end"]) for item in results}) == 1 if all(status in {"zero", "expired"} for status in statuses): timing = {"embargo_status": "expired" if "expired" in statuses else "zero", "embargo_months": None, "embargo_end": next((item["embargo_end"] for item in results if item["embargo_end"]), "")} elif timing_same: timing = {key: results[0][key] for key in ("embargo_status", "embargo_months", "embargo_end")} else: timing = {"embargo_status": "unknown", "embargo_months": None, "embargo_end": ""} if state == "meets": state, permission = "conditional", "conditional" reasons.append("The destinations have different release conditions; follow each linked option separately before opening files.") groups.append({"state": state, "permission_status": permission, "selected_option": options[0] if len(options) == 2 else None, "selected_options": options if len(options) == 2 else [], "destination_checks": results, "option_checks": [check for item in results for check in item["option_checks"]], "unresolved_conditions": reasons, **timing, "context": {"manuscript_version": manuscript_version, "intended_repository": intended_repository, "intended_licence": intended_licence, "funder": funder}}) rank = {"meets": 0, "conditional": 1, "unknown": 2, "does_not_meet": 3} return min(groups, key=lambda item: rank[item["state"]])