Coverage for tests/test_nibrs_reader.py: 100%

160 statements  

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

1"""Tests of reading and parsing NIBRS master files. 

2 

3Contents: 

4 test_field_rejects_bad_kinds: checks the kinds and widths of fields. 

5 test_layout_widths_match_the_documentation: checks layouts' widths. 

6 test_layouts_have_no_overlapping_fields: checks layouts' positions. 

7 test_layouts_have_unique_names_and_known_codebooks: checks layouts' names. 

8 test_open_master_reads_plain_text: checks master files outside archives. 

9 test_parse_converts_each_kind: checks parsing each kind of field. 

10 test_parse_of_no_records_has_the_layouts_columns: checks empty tables. 

11 test_read_decodes_fields: checks that reading decodes codes. 

12 test_read_joins_split_batch_headers: checks pre-2013 batch headers. 

13 test_read_keeps_states: checks keeping only some states. 

14 test_read_makes_every_table: checks that reading makes every table. 

15 test_read_of_a_recent_year: checks a recent master file. 

16 test_read_rejects_unknown_tables: checks reading unknown tables. 

17 test_read_stops_at_the_limit: checks the limit of records. 

18 test_records_reads_both_compressions: checks Deflate and Deflate64. 

19 test_tables_parses_in_chunks: checks parsing in chunks. 

20 test_year_of_finds_years_in_names: checks finding years in names. 

21 

22""" 

23 

24from __future__ import annotations 

25 

26import datetime 

27import pathlib 

28 

29from typing import Any 

30 

31import amos 

32import pandas as pd 

33import pytest 

34 

35from crimepy.nibrs import codes, layouts, reader 

36 

37""" Private Functions """ 

38 

39 

40def _read(*args: Any, **kwargs: Any) -> dict[str, pd.DataFrame]: 

41 """Reads tables with `reader.read`, checking that they are datasets. 

42 

43 Args: 

44 *args: arguments for `reader.read`. 

45 **kwargs: arguments for `reader.read`. 

46 

47 Returns: 

48 The data frame of each dataset, by name. 

49 

50 """ 

51 datasets = reader.read(*args, **kwargs) 

52 assert all(isinstance(d, amos.Dataset) for d in datasets.values()) 

53 return {name: dataset.data for name, dataset in datasets.items()} 

54 

55 

56""" Tests """ 

57 

58 

59def test_layouts_have_no_overlapping_fields() -> None: 

60 """Checks that no two fields of a layout share a position.""" 

61 for layout in layouts.LAYOUTS.values(): 

62 fields = sorted(layout, key = lambda f: f.start) 

63 for first, second in zip(fields, fields[1:], strict = False): 

64 assert first.end <= second.start, (layout.level, first, second) 

65 

66 

67def test_layouts_have_unique_names_and_known_codebooks() -> None: 

68 """Checks that each layout's names are unique and its codebooks exist.""" 

69 for layout in layouts.LAYOUTS.values(): 

70 assert len(set(layout.names)) == len(layout.names) 

71 for field in layout: 

72 if field.codes is not None: 

73 assert field.codes in codes.CODEBOOKS 

74 

75 

76def test_layout_widths_match_the_documentation() -> None: 

77 """Checks the widths of layouts against the record descriptions.""" 

78 assert layouts.LAYOUTS['BH'].width == 292 

79 assert layouts.LAYOUTS['02'].width == 67 

80 assert layouts.LAYOUTS['03'].width == 102 

81 assert layouts.LAYOUTS['04'].width == 135 

82 assert layouts.LAYOUTS['05'].width == 40 

83 assert layouts.LAYOUTS['06'].width == 73 

84 assert layouts.LAYOUTS['07'].width == 56 

85 assert layouts.LAYOUTS['W6'].width == 104 

86 

87 

88def test_field_rejects_bad_kinds() -> None: 

89 """Checks that a field must have a known kind and a width.""" 

90 with pytest.raises(ValueError, match = 'kind'): 

91 layouts.Field('x', 0, 1, 'number') 

92 with pytest.raises(ValueError, match = 'width'): 

93 layouts.Field('x', 3, 3) 

94 

95 

96def test_year_of_finds_years_in_names(tmp_path: pathlib.Path) -> None: 

97 """Checks that years are found in new and old names of master files. 

98 

99 Args: 

100 tmp_path: the test's temporary folder. 

101 

102 """ 

103 assert reader.year_of('nibrs-2023.zip') == 2023 

104 assert reader.year_of(tmp_path / 'NIBRS04.ENC') == 2004 

105 assert reader.year_of(tmp_path / 'KCNIBR91.DAT') == 1991 

106 assert reader.year_of(tmp_path / 'master.txt') is None 

107 

108 

