Coverage for src/crimepy/cli.py: 84%
86 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 18:01 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 18:01 +0000
1"""The command line interface of crimepy.
3```sh
4crimepy nibrs years
5crimepy nibrs download 2021-2023
6crimepy nibrs convert 2021-2023
7crimepy nibrs export victims 2022 --states DE --output victims.csv
8crimepy project study.ini --export
9```
11`export` writes a table of the master files (such as "agencies") or a built
12table (such as "incidents"; see `crimepy.nibrs.tables`) to a csv or parquet
13file, downloading and converting the years first if needed. `project` runs an
14`amos` project from a settings file.
16Contents:
17 main: runs the command line interface.
19"""
21from __future__ import annotations
23import argparse
24import pathlib
25import sys
26from collections.abc import Sequence
28from . import nibrs
30""" Public Functions """
33def main(arguments: Sequence[str] | None = None) -> int:
34 """Runs the command line interface.
36 Args:
37 arguments: the arguments, without the program's name. Defaults to
38 `None`, in which case they are those that the program was called
39 with.
41 Returns:
42 The exit status: 0 if the command succeeded.
44 """
45 parser = _parser()
46 parsed = parser.parse_args(arguments)
47 if not hasattr(parsed, 'run'):
48 parser.print_help()
49 return 1
50 return int(parsed.run(parsed) or 0)
53""" Private Functions """
56def _parser() -> argparse.ArgumentParser:
57 """Returns the parser of the command line arguments.
59 Returns:
60 The parser, with a subcommand for each command, each of which sets
61 "run" to the function that runs it.
63 """
64 parser = argparse.ArgumentParser(
65 prog = 'crimepy',
66 description = (
67 'Download, combine, munge, analyze, and plot crime data.'))
68 commands = parser.add_subparsers(title = 'commands')
69 data = commands.add_parser('nibrs', help = 'work with NIBRS master files')
70 tasks = data.add_subparsers(title = 'NIBRS commands')
72 years = tasks.add_parser('years', help = 'list the years the FBI has')
73 years.set_defaults(run = _years)
75 download = tasks.add_parser('download', help = 'download master files')
76 _add_years(download)
77 _add_folder(download)
78 download.add_argument(
79 '--overwrite', action = 'store_true',
80 help = 'download files that were downloaded before')
81 download.set_defaults(run = _download)
83 convert = tasks.add_parser(
84 'convert', help = 'convert master files to parquet files')
85 _add_years(convert)
86 _add_folder(convert)
87 convert.add_argument(
88 '--tables', nargs = '+', default = None,
89 help = 'the tables to convert (all of them by default)')
90 convert.add_argument(
91 '--overwrite', action = 'store_true',
92 help = 'convert tables that were converted before')
93 convert.set_defaults(run = _convert)
95 export = tasks.add_parser(
96 'export', help = 'write a table to a csv or parquet file')
97 export.add_argument(
98 'table',
99 choices = sorted({*nibrs.layouts.SEGMENTS, *nibrs.tables.BUILDERS}),
100 help = 'the table to write')
101 _add_years(export)
102 _add_folder(export)
103 export.add_argument(
104 '--states', nargs = '+', default = None,
105 help = 'postal abbreviations of the states to keep')
106 export.add_argument(
107 '--codes', action = 'store_true',
108 help = 'keep codes instead of replacing them with labels')
109 export.add_argument(
110 '--output', required = True, type = pathlib.Path,
111 help = 'the file to write (.csv or .parquet)')
112 export.set_defaults(run = _export)
114 project = commands.add_parser(
115 'project', help = 'run an amos project from a settings file')
116 project.add_argument('settings', type = pathlib.Path)
117 project.add_argument(
118 '--export', action = 'store_true',
119 help = 'export the results to a folder named for the run')
120 project.set_defaults(run = _project)
121 return parser
124def _add_folder(parser: argparse.ArgumentParser) -> None:
125 """Adds the "--folder" argument to `parser`.
127 Args:
128 parser: the parser of a command that uses the data folder.
130 """
131 parser.add_argument(
132 '--folder', type = pathlib.Path, default = None,
133 help = 'the data folder (by default, the "crime" folder of the data '
134 'folder of amos\'s workspace, beside the "code" folder)')
137def _add_years(parser: argparse.ArgumentParser) -> None:
138 """Adds the "years" argument to `parser`.
140 Args:
141 parser: the parser of a command that takes years.
143 """
144 parser.add_argument(
145 'years', nargs = '+',
146 help = 'years or ranges of years, such as 2022 or 2015-2023')
149def _convert(arguments: argparse.Namespace) -> int:
150 """Converts master files to parquet files.
152 Args:
153 arguments: the parsed arguments of the "convert" command.
155 Returns:
156 The exit status, 0.
158 """
159 converted = nibrs.convert(
160 arguments.years, arguments.folder, tables = arguments.tables,
161 overwrite = arguments.overwrite)
162 for year, paths in converted.items():
163 folder = next(iter(paths.values())).parent
164 print(f'{year}: {len(paths)} tables in {folder}')
165 return 0
168def _download(arguments: argparse.Namespace) -> int:
169 """Downloads master files.
171 Args:
172 arguments: the parsed arguments of the "download" command.
174 Returns:
175 The exit status, 0.
177 """
178 for path in nibrs.download(
179 arguments.years, arguments.folder, overwrite = arguments.overwrite):
180 print(path)
181 return 0
184def _export(arguments: argparse.Namespace) -> int:
185 """Writes a table to a csv or parquet file.
187 Args:
188 arguments: the parsed arguments of the "export" command.
190 Returns:
191 The exit status: 0 if the table was written, or 2 if the output is
192 not a csv or parquet file.
194 """
195 suffix = arguments.output.suffix.lower()
196 if suffix not in ('.csv', '.parquet'):
197 print('the output must be a .csv or .parquet file', file = sys.stderr)
198 return 2
199 decode = not arguments.codes
200 if arguments.table in nibrs.tables.BUILDERS:
201 data = nibrs.tables.build(
202 arguments.table, arguments.years, states = arguments.states,
203 folder = arguments.folder, decode = decode).data
204 else:
205 data = nibrs.load_frame(
206 arguments.table, arguments.years, states = arguments.states,
207 folder = arguments.folder, decode = decode)
208 arguments.output.parent.mkdir(parents = True, exist_ok = True)
209 if suffix == '.csv':
210 data.to_csv(arguments.output, index = False)
211 else:
212 data.to_parquet(arguments.output, index = False)
213 print(f'wrote {len(data):,} rows to {arguments.output}')
214 return 0
217def _project(arguments: argparse.Namespace) -> int:
218 """Runs an amos project from a settings file.
220 Args:
221 arguments: the parsed arguments of the "project" command.
223 Returns:
224 The exit status, 0.
226 """
227 import amos # noqa: PLC0415
229 project = amos.Project.create(arguments.settings)
230 if arguments.export:
231 print(project.export())
232 print(project.result)
233 return 0
236def _years(arguments: argparse.Namespace) -> int: # noqa: ARG001
237 """Lists the years of NIBRS master files that the FBI has.
239 Args:
240 arguments: the parsed arguments of the "years" command, which are
241 not used.
243 Returns:
244 The exit status, 0.
246 """
247 found = nibrs.available_years()
248 print(f'{found[0]}-{found[-1]}')
249 return 0