Coverage for src/crimepy/nibrs/cde.py: 89%

116 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 18:01 +0000

1"""Downloads NIBRS master files from the FBI's Crime Data Explorer. 

2 

3The Crime Data Explorer (CDE) has a master file of NIBRS data for each year 

4from 1991. Each is a zip archive (from about 25 MB for 1991 to about 600 MB 

5for recent years) of one fixed-width text file. The CDE does not link to the 

6files directly: it asks its own web service for a link that works for 15 

7minutes, and so does `crimepy`. 

8 

9Downloads are saved as "nibrs-{year}.zip" in the "nibrs/raw" folder of the 

10data folder (`crimepy.utilities.data_folder()`, unless another is given). A 

11file that is already there is not downloaded again unless `overwrite` is 

12true. 

13 

14Contents: 

15 available_years: returns the years of master files that the CDE has. 

16 download: downloads the master files of years. 

17 download_documentation: downloads the FBI's documentation of the files. 

18 master_path: returns where the master file of a year is saved. 

19 

20""" 

21 

22from __future__ import annotations 

23 

24import functools 

25import logging 

26import pathlib 

27import sys 

28from collections.abc import Sequence 

29from typing import Any 

30 

31import requests 

32 

33from .. import options, utilities 

34 

35logger = logging.getLogger(__name__) 

36 

37# The CDE's list of master files, with the first and last year of each. 

38_MASTERS_URL: str = ( 

39 'https://cde.ucr.cjis.gov/LATEST/webapp/assets/JSON/downloads/masters.json') 

40# The CDE's service that returns a temporary link to a file in its bucket. 

41_SIGNED_URL: str = 'https://cde.ucr.cjis.gov/LATEST/s3/signedurl' 

42# The key of a year's master file in the CDE's bucket. 

43_KEY: str = 'master_files/nibrs/nibrs-{year}.zip' 

44# The key of the CDE's documentation of the master files. 

45_HELP_KEY: str = 'master_files/nibrs/nibrs-help.zip' 

46# The first year of NIBRS master files, used if the CDE's list cannot be read. 

47_FIRST_YEAR: int = 1991 

48# Bytes read from the server at a time. 

49_BLOCK: int = 1 << 20 

50# Tries in a row that download nothing before a download gives up. 

51_ATTEMPTS: int = 5 

52# Errors of a connection that broke, after which a download resumes. 

53_BROKEN: tuple[type[Exception], ...] = ( 

54 requests.ConnectionError, requests.exceptions.ChunkedEncodingError, 

55 requests.Timeout) 

56 

57 

58""" Public Functions """ 

59 

60 

61def available_years(*, timeout: float | None = None) -> list[int]: 

62 """Returns the years of NIBRS master files that the CDE has. 

63 

64 Args: 

65 timeout: seconds to wait for the CDE. Defaults to `None`, in which 

66 case it is `crimepy.options.TIMEOUT`. 

67 

68 Raises: 

69 requests.HTTPError: if the CDE refuses the request. 

70 

71 Returns: 

72 Every year from the first to the last master file. 

73 

74 """ 

75 first, last = _year_range(timeout or options.TIMEOUT) 

76 return list(range(first, last + 1)) 

77 

78 

79def master_path( 

80 year: int, 

81 folder: pathlib.Path | str | None = None) -> pathlib.Path: 

82 """Returns where the NIBRS master file of `year` is saved. 

83 

84 Args: 

85 year: the year of the master file. 

86 folder: the data folder. Defaults to `None`, in which case it is 

87 `crimepy.utilities.data_folder()`. 

88 

89 Returns: 

90 The path of the zip archive, which may not exist yet. 

91 

92 """ 

93 raw = utilities.data_folder(folder) / 'nibrs' / 'raw' 

94 return raw / f'nibrs-{int(year)}.zip' 

95 

96 

97def download( 

98 years: Any, 

99 folder: pathlib.Path | str | None = None, 

100 *, 

101 overwrite: bool = False, 

102 progress: bool = True, 

103 timeout: float | None = None) -> list[pathlib.Path]: 

104 """Downloads the NIBRS master files of `years` from the CDE. 

105 

106 Args: 

107 years: a year, years, or a range of years (see 

108 `crimepy.utilities.to_years`), such as 2022, [2019, 2020], or 

109 "2015-2023". 

110 folder: the data folder. Defaults to `None`, in which case it is 

111 `crimepy.utilities.data_folder()`. 

112 overwrite: whether to download a file that was downloaded before. 

113 Defaults to `False`. 

114 progress: whether to show how much of each file has been downloaded. 

115 Defaults to `True`. 

116 timeout: seconds to wait for the CDE. Defaults to `None`, in which 

117 case it is `crimepy.options.TIMEOUT`. 

118 

119 Raises: 

120 ValueError: if a year has no master file. 

121 requests.HTTPError: if the CDE refuses a request. 

122 

123 Returns: 

124 The paths of the zip archives, in the order of the years. 

125 

126 """ 