109def test_records_reads_both_compressions( 

110 master_2004: pathlib.Path, 

111 master_2025: pathlib.Path) -> None: 

112 """Checks that records are read from Deflate and Deflate64 archives. 

113 

114 Args: 

115 master_2004: the path of the 2004 master file. 

116 master_2025: the path of the 2025 master file, compressed with 

117 Deflate64. 

118 

119 """ 

120 old = list(reader.records(master_2004)) 

121 new = list(reader.records(master_2025)) 

122 assert len(old) == 20 

123 assert all(len(r) == 132 for r in old) 

124 assert new[0].startswith(b'BH50AK0010200') 

125 assert new[-1].endswith(b'30MINN') 

126 

127 

128def test_open_master_reads_plain_text(tmp_path: pathlib.Path) -> None: 

129 """Checks that a master file that is not in an archive is read. 

130 

131 Args: 

132 tmp_path: the test's temporary folder. 

133 

134 """ 

135 path = tmp_path / 'nibrs-2025.txt' 

136 path.write_bytes(b'0550AK0010200C30BRCZ9728N202503010125FIN\n') 

137 assert list(reader.records(path)) == [ 

138 b'0550AK0010200C30BRCZ9728N202503010125FIN'] 

139 with pytest.raises(FileNotFoundError): 

140 list(reader.records(tmp_path / 'missing.zip')) 

141 

142 

143def test_parse_converts_each_kind() -> None: 

144 """Checks that parsing converts each kind of field.""" 

145 table = reader.parse( 

146 [b'0350AK0010200C30BREBQ728N20250601738000000050', 

147 b'0311IDIBN01006B-4RHP 728N19950118610000000000 ' 

148 b'L000000001250GM'], 

149 '03', data_year = 2025) 

150 rows = table.to_pylist() 

151 assert rows[0]['data_year'] == 2025 

152 assert rows[0]['incident_date'] == datetime.date(2025, 6, 1) 

153 assert rows[0]['loss_type'] == '7' 

154 assert rows[0]['property_description'] == '38' 

155 assert rows[0]['property_value'] == 50 

156 assert rows[0]['drug_quantity_1'] is None 

157 assert rows[1]['drug_type_1'] == 'L' 

158 assert rows[1]['drug_quantity_1'] == pytest.approx(1.25) 

159 assert rows[1]['drug_measure_1'] == 'GM' 

160 

161 

162def test_parse_of_no_records_has_the_layouts_columns() -> None: 

163 """Checks that a table of no records has the layout's columns.""" 

164 table = reader.parse([], '05', data_year = 2025) 

165 assert table.num_rows == 0 

166 assert table.column_names == ['data_year', *layouts.LAYOUTS['05'].names] 

167 

168 

169def test_read_makes_every_table(master_2004: pathlib.Path) -> None: 

170 """Checks that reading a master file makes every table. 

171 

172 Args: 

173 master_2004: the path of the 2004 master file. 

174 

175 """ 

176 frames = _read(master_2004) 

177 assert set(frames) == set(layouts.SEGMENTS) 

178 assert len(frames['administrative']) == 2 

179 assert len(frames['offenses']) == 2 

180 assert len(frames['group_b_arrests']) == 1 

181 assert len(frames['window_clearances']) == 1 

182 assert len(frames['window_properties']) == 1 

183 assert len(frames['window_arrestees']) == 1 

184 for name, frame in frames.items(): 

185 assert set(reader.schema(name).names) <= set(frame.columns) 

186 

187 

188def test_read_decodes_fields(master_2004: pathlib.Path) -> None: 

189 """Checks that reading replaces codes with their labels. 

190 

191 Args: 

192 master_2004: the path of the 2004 master file. 

193 

194 """ 

195 frames = _read(master_2004) 

196 offenses = frames['offenses'].set_index('incident_number') 

197 burglary = offenses.loc['E0DROSC-2 N'] 

198 assert burglary['offense_code'] == 'Burglary/Breaking & Entering' 

199 assert burglary['location'] == 'Specialty Store' 

200 assert burglary['entry_method'] == 'Force' 

201 assert burglary['crime_against'] == 'Property' 

202 assert burglary['bias_motivation_1'] == 'None (No Bias)' 

203 assault = offenses.loc['E0DROSCQ2 N'] 

204 assert assault['weapon_1'] == 'Personal Weapons' 

205 victims = frames['victims'].set_index('incident_number') 

206 person = victims.loc['E0DROSCQ2 N'] 

207 assert person['victim_age'] == 29 

208 assert person['victim_sex'] == 'Male' 

209 assert person['injury_1'] == 'Apparent Minor Injury' 

210 assert person['offender_1'] == 1 

211 assert person['relationship_1'] == 'Spouse' 

