Coverage for changes_metadata_manager / zenodo_api.py: 100%

79 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-07-17 16:43 +0000

1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5import json 

6import sqlite3 

7import time 

8from pathlib import Path 

9 

10import requests 

11from piccione.upload.on_zenodo import get_headers 

12from rich.console import Console 

13 

14from changes_metadata_manager.zenodo_metadata import ZenodoUpdatePayload 

15 

16console = Console() 

17 

18MAX_RETRIES = 5 

19BASE_BACKOFF = 10 

20REQUEST_TIMEOUT = 120 

21RETRYABLE_STATUS_CODES = {429, 502, 503, 504} 

22CACHE_TTL_SECONDS = 24 * 60 * 60 

23 

24 

25class ZenodoRecordCache: 

26 def __init__(self, path: Path, ttl_seconds: int = CACHE_TTL_SECONDS) -> None: 

27 self.ttl_seconds = ttl_seconds 

28 self.connection = sqlite3.connect(path) 

29 self.connection.execute( 

30 """ 

31 CREATE TABLE IF NOT EXISTS records ( 

32 zenodo_url TEXT NOT NULL, 

33 record_id TEXT NOT NULL, 

34 fetched_at REAL NOT NULL, 

35 has_edit_draft INTEGER NOT NULL, 

36 record_json TEXT NOT NULL, 

37 PRIMARY KEY (zenodo_url, record_id) 

38 ) 

39 """ 

40 ) 

41 self.connection.commit() 

42 

43 def __enter__(self) -> "ZenodoRecordCache": 

44 return self 

45 

46 def __exit__( 

47 self, 

48 exc_type: type[BaseException] | None, 

49 exc_value: BaseException | None, 

50 traceback: object | None, 

51 ) -> None: 

52 self.connection.close() 

53 

54 def get(self, zenodo_url: str, record_id: str) -> tuple[dict, bool] | None: 

55 normalized_url = zenodo_url.rstrip("/") 

56 row = self.connection.execute( 

57 """ 

58 SELECT fetched_at, has_edit_draft, record_json 

59 FROM records 

60 WHERE zenodo_url = ? AND record_id = ? 

61 """, 

62 (normalized_url, record_id), 

63 ).fetchone() 

64 if row is None: 

65 return None 

66 

67 fetched_at, has_edit_draft, record_json = row 

68 if time.time() - fetched_at >= self.ttl_seconds: 

69 self.invalidate(normalized_url, record_id) 

70 return None 

71 return json.loads(record_json), bool(has_edit_draft) 

72 

73 def set( 

74 self, 

75 zenodo_url: str, 

76 record_id: str, 

77 record: dict, 

78 has_edit_draft: bool, 

79 ) -> None: 

80 self.connection.execute( 

81 """ 

82 INSERT INTO records ( 

83 zenodo_url, record_id, fetched_at, has_edit_draft, record_json 

84 ) VALUES (?, ?, ?, ?, ?) 

85 ON CONFLICT (zenodo_url, record_id) DO UPDATE SET 

86 fetched_at = excluded.fetched_at, 

87 has_edit_draft = excluded.has_edit_draft, 

88 record_json = excluded.record_json 

89 """, 

90 ( 

91 zenodo_url.rstrip("/"), 

92 record_id, 

93 time.time(), 

94 has_edit_draft, 

95 json.dumps(record), 

96 ), 

97 ) 

98 self.connection.commit() 

99 

100 def invalidate(self, zenodo_url: str, record_id: str) -> None: 

101 self.connection.execute( 

102 "DELETE FROM records WHERE zenodo_url = ? AND record_id = ?", 

103 (zenodo_url.rstrip("/"), record_id), 

104 ) 

105 self.connection.commit() 

106 

107 

108def request_with_retry(method: str, url: str, **kwargs) -> requests.Response: 

109 response = requests.request(method, url, **kwargs) 

110 for attempt in range(1, MAX_RETRIES): 

111 if response.status_code not in RETRYABLE_STATUS_CODES: 

112 return response 

113 wait = BASE_BACKOFF * (2**attempt) 

114 console.print( 

115 f" [yellow]HTTP {response.status_code}, retrying in {wait}s...[/yellow]" 

116 ) 

117 time.sleep(wait) 

118 response = requests.request(method, url, **kwargs) 

119 return response 

120 

121 

122def fetch_record( 

123 zenodo_url: str, record_id: str, access_token: str, user_agent: str 

124) -> tuple[dict, bool]: 

125 headers = get_headers(access_token, user_agent) 

126 headers["Accept"] = "application/vnd.inveniordm.v1+json" 

127 response = request_with_retry( 

128 "GET", 

129 f"{zenodo_url}/records/{record_id}/draft", 

130 headers=headers, 

131 timeout=REQUEST_TIMEOUT, 

132 ) 

133 has_edit_draft = response.status_code != 404 

134 if not has_edit_draft: 

135 return ( 

136 fetch_published_record( 

137 zenodo_url, 

138 record_id, 

139 access_token, 

140 user_agent, 

141 ), 

142 False, 

143 ) 

144 response.raise_for_status() 

145 return response.json(), True 

146 

147 

148def fetch_published_record( 

149 zenodo_url: str, record_id: str, access_token: str, user_agent: str 

150) -> dict: 

151 return _fetch_published_record( 

152 f"{zenodo_url}/records/{record_id}", 

153 access_token, 

154 user_agent, 

155 ) 

156 

157 

158def fetch_latest_published_record( 

159 zenodo_url: str, record_id: str, access_token: str, user_agent: str 

160) -> dict: 

161 return _fetch_published_record( 

162 f"{zenodo_url}/records/{record_id}/versions/latest", 

163 access_token, 

164 user_agent, 

165 ) 

166 

167 

168def _fetch_published_record( 

169 record_url: str, access_token: str, user_agent: str 

170) -> dict: 

171 headers = get_headers(access_token, user_agent) 

172 headers["Accept"] = "application/vnd.inveniordm.v1+json" 

173 response = request_with_retry( 

174 "GET", 

175 record_url, 

176 headers=headers, 

177 timeout=REQUEST_TIMEOUT, 

178 ) 

179 response.raise_for_status() 

180 return response.json() 

181 

182 

183def create_edit_draft( 

184 zenodo_url: str, record_id: str, access_token: str, user_agent: str 

185) -> dict: 

186 response = request_with_retry( 

187 "POST", 

188 f"{zenodo_url}/records/{record_id}/draft", 

189 headers=get_headers(access_token, user_agent), 

190 timeout=REQUEST_TIMEOUT, 

191 ) 

192 response.raise_for_status() 

193 return response.json() 

194 

195 

196def update_draft( 

197 zenodo_url: str, 

198 record_id: str, 

199 access_token: str, 

200 user_agent: str, 

201 payload: ZenodoUpdatePayload, 

202) -> None: 

203 response = request_with_retry( 

204 "PUT", 

205 f"{zenodo_url}/records/{record_id}/draft", 

206 headers=get_headers(access_token, user_agent, "application/json"), 

207 json=payload, 

208 timeout=REQUEST_TIMEOUT, 

209 ) 

210 response.raise_for_status() 

211 

212 

213def publish_draft( 

214 zenodo_url: str, record_id: str, access_token: str, user_agent: str 

215) -> None: 

216 response = request_with_retry( 

217 "POST", 

218 f"{zenodo_url}/records/{record_id}/draft/actions/publish", 

219 headers=get_headers(access_token, user_agent), 

220 timeout=REQUEST_TIMEOUT, 

221 ) 

222 response.raise_for_status()