127 wanted = utilities.to_years(years) 

128 if not wanted: 

129 message = 'there are no years to download' 

130 raise ValueError(message) 

131 timeout = timeout or options.TIMEOUT 

132 first, last = _year_range(timeout) 

133 missing = [y for y in wanted if not first <= y <= last] 

134 if missing: 

135 message = ( 

136 f'the CDE has NIBRS master files for {first} to {last}, so there ' 

137 f'are none for {missing}') 

138 raise ValueError(message) 

139 paths = [] 

140 for year in wanted: 

141 path = master_path(year, folder) 

142 if path.is_file() and not overwrite: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true

143 logger.info(f'{path} was downloaded before') 

144 else: 

145 _fetch(_KEY.format(year = year), path, progress, timeout) 

146 paths.append(path) 

147 return paths 

148 

149 

150def download_documentation( 

151 folder: pathlib.Path | str | None = None, 

152 *, 

153 overwrite: bool = False, 

154 timeout: float | None = None) -> pathlib.Path: 

155 """Downloads the FBI's documentation of the NIBRS master files. 

156 

157 The CDE's documentation is a zip archive with one PDF, a scan of the 

158 "NIBRS Flatfile Yearly Master Record Descriptions" (12/18/95), which 

159 `crimepy.nibrs.layouts` follows. 

160 

161 Args: 

162 folder: the data folder. Defaults to `None`, in which case it is 

163 `crimepy.utilities.data_folder()`. 

164 overwrite: whether to download it again if it was downloaded before. 

165 Defaults to `False`. 

166 timeout: seconds to wait for the CDE. Defaults to `None`, in which 

167 case it is `crimepy.options.TIMEOUT`. 

168 

169 Returns: 

170 The path of the zip archive. 

171 

172 """ 

173 path = master_path(0, folder).with_name('nibrs-help.zip') 

174 if overwrite or not path.is_file(): 174 ↛ 176line 174 didn't jump to line 176 because the condition on line 174 was always true

175 _fetch(_HELP_KEY, path, False, timeout or options.TIMEOUT) 

176 return path 

177 

178 

179""" Private Functions """ 

180 

181 

182def _fetch( 

183 key: str, 

184 path: pathlib.Path, 

185 progress: bool, 

186 timeout: float) -> None: 

187 """Downloads the file at `key` in the CDE's bucket to `path`. 

188 

189 The file is downloaded to a temporary file that replaces `path` only when 

190 the download is complete, so an interrupted download leaves nothing that 

191 looks finished. The CDE's links work for about 15 minutes, which is less 

192 than a slow connection needs for a recent master file, so when the 

193 connection breaks, the download asks for a new link and resumes where it 

194 stopped. It gives up after `_ATTEMPTS` tries in a row that add nothing. 

195 

196 Args: 

197 key: the key of the file in the CDE's bucket. 

198 path: where to save the file. 

199 progress: whether to show how much has been downloaded. 

200 timeout: seconds to wait for a server. 

201 

202 Raises: 

203 requests.HTTPError: if a server refuses a request. 

204 requests.ConnectionError: if the connection breaks `_ATTEMPTS` times 

205 in a row without downloading anything. 

206 

207 """ 

208 path.parent.mkdir(parents = True, exist_ok = True) 

209 partial = path.with_name(f'{path.name}.part') 

210 partial.unlink(missing_ok = True) 

211 done, size, failures = 0, 0, 0 

212 while True: 

213 before = done 

214 try: 

215 done, size = _resume( 

216 _signed_url(key, timeout), partial, done, size, 

217 path.name if progress else None, timeout) 

218 except _BROKEN as error: 

219 done = partial.stat().st_size if partial.is_file() else 0 

220 failures = 0 if done > before else failures + 1 

221 if failures >= _ATTEMPTS: 

222 raise 

223 logger.info(f'resuming {path.name} at {done:,} bytes after {error}') 

224 continue 

225 if not size or done >= size: 225 ↛ 212line 225 didn't jump to line 212 because the condition on line 225 was always true

226 break 

227 if progress: 

228 print(file = sys.stderr) 

229 partial.replace(path) 

230 logger.info(f'downloaded {path}') 

231 

232 