212 administrative = frames['administrative'].set_index('incident_number') 

213 assert bool(administrative.loc['E0DROSC-2 N', 'report_date']) 

214 assert pd.isna(administrative.loc['E0DROSC-2 N', 'incident_hour']) 

215 assert administrative.loc['E0DROSCQ2 N', 'incident_hour'] == 11 

216 assert (frames['administrative']['agency_ori'] == 'AZ0111300').all() 

217 window = frames['window_properties'].iloc[0] 

218 assert window['offense_1'] == 'Burglary/Breaking & Entering' 

219 assert window['property_description'] == 'Firearms' 

220 

221 

222def test_read_joins_split_batch_headers(master_2004: pathlib.Path) -> None: 

223 """Checks that the three batch header records of 2004 become one row. 

224 

225 Args: 

226 master_2004: the path of the 2004 master file. 

227 

228 """ 

229 agencies = _read(master_2004, 'agencies')['agencies'] 

230 assert list(agencies['ori']) == ['ALAST0000', 'AZ0111300'] 

231 tempe = agencies.set_index('ori').loc['AZ0111300'] 

232 assert tempe['city'] == 'TEMPE' 

233 assert tempe['population'] == 158880 

234 assert tempe['population_group'] == 'Cities 100,000 to 249,999' 

235 assert tempe['agency_type'] == 'City' 

236 assert bool(tempe['core_city']) 

237 assert tempe['months_reported'] == 12 

238 assert tempe['months_active'] == 12 

239 assert tempe['master_file_year'] == 2004 

240 assert tempe['date_ori_went_nibrs'] == pd.Timestamp('1996-01-01') 

241 patrol = agencies.set_index('ori').loc['ALAST0000'] 

242 assert patrol['agency_type'] == 'State Police' 

243 assert patrol['months_active'] == 0 

244 

245 

246def test_read_of_a_recent_year(master_2025: pathlib.Path) -> None: 

247 """Checks the fields of a recent master file, without decoding. 

248 

249 Args: 

250 master_2025: the path of the 2025 master file, compressed with 

251 Deflate64. 

252 

253 """ 

254 frames = _read(master_2025, decode = False) 

255 offender = frames['offenders'].iloc[0] 

256 assert offender['offender_age'] == 25 

257 assert offender['offender_sex'] == 'F' 

258 assert offender['offender_race'] == 'I' 

259 assert offender['offender_ethnicity'] == 'N' 

260 assert not frames['administrative'].iloc[0]['cargo_theft'] 

261 agency = frames['agencies'].iloc[0] 

262 assert agency['population'] == 31961 

263 assert agency['state'] == 'AK' 

264 arrest = frames['group_b_arrests'].iloc[0] 

265 assert arrest['arrest_offense'] == '90C' 

266 assert arrest['arrest_type'] == 'O' 

267 

268 

269def test_read_keeps_states(master_2004: pathlib.Path) -> None: 

270 """Checks that reading keeps only the states asked for. 

271 

272 Args: 

273 master_2004: the path of the 2004 master file. 

274 

275 """ 

276 frames = _read(master_2004, ['agencies', 'victims'], states = 'AL') 

277 assert list(frames['agencies']['ori']) == ['ALAST0000'] 

278 assert frames['victims'].empty 

279 frames = _read(master_2004, 'victims', states = ['az']) 

280 assert len(frames['victims']) == 2 

281 with pytest.raises(ValueError, match = 'state'): 

282 reader.read(master_2004, states = 'Arizona') 

283 

284 

285def test_tables_parses_in_chunks(master_2004: pathlib.Path) -> None: 

286 """Checks that tables are parsed in chunks and report progress. 

287 

288 Args: 

289 master_2004: the path of the 2004 master file. 

290 

291 """ 

292 seen = [] 

293 chunks = list(reader.tables( 

294 master_2004, 'offenses', chunk_size = 1, progress = seen.append)) 

295 assert sum(c['offenses'].num_rows for c in chunks if 'offenses' in c) == 2 

296 assert seen[-1] == 20 

297 

298 

299def test_read_stops_at_the_limit(master_2004: pathlib.Path) -> None: 

300 """Checks that reading stops after the limit of records. 

301 

302 Args: 

303 master_2004: the path of the 2004 master file. 

304 

305 """ 

306 frames = _read(master_2004, limit = 8) 

307 assert len(frames['administrative']) == 1 

308 assert frames['victims'].empty 

309 

310 

311def test_read_rejects_unknown_tables(master_2004: pathlib.Path) -> None: 

312 """Checks that reading a table that does not exist fails. 

313 

314 Args: 

315 master_2004: the path of the 2004 master file. 

316 

317 """ 

318 with pytest.raises(KeyError): 

319 reader.read(master_2004, 'suspects')