233def _resume( 

234 url: str, 

235 partial: pathlib.Path, 

236 done: int, 

237 size: int, 

238 name: str | None, 

239 timeout: float) -> tuple[int, int]: 

240 """Downloads the rest of a file, after the bytes already in `partial`. 

241 

242 Args: 

243 url: a link to the file. 

244 partial: the temporary file, with the first `done` bytes. 

245 done: the bytes already downloaded. 

246 size: the bytes in the whole file, or 0 if not known yet. 

247 name: the name of the file to show progress for, or `None` to not 

248 show progress. 

249 timeout: seconds to wait for a server. 

250 

251 Raises: 

252 requests.HTTPError: if the server refuses the request. 

253 requests.ConnectionError: if the connection breaks before the file 

254 is complete. 

255 

256 Returns: 

257 The bytes downloaded and the bytes in the whole file. 

258 

259 """ 

260 headers = {'User-Agent': options.USER_AGENT} 

261 if done: 

262 headers['Range'] = f'bytes={done}-' 

263 with requests.get( 

264 url, stream = True, timeout = timeout, headers = headers) as response: 

265 response.raise_for_status() 

266 # A server that ignores the range sends the whole file again. 

267 if done and response.status_code != 206: 

268 done = 0 

269 length = int(response.headers.get('Content-Length', 0)) 

270 size = size or (done + length if length else 0) 

271 with open(partial, 'ab' if done else 'wb') as stream: 

272 for block in response.iter_content(chunk_size = _BLOCK): 

273 stream.write(block) 

274 done += len(block) 

275 if name is not None: 

276 _show(name, done, size) 

277 if size and done < size: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true

278 message = f'the connection closed after {done:,} of {size:,} bytes' 

279 raise requests.ConnectionError(message) 

280 return done, size 

281 

282 

283def _show(name: str, done: int, size: int) -> None: 

284 """Shows how much of a file has been downloaded. 

285 

286 Args: 

287 name: name of the file. 

288 done: bytes downloaded so far. 

289 size: bytes in the file, or 0 if not known. 

290 

291 """ 

292 megabytes = done / 1e6 

293 if size: 293 ↛ 296line 293 didn't jump to line 296 because the condition on line 293 was always true

294 text = f'\r{name}: {megabytes:,.0f} of {size / 1e6:,.0f} MB' 

295 else: 

296 text = f'\r{name}: {megabytes:,.0f} MB' 

297 print(text, end = '', file = sys.stderr, flush = True) 

298 

299 

300def _signed_url(key: str, timeout: float) -> str: 

301 """Returns a temporary link to the file at `key` in the CDE's bucket. 

302 

303 Args: 

304 key: the key of the file in the CDE's bucket. 

305 timeout: seconds to wait for the CDE. 

306 

307 Raises: 

308 requests.HTTPError: if the CDE refuses the request. 

309 ValueError: if the CDE does not return a link. 

310 

311 Returns: 

312 A link that works for about 15 minutes. 

313 

314 """ 

315 response = requests.get( 

316 _SIGNED_URL, params = {'key': key}, timeout = timeout, 

317 headers = {'User-Agent': options.USER_AGENT}) 

318 response.raise_for_status() 

319 links = response.json() 

320 if not isinstance(links, dict) or not links.get(key): 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 message = f'the CDE did not return a link to {key!r}: {links!r}' 

322 raise ValueError(message) 

323 return str(links[key]) 

324 

325 

326@functools.cache 

327def _year_range(timeout: float) -> tuple[int, int]: 

328 """Returns the first and last years of the CDE's NIBRS master files. 

329 

330 Args: 

331 timeout: seconds to wait for the CDE. 

332 

333 Raises: 

334 requests.HTTPError: if the CDE refuses the request. 

335 ValueError: if the CDE does not list a NIBRS master file. 

336 

337 Returns: 

338 The first and last years. 

339 

340 """ 

341 response = requests.get( 

342 _MASTERS_URL, timeout = timeout, 

343 headers = {'User-Agent': options.USER_AGENT}) 

344 response.raise_for_status() 

345 masters: Sequence[dict[str, Any]] = response.json() 

346 for master in masters: 346 ↛ 351line 346 didn't jump to line 351 because the loop on line 346 didn't complete

347 if master.get('id') == 'nibrs': 347 ↛ 346line 347 didn't jump to line 346 because the condition on line 347 was always true

348 return ( 

349 int(master.get('minYear', _FIRST_YEAR)), 

350 int(master['maxYear'])) 

351 message = 'the CDE does not list a NIBRS master file' 

352 raise ValueError(message)