cpi 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cpi/__init__.py ADDED
@@ -0,0 +1,173 @@
1
+ #! /usr/bin/env python
2
+ """
3
+ Quickly adjust U.S. dollars for inflation using the Consumer Price Index (CPI)
4
+ """
5
+
6
+ import logging
7
+ import numbers
8
+ import warnings
9
+ from datetime import date, datetime
10
+ from pathlib import Path
11
+
12
+ from . import models
13
+ from .defaults import DEFAULT_SERIES_ID, DEFAULTS_SERIES_ATTRS
14
+ from .download import Downloader
15
+ from .errors import StaleDataWarning
16
+
17
+ logger = logging.getLogger(__name__)
18
+ logger.addHandler(logging.NullHandler())
19
+
20
+ # Check if the cpi.db database exists, if not, download it.
21
+ this_dir = Path(__file__).parent.absolute()
22
+ db_path = this_dir / "cpi.db"
23
+ if not db_path.exists():
24
+ warnings.warn(
25
+ "CPI database not found. Downloading... This may take a few minutes.",
26
+ stacklevel=2,
27
+ )
28
+ logger.info("CPI database not found. Downloading...")
29
+ Downloader().update()
30
+
31
+ # Create a list of all available series, will be lazy loaded as we go
32
+ series = models.SeriesList()
33
+
34
+ # Set the default series to the CPI-U
35
+ DEFAULT_SERIES = models.Series.get_by_id(DEFAULT_SERIES_ID)
36
+ series.append(DEFAULT_SERIES)
37
+
38
+ # Establish the range of data available
39
+ LATEST_MONTH = DEFAULT_SERIES.latest_month
40
+ LATEST_YEAR = DEFAULT_SERIES.latest_year
41
+
42
+ # Figure out how out of date you are
43
+ DAYS_SINCE_LATEST_MONTH = (date.today() - LATEST_MONTH).days
44
+ DAYS_SINCE_LATEST_YEAR = (date.today() - date(LATEST_YEAR, 1, 1)).days
45
+
46
+ # If it's more than two and a half years out of date, raise a warning.
47
+ if DAYS_SINCE_LATEST_YEAR > (365 * 2.25) or DAYS_SINCE_LATEST_MONTH > 90:
48
+ warnings.warn(StaleDataWarning(), stacklevel=2)
49
+ logger.warning(
50
+ "CPI data is out of date. To accurately inflate to today's dollars, you must run `cpi.update()`."
51
+ )
52
+
53
+ # Create aliases for accessing the other data tables
54
+ areas = models.Area
55
+ periods = models.Period
56
+ periodicities = models.Periodicity
57
+ items = models.Item
58
+
59
+
60
+ def get(
61
+ year_or_month,
62
+ survey=DEFAULTS_SERIES_ATTRS["survey"],
63
+ seasonally_adjusted=DEFAULTS_SERIES_ATTRS["seasonally_adjusted"],
64
+ periodicity=DEFAULTS_SERIES_ATTRS["periodicity"],
65
+ area=DEFAULTS_SERIES_ATTRS["area"],
66
+ items=DEFAULTS_SERIES_ATTRS["items"],
67
+ series_id=None,
68
+ ):
69
+ """Returns the CPI value for a given year."""
70
+ # Pull the series
71
+ if series_id:
72
+ # If the user has provided an explicit series id, we are going to ignore the humanized options.
73
+ series_obj = series.get_by_id(series_id)
74
+ else:
75
+ # Otherwise, we build the series id using the more humanized options
76
+ series_obj = series.get(survey, seasonally_adjusted, periodicity, area, items)
77
+
78
+ # Prep the lookup value depending on the input type.
79
+ if isinstance(year_or_month, numbers.Integral):
80
+ year_or_month = date(year_or_month, 1, 1)
81
+ period_type = "annual"
82
+ elif isinstance(year_or_month, date):
83
+ period_type = "monthly"
84
+ # If it's not set to the first day of the month, we should do that now.
85
+ if year_or_month.day != 1:
86
+ year_or_month = year_or_month.replace(day=1)
87
+ else:
88
+ raise ValueError("Only integers and date objects are accepted.")
89
+
90
+ # Pull the value from the series by date
91
+ return series_obj.get_index_by_date(year_or_month, period_type=period_type).value
92
+
93
+
94
+ def inflate(
95
+ value,
96
+ year_or_month,
97
+ to=None,
98
+ survey=DEFAULTS_SERIES_ATTRS["survey"],
99
+ seasonally_adjusted=DEFAULTS_SERIES_ATTRS["seasonally_adjusted"],
100
+ periodicity=DEFAULTS_SERIES_ATTRS["periodicity"],
101
+ area=DEFAULTS_SERIES_ATTRS["area"],
102
+ items=DEFAULTS_SERIES_ATTRS["items"],
103
+ series_id=None,
104
+ ):
105
+ """
106
+ Returns a dollar value adjusted for inflation.
107
+
108
+ You must submit the value, followed by the year or month its from.
109
+
110
+ Years should be submitted as integers. Months as datetime.date objects.
111
+
112
+ By default, the input is adjusted to the most recent year or month available from the CPI.
113
+
114
+ If you'd like to adjust to a different year or month, submit it to the optional `to` keyword argument.
115
+
116
+ Yearly data can only be updated to other years. Monthly data can only be updated to other months.
117
+ """
118
+ # If the two dates match, just return the value unadjusted
119
+ if year_or_month == to:
120
+ return value
121
+
122
+ # Figure out the 'to' date if it has not been provided
123
+ if not to:
124
+ if isinstance(year_or_month, (date, datetime)):
125
+ to = LATEST_MONTH
126
+ else:
127
+ to = LATEST_YEAR
128
+ # Otherwise sanitize it
129
+ else:
130
+ if isinstance(to, numbers.Integral):
131
+ to = int(to)
132
+ elif isinstance(to, datetime):
133
+ # We want dates not datetimes
134
+ to = to.date()
135
+
136
+ # Sanitize the year_or_month
137
+ if isinstance(year_or_month, numbers.Integral):
138
+ # We need to make sure that int64, int32 and other int-like objects
139
+ # are the same type for the comparison to come.
140
+ year_or_month = int(year_or_month)
141
+ # If a datetime has been provided, shave it down to a date.
142
+ elif isinstance(year_or_month, datetime):
143
+ year_or_month = year_or_month.date()
144
+
145
+ # Make sure the two dates are the same type
146
+ if type(year_or_month) is not type(to):
147
+ raise TypeError(
148
+ "Years can only be converted to other years. Months only to other months."
149
+ )
150
+
151
+ # Otherwise, let's do the math.
152
+ # The input value is multiplied by the CPI of the target year,
153
+ # then divided into the CPI from the source year.
154
+ kwargs = {
155
+ "survey": survey,
156
+ "seasonally_adjusted": seasonally_adjusted,
157
+ "periodicity": periodicity,
158
+ "area": area,
159
+ "items": items,
160
+ "series_id": series_id,
161
+ }
162
+ source_index = get(year_or_month, **kwargs)
163
+ target_index = get(to, **kwargs)
164
+ return (value * target_index) / float(source_index)
165
+
166
+
167
+ def update():
168
+ """
169
+ Updates the Consumer Price Index dataset at the core of this library.
170
+
171
+ Requires an Internet connection.
172
+ """
173
+ Downloader().update()
cpi/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ #! /usr/bin/env python
2
+ """
3
+ Command-line interface.
4
+ """
5
+
6
+ import click
7
+ from dateutil.parser import parse as dateparse
8
+
9
+ import cpi
10
+
11
+
12
+ @click.command()
13
+ @click.argument(
14
+ "value",
15
+ nargs=1,
16
+ required=True,
17
+ )
18
+ @click.argument(
19
+ "year_or_month",
20
+ nargs=1,
21
+ required=True,
22
+ )
23
+ @click.option(
24
+ "--to", nargs=1, default=None, help="The year or month to adjust the value to."
25
+ )
26
+ @click.option(
27
+ "--series_id",
28
+ type=click.STRING,
29
+ nargs=1,
30
+ default=cpi.DEFAULT_SERIES_ID,
31
+ help="The CPI data series used for the conversion. The default is the CPI-U.",
32
+ )
33
+ def inflate(value, year_or_month, to=None, series_id=cpi.DEFAULT_SERIES):
34
+ """
35
+ Returns a dollar value adjusted for inflation.
36
+ """
37
+ # Sanitize the value
38
+ try:
39
+ value = float(value)
40
+ except ValueError:
41
+ click.ClickException("Dollar value must be an integer or float.")
42
+
43
+ # Sanitize the `from` date.
44
+ try:
45
+ year_or_month = _parse_date(year_or_month)
46
+ except ValueError:
47
+ click.ClickException(
48
+ "Source date must be a year as an integer or a month as a parseable date string."
49
+ )
50
+
51
+ # Sanitize the `to` date.
52
+ if to:
53
+ try:
54
+ to = _parse_date(to)
55
+ except ValueError:
56
+ click.ClickException(
57
+ "Source date must be a year as an integer or a month as a parseable date string."
58
+ )
59
+
60
+ # Run the command
61
+ result = cpi.inflate(value, year_or_month, to=to, series_id=series_id)
62
+
63
+ # Print out the result to the terminal
64
+ click.echo(result)
65
+
66
+
67
+ def _parse_date(value):
68
+ """
69
+ Parse a date submitted to the CLIself.
70
+
71
+ Returns and integer if its a year. Returns a date object if its a month.
72
+ """
73
+ try:
74
+ return int(value)
75
+ except ValueError:
76
+ return dateparse(value).date()
cpi/cpi.db ADDED
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: cpi/cpi.db
3
+ size: 63467520 bytes
4
+ sha256: 28a3230316ae3f2a0566428f14d8d6402c25ab5702f63fab565d49e143276a1c
cpi/defaults.py ADDED
@@ -0,0 +1,23 @@
1
+ """Default values."""
2
+
3
+ from typing import TypedDict
4
+
5
+
6
+ class SeriesAttributes(TypedDict):
7
+ """Human-readable attributes identifying a CPI series."""
8
+
9
+ survey: str
10
+ seasonally_adjusted: bool
11
+ periodicity: str
12
+ area: str
13
+ items: str
14
+
15
+
16
+ DEFAULT_SERIES_ID = "CUUR0000SA0"
17
+ DEFAULTS_SERIES_ATTRS: SeriesAttributes = {
18
+ "survey": "All urban consumers",
19
+ "seasonally_adjusted": False,
20
+ "periodicity": "Monthly",
21
+ "area": "U.S. city average",
22
+ "items": "All items",
23
+ }
cpi/download.py ADDED
@@ -0,0 +1,192 @@
1
+ """Download the latest annual Consumer Price Index (CPI) dataset."""
2
+
3
+ import io
4
+ import logging
5
+ import sqlite3
6
+ import typing
7
+ from pathlib import Path
8
+
9
+ import pandas as pd
10
+ import requests
11
+
12
+ from cpi import parsers
13
+
14
+ logger = logging.getLogger(__name__)
15
+ logger.addHandler(logging.NullHandler())
16
+
17
+
18
+ class Downloader:
19
+ """Download the latest data."""
20
+
21
+ THIS_DIR: Path = Path(__file__).parent.absolute()
22
+ FILE_LIST: typing.List[str] = [
23
+ "cu.area",
24
+ "cu.item",
25
+ "cu.period",
26
+ "cu.periodicity",
27
+ "cu.series",
28
+ "cu.data.0.Current",
29
+ "cu.data.1.AllItems",
30
+ "cu.data.2.Summaries",
31
+ "cu.data.3.AsizeNorthEast",
32
+ "cu.data.4.AsizeNorthCentral",
33
+ "cu.data.5.AsizeSouth",
34
+ "cu.data.6.AsizeWest",
35
+ "cu.data.7.OtherNorthEast",
36
+ "cu.data.8.OtherNorthCentral",
37
+ "cu.data.9.OtherSouth",
38
+ "cu.data.10.OtherWest",
39
+ "cu.data.11.USFoodBeverage",
40
+ "cu.data.12.USHousing",
41
+ "cu.data.13.USApparel",
42
+ "cu.data.14.USTransportation",
43
+ "cu.data.15.USMedical",
44
+ "cu.data.16.USRecreation",
45
+ "cu.data.17.USEducationAndCommunication",
46
+ "cu.data.18.USOtherGoodsAndServices",
47
+ "cu.data.19.PopulationSize",
48
+ "cu.data.20.USCommoditiesServicesSpecial",
49
+ ]
50
+
51
+ def rm(self) -> None:
52
+ """Remove any existing files."""
53
+ db_path = self.THIS_DIR / "cpi.db"
54
+ if db_path.exists():
55
+ logger.debug("Clearing database")
56
+ # Drop all tables in the database
57
+ conn = self.get_db_conn()
58
+ table_list = [
59
+ "areas",
60
+ "items",
61
+ "periods",
62
+ "periodicities",
63
+ "series",
64
+ "indexes",
65
+ ]
66
+ for t in table_list:
67
+ conn.execute(f"DROP TABLE IF EXISTS '{t}';")
68
+ conn.close()
69
+ self.vacuum()
70
+
71
+ def vacuum(self) -> None:
72
+ """Vacuum the database."""
73
+ conn = self.get_db_conn()
74
+ conn.execute("VACUUM;")
75
+ conn.close()
76
+
77
+ def update(self) -> None:
78
+ """Update the Consumer Price Index dataset that powers this library."""
79
+ # Delete existing files
80
+ self.rm()
81
+
82
+ # Load the default files
83
+ self.load_file_list(self.FILE_LIST)
84
+
85
+ # Process flat CSVs we can use in our API
86
+ self.process_files()
87
+
88
+ # Drop the raw files now that we don't need them
89
+ self.drop_file_list(self.FILE_LIST)
90
+
91
+ def get_db_conn(self) -> sqlite3.Connection:
92
+ """Connect to db."""
93
+ db_path = self.THIS_DIR / "cpi.db"
94
+ return sqlite3.connect(db_path)
95
+
96
+ def process_files(self) -> None:
97
+ """Process the raw data files into simplified tables."""
98
+ logger.info("Parsing data files from the BLS")
99
+ conn = self.get_db_conn()
100
+
101
+ areas = parsers.ParseArea().get_df()
102
+ areas.to_sql("areas", conn, if_exists="replace", index=False)
103
+
104
+ items = parsers.ParseItem().get_df()
105
+ items.to_sql("items", conn, if_exists="replace", index=False)
106
+
107
+ periods = parsers.ParsePeriod().get_df()
108
+ periods.to_sql("periods", conn, if_exists="replace", index=False)
109
+
110
+ periodicities = parsers.ParsePeriodicity().get_df()
111
+ periodicities.to_sql("periodicities", conn, if_exists="replace", index=False)
112
+
113
+ series = parsers.ParseSeries().get_df()
114
+ series.to_sql("series", conn, if_exists="replace", index=False)
115
+
116
+ index = parsers.ParseIndex().get_df()
117
+ index.to_sql("indexes", conn, if_exists="replace", index=False)
118
+
119
+ conn.close()
120
+
121
+ def load_file_list(self, file_list: typing.List[str]) -> None:
122
+ # Download the TSVs
123
+ logger.debug(f"Downloading {len(file_list)} files from the BLS")
124
+ df_list = {name: self.get_df(name) for name in file_list}
125
+
126
+ # Insert the TSVs
127
+ logger.debug("Loading data into SQLite database")
128
+ conn = self.get_db_conn()
129
+
130
+ # Load them one by one
131
+ for name, df in df_list.items():
132
+ logger.debug(f"- {name}")
133
+ df.to_sql(name, conn, if_exists="replace", index=False)
134
+
135
+ # Close connection
136
+ conn.close()
137
+
138
+ def drop_file_list(self, file_list: typing.List[str]) -> None:
139
+ """Drop the raw data from BLS."""
140
+ logger.debug("Dropping data from SQLite database")
141
+
142
+ # Connect
143
+ conn = self.get_db_conn()
144
+
145
+ # Do tables one by one
146
+ for name in file_list:
147
+ logger.debug(f"- {name}")
148
+ conn.execute(f"DROP TABLE '{name}';")
149
+
150
+ # Close the connection
151
+ conn.close()
152
+
153
+ # Vacuum the database.
154
+ self.vacuum()
155
+
156
+ def get_df(self, file: str) -> pd.DataFrame:
157
+ """Download TSV file from the BLS."""
158
+ # Download it
159
+ url = f"https://download.bls.gov/pub/time.series/cu/{file}"
160
+ logger.debug(f" - {url}")
161
+ headers = {
162
+ "User-Agent": "b@palewi.re",
163
+ }
164
+ response = requests.get(url, headers=headers, timeout=30)
165
+
166
+ # Make sure the response is legit
167
+ try:
168
+ assert response.ok
169
+ except AssertionError:
170
+ logger.error(f"Error downloading {url}")
171
+ logger.error(f"Response: {response.text}")
172
+ raise AssertionError(f"Error downloading {url} - {response.text}")
173
+
174
+ # Read in the contents as an io.StringIO object
175
+ df = pd.read_csv(io.StringIO(response.text), sep="\t")
176
+
177
+ # .strip() every value in the dataframe
178
+ df_obj = df.select_dtypes("object")
179
+ df[df_obj.columns] = df_obj.apply(lambda x: x.str.strip())
180
+
181
+ # .strip every column name
182
+ df.columns = [col.strip() for col in df.columns]
183
+
184
+ # Clean file
185
+ df.drop([None], axis=1, inplace=True, errors="ignore")
186
+
187
+ # Pass it back
188
+ return df
189
+
190
+
191
+ if __name__ == "__main__":
192
+ Downloader().update()
cpi/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ #! /usr/bin/env python
2
+ """
3
+ Custom errors.
4
+ """
5
+
6
+
7
+ class CPIObjectDoesNotExist(Exception):
8
+ """
9
+ Error raised when a CPI object is requested that doesn't exist.
10
+ """
11
+
12
+ pass
13
+
14
+
15
+ class StaleDataWarning(Warning):
16
+ """
17
+ The warning to raise when the local data are out of date.
18
+ """
19
+
20
+ def __str__(self):
21
+ return "CPI data is out of date. To accurately inflate to today's dollars, you must run `cpi.update()`."
cpi/models.py ADDED
@@ -0,0 +1,444 @@
1
+ """Python objects for modeling Consumer Price Index (CPI) data structures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sqlite3
7
+ from collections.abc import Sequence
8
+ from datetime import date as Date
9
+ from typing import Any
10
+ from pathlib import Path
11
+
12
+ from pandas import json_normalize
13
+
14
+ from .defaults import DEFAULTS_SERIES_ATTRS
15
+
16
+ # CPI tools
17
+ from .errors import CPIObjectDoesNotExist
18
+
19
+ logger = logging.getLogger(__name__)
20
+ logger.addHandler(logging.NullHandler())
21
+
22
+
23
+ def query(sql: str, params: Sequence[Any] | None = None) -> list[dict[str, Any]]:
24
+ """Query the cpi.db database and return the result.
25
+
26
+ Args:
27
+ sql (str): The SQL query to execute.
28
+ params (list | tuple): The parameters to pass to the query.
29
+
30
+ Returns:
31
+ list[dict]: A list of dictionaries representing the result of the query.
32
+
33
+ Examples:
34
+ >>> query("SELECT * FROM 'areas';")
35
+ [{'id': '0000', 'code': 'US', 'name': 'United States'}, ...]
36
+ """
37
+ # Connect
38
+ this_dir = Path(__file__).parent.absolute()
39
+ conn = sqlite3.connect(this_dir / "cpi.db")
40
+ cursor = conn.cursor()
41
+
42
+ # Query the sql
43
+ if not params:
44
+ query = cursor.execute(sql)
45
+ else:
46
+ query = cursor.execute(sql, params)
47
+ columns = [d[0] for d in query.description]
48
+ result_list = [dict(zip(columns, r)) for r in query.fetchall()]
49
+
50
+ # Close
51
+ conn.close()
52
+
53
+ # Return the result
54
+ return result_list
55
+
56
+
57
+ def queryone(sql: str, params: Sequence[Any] | None = None) -> dict[str, Any]:
58
+ """Query the cpi.db database and return a single result.
59
+
60
+ Args:
61
+ sql (str): The SQL query to execute.
62
+ params (list | tuple): The parameters to pass to the query.
63
+
64
+ Returns:
65
+ dict: A dictionary representing the result of the query.
66
+
67
+ Raises:
68
+ CPIObjectDoesNotExist: If the object does not exist.
69
+ ValueError: If more than one object exists.
70
+
71
+ Examples:
72
+ >>> queryone("SELECT * FROM 'areas' WHERE id=?", ('0000',))
73
+ {'id': '0000', 'code': 'US', 'name': 'United States'}
74
+ """
75
+ dict_list = query(sql, params)
76
+ try:
77
+ assert len(dict_list) == 1
78
+ except AssertionError:
79
+ if len(dict_list) == 0:
80
+ raise CPIObjectDoesNotExist("Object does not exist")
81
+ elif len(dict_list) > 1:
82
+ raise ValueError("More than one object exists")
83
+ return dict_list[0]
84
+
85
+
86
+ class BaseObject:
87
+ """An abstract base class for all the models."""
88
+
89
+ table_name: str | None = None #: The name of the table in the database.
90
+
91
+ def __repr__(self) -> str:
92
+ return f"<{self.__class__.__name__}: {self.__str__()}>"
93
+
94
+ def __eq__(self, other: object) -> bool:
95
+ return bool(
96
+ getattr(other, "id", None) is not None
97
+ and getattr(self, "id", None) == getattr(other, "id", None)
98
+ )
99
+
100
+ def __str__(self) -> str:
101
+ return str(getattr(self, "name"))
102
+
103
+ @classmethod
104
+ def get_by_id(cls, value: str) -> Any:
105
+ """Returns the object with the provided identifier code."""
106
+ d = queryone(f"SELECT * from '{cls.table_name}' WHERE id=?", (value,))
107
+ return cls(**d)
108
+
109
+ @classmethod
110
+ def get_by_name(cls, value: str) -> Any:
111
+ """Returns the object with the provided name."""
112
+ d = queryone(f"SELECT * from '{cls.table_name}' WHERE name=?", (value,))
113
+ return cls(**d)
114
+
115
+ @classmethod
116
+ def all(cls) -> list[Any]:
117
+ """Returns a list of all objects in the table."""
118
+ dict_list = query(f"SELECT * FROM '{cls.table_name}'")
119
+ return [cls(**d) for d in dict_list]
120
+
121
+
122
+ class Area(BaseObject):
123
+ """A geographical area where prices are gathered monthly."""
124
+
125
+ table_name = "areas"
126
+
127
+ def __init__(self, id, code, name):
128
+ self.id = id
129
+ self.code = code
130
+ self.name = name
131
+
132
+ def __dict__(self):
133
+ return {"id": self.id, "code": self.code, "name": self.name}
134
+
135
+
136
+ class Item(BaseObject):
137
+ """A type of consumer good or goods that has its price tracked."""
138
+
139
+ table_name = "items"
140
+
141
+ def __init__(self, id, code, name):
142
+ self.id = id
143
+ self.code = code
144
+ self.name = name
145
+
146
+ def __dict__(self):
147
+ return {"id": self.id, "code": self.code, "name": self.name}
148
+
149
+
150
+ class Period(BaseObject):
151
+ """A time period tracked by the CPI."""
152
+
153
+ table_name = "periods"
154
+
155
+ def __init__(self, id, code, abbreviation, name):
156
+ self.id = id
157
+ self.code = code
158
+ self.abbreviation = abbreviation
159
+ self.name = name
160
+
161
+ def __dict__(self):
162
+ return {
163
+ "id": self.id,
164
+ "code": self.code,
165
+ "abbreviation": self.abbreviation,
166
+ "name": self.name,
167
+ "month": self.month,
168
+ "type": self.type,
169
+ }
170
+
171
+ @property
172
+ def month(self) -> int:
173
+ """
174
+ Returns the month integer for the period.
175
+ """
176
+ if self.id in ["M13", "S01", "S03"]:
177
+ return 1
178
+ elif self.id == "S02":
179
+ return 7
180
+ else:
181
+ return int(self.id.replace("M", ""))
182
+
183
+ @property
184
+ def type(self) -> str:
185
+ """
186
+ Returns a string classifying the period.
187
+ """
188
+ if self.id in ["M13", "S03"]:
189
+ return "annual"
190
+ elif self.id in ["S01", "S02"]:
191
+ return "semiannual"
192
+ else:
193
+ return "monthly"
194
+
195
+
196
+ class Periodicity(BaseObject):
197
+ """A time interval tracked by the CPI."""
198
+
199
+ table_name = "periodicities"
200
+
201
+ def __init__(self, id, code, name):
202
+ self.id = code
203
+ self.code = code
204
+ self.name = name
205
+
206
+ def __dict__(self):
207
+ return {"id": self.id, "code": self.code, "name": self.name}
208
+
209
+
210
+ class Index(BaseObject):
211
+ """A Consumer Price Index value generated by the Bureau of Labor Statistics."""
212
+
213
+ table_name = "indexes"
214
+
215
+ def __init__(self, series_id: str, year: int, period: Period, value: float):
216
+ self.series_id = series_id
217
+ self.year = year
218
+ self.period = period
219
+ self.value = value
220
+
221
+ def __str__(self) -> str:
222
+ return f"{self.date} ({self.period}): {self.value}"
223
+
224
+ def __eq__(self, other: object) -> bool:
225
+ if not isinstance(other, Index):
226
+ return NotImplemented
227
+ return (
228
+ self.value == other.value
229
+ and self.series_id == other.series_id
230
+ and self.year == other.year
231
+ and self.period == other.period
232
+ )
233
+
234
+ def __dict__(self):
235
+ return {
236
+ "series_id": self.series_id,
237
+ "year": self.year,
238
+ "date": str(self.date),
239
+ "period": self.period.__dict__(),
240
+ "value": self.value,
241
+ }
242
+
243
+ @property
244
+ def date(self) -> Date:
245
+ """
246
+ Accepts a row from the raw BLS data. Returns a Python date object based on its period.
247
+ """
248
+ return Date(self.year, self.period.month, 1)
249
+
250
+
251
+ class Series(BaseObject):
252
+ """
253
+ A set of CPI data observed over an extended period of time over consistent time intervals ranging from
254
+ a specific consumer item in a specific geographical area whose price is gathered monthly to a category
255
+ of worker in a specific industry whose employment rate is being recorded monthly, etc.
256
+
257
+ Yes, that's the official government definition. I'm not kidding.
258
+ """
259
+
260
+ def __init__(
261
+ self,
262
+ id: str,
263
+ title: str,
264
+ survey: str,
265
+ seasonally_adjusted: str,
266
+ periodicity: Periodicity,
267
+ area: Area,
268
+ items: Item,
269
+ indexes: list[Index],
270
+ ):
271
+ self.id = id
272
+ self.title = title
273
+ self.survey = survey
274
+ self.seasonally_adjusted = seasonally_adjusted
275
+ self.periodicity = periodicity
276
+ self.area = area
277
+ self.items = items
278
+ self.indexes = indexes
279
+
280
+ def __str__(self) -> str:
281
+ return f"{self.id}: {self.title}"
282
+
283
+ def __dict__(self):
284
+ return {
285
+ "id": self.id,
286
+ "title": self.title,
287
+ "survey": self.survey,
288
+ "seasonally_adjusted": self.seasonally_adjusted,
289
+ "periodicity": self.periodicity.__dict__(),
290
+ "area": self.area.__dict__(),
291
+ "items": self.items.__dict__(),
292
+ }
293
+
294
+ def to_dataframe(self):
295
+ """
296
+ Returns this series and all its indexes as a pandas DataFrame.
297
+ """
298
+ dict_list = [obj.__dict__() for obj in self.indexes]
299
+ return json_normalize(dict_list, sep="_")
300
+
301
+ @property
302
+ def latest_month(self) -> Date:
303
+ return max([i.date for i in self.indexes if i.period.type == "monthly"])
304
+
305
+ @property
306
+ def latest_year(self) -> int:
307
+ return max([i.year for i in self.indexes if i.period.type == "annual"])
308
+
309
+ def get_index_by_date(self, date: Date, period_type: str = "annual") -> Index:
310
+ period_list = [i for i in self.indexes if i.period.type == period_type]
311
+ try:
312
+ return next(i for i in period_list if i.date == date)
313
+ except StopIteration:
314
+ raise CPIObjectDoesNotExist(
315
+ f"Index of {period_type} type for {date} does not exist"
316
+ )
317
+
318
+ @classmethod
319
+ def get_by_id(cls, value: str) -> Series:
320
+ # If it's not there, try querying the database
321
+ d = queryone("SELECT * FROM 'series' WHERE id=?", (value,))
322
+
323
+ # Get the other bits
324
+ seasonalities = {1: True, 0: False}
325
+ d["seasonally_adjusted"] = seasonalities[d["seasonally_adjusted"]]
326
+ d["periodicity"] = Periodicity.get_by_id(d["periodicity"])
327
+ d["area"] = Area.get_by_id(d["area"])
328
+ d["items"] = Item.get_by_id(d["items"])
329
+
330
+ # Get the indexes
331
+ dict_list = query("SELECT * FROM 'indexes' WHERE series=?", (value,))
332
+
333
+ # Cache the periods to reduce queries
334
+ period_cache = {p.id: p for p in Period.all()}
335
+
336
+ # Load the indexes one by one
337
+ d["indexes"] = []
338
+ for i in dict_list:
339
+ obj = Index(
340
+ series_id=d["id"],
341
+ year=int(i["year"]),
342
+ period=period_cache[i["period"]],
343
+ value=float(i["value"]),
344
+ )
345
+ d["indexes"].append(obj)
346
+
347
+ # Convert it into a Series object
348
+ return cls(**d)
349
+
350
+
351
+ class SeriesList(list[Series]):
352
+ """
353
+ A custom list of indexes in a series.
354
+ """
355
+
356
+ SEASONALITIES = {True: "S", False: "U"}
357
+ SURVEYS = {
358
+ "All urban consumers": "CU",
359
+ "Urban wage earners and clerical workers": "CW",
360
+ }
361
+
362
+ # Set a cache
363
+ _dict: dict[str, Series] = {}
364
+
365
+ def to_dataframe(self):
366
+ """
367
+ Returns the list as a pandas DataFrame.
368
+ """
369
+ dict_list = [obj.__dict__() for obj in self]
370
+ return json_normalize(dict_list, sep="_")
371
+
372
+ def append(self, obj: Series):
373
+ """
374
+ Override to default append method that allows validation and dictionary-style lookups
375
+ """
376
+ # Add to dictionary lookup
377
+ self._dict[obj.id] = obj
378
+
379
+ # Append to list
380
+ super().append(obj)
381
+
382
+ def get_by_id(self, value: str) -> Series:
383
+ """Returns the CPI series object with the provided identifier code."""
384
+ logger.debug(f"Retrieving series with id {value}")
385
+
386
+ # First try the cache
387
+ try:
388
+ return self._dict[value]
389
+ except KeyError:
390
+ pass
391
+
392
+ # Get it
393
+ obj = Series.get_by_id(value)
394
+
395
+ # Cache it
396
+ self._dict[value] = obj
397
+
398
+ # Return it
399
+ return obj
400
+
401
+ def all(self) -> list[Series]:
402
+ """Get all of the series from our database."""
403
+ # Query all of the series ids from the database
404
+ series_list = query("SELECT id FROM 'series';")
405
+
406
+ # Get all of them, to ensure they're all loaded in the cache
407
+ return [self.get_by_id(d["id"]) for d in series_list]
408
+
409
+ def get(
410
+ self,
411
+ survey: str = DEFAULTS_SERIES_ATTRS["survey"],
412
+ seasonally_adjusted: bool = DEFAULTS_SERIES_ATTRS["seasonally_adjusted"],
413
+ periodicity: str = DEFAULTS_SERIES_ATTRS["periodicity"],
414
+ area: str = DEFAULTS_SERIES_ATTRS["area"],
415
+ items: str = DEFAULTS_SERIES_ATTRS["items"],
416
+ ) -> Series:
417
+ """Returns a single CPI Series object based on the input.
418
+
419
+ The default series is returned if not configuration is made to the keyword arguments.
420
+ """
421
+ # Get all the codes for these humanized input.
422
+ try:
423
+ survey_code = self.SURVEYS[survey]
424
+ except KeyError:
425
+ raise CPIObjectDoesNotExist(f"Survey with the name {survey} does not exist")
426
+
427
+ try:
428
+ seasonality_code = self.SEASONALITIES[seasonally_adjusted]
429
+ except KeyError:
430
+ raise CPIObjectDoesNotExist(
431
+ f"Seasonality {seasonally_adjusted} does not exist"
432
+ )
433
+
434
+ # Generate the series id
435
+ series_id = "{}{}{}{}{}".format(
436
+ survey_code,
437
+ seasonality_code,
438
+ Periodicity.get_by_name(periodicity).code,
439
+ Area.get_by_name(area).code,
440
+ Item.get_by_name(items).code,
441
+ )
442
+
443
+ # Pull the series
444
+ return self.get_by_id(series_id)
cpi/parsers.py ADDED
@@ -0,0 +1,215 @@
1
+ #! /usr/bin/env python
2
+ """
3
+ Parse and prepare the Consumer Price Index (CPI) dataset.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ import sqlite3
9
+ from typing import Any
10
+
11
+ import pandas as pd
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logger.addHandler(logging.NullHandler())
15
+
16
+
17
+ class BaseParser:
18
+ THIS_DIR = os.path.dirname(__file__)
19
+
20
+ def get_file(self, file):
21
+ """
22
+ Returns the CPI data file provided as a list of dictionaries.
23
+ """
24
+ # Connect to database
25
+ db_path = os.path.join(self.THIS_DIR, "cpi.db")
26
+ conn = sqlite3.connect(db_path)
27
+ cursor = conn.cursor()
28
+
29
+ # Query this file
30
+ query = cursor.execute(f'SELECT * FROM "{file}"')
31
+ columns = [d[0] for d in query.description]
32
+ result_list = [dict(zip(columns, r)) for r in query.fetchall()]
33
+
34
+ # Close database connection
35
+ cursor.close()
36
+ cursor.connection.close()
37
+
38
+ # Return data
39
+ return result_list
40
+
41
+ def parse(self) -> list[dict[str, Any]]:
42
+ raise NotImplementedError
43
+
44
+ def get_df(self) -> pd.DataFrame:
45
+ """Convert the database table to a polished dataframe."""
46
+ return pd.DataFrame(self.parse()).drop_duplicates()
47
+
48
+
49
+ class ParseArea(BaseParser):
50
+ """Parse the raw list of CPI areas."""
51
+
52
+ def parse(self):
53
+ """
54
+ Returns a list Area objects.
55
+ """
56
+ logger.debug("Parsing area file")
57
+ object_list = []
58
+ for row in self.get_file("cu.area"):
59
+ d = dict(
60
+ id=row["area_code"],
61
+ code=row["area_code"],
62
+ name=row["area_name"],
63
+ )
64
+ object_list.append(d)
65
+ return object_list
66
+
67
+
68
+ class ParseItem(BaseParser):
69
+ """
70
+ Parses the raw list of CPI items.
71
+ """
72
+
73
+ def parse(self):
74
+ """
75
+ Returns a list Area objects.
76
+ """
77
+ logger.debug("Parsing item file")
78
+ object_list = []
79
+ for row in self.get_file("cu.item"):
80
+ d = dict(
81
+ id=row["item_code"],
82
+ code=row["item_code"],
83
+ name=row["item_name"],
84
+ )
85
+ object_list.append(d)
86
+ return object_list
87
+
88
+
89
+ class ParsePeriod(BaseParser):
90
+ """
91
+ Parses the raw list of CPI periods.
92
+ """
93
+
94
+ def parse(self):
95
+ """
96
+ Returns a list Area objects.
97
+ """
98
+ logger.debug("Parsing period file")
99
+ object_list = []
100
+ for row in self.get_file("cu.period"):
101
+ d = dict(
102
+ id=row["period"],
103
+ code=row["period"],
104
+ abbreviation=row["period_abbr"],
105
+ name=row["period_name"],
106
+ )
107
+ object_list.append(d)
108
+ return object_list
109
+
110
+
111
+ class ParsePeriodicity(BaseParser):
112
+ """
113
+ Parses the raw list of CPI periodicities.
114
+ """
115
+
116
+ def parse(self):
117
+ """
118
+ Returns a list Periodicity objects.
119
+ """
120
+ logger.debug("Parsing periodicity file")
121
+ object_list = []
122
+ for row in self.get_file("cu.periodicity"):
123
+ d = dict(
124
+ id=row["periodicity_code"],
125
+ code=row["periodicity_code"],
126
+ name=row["periodicity_name"],
127
+ )
128
+ object_list.append(d)
129
+ return object_list
130
+
131
+
132
+ class ParseSeries(BaseParser):
133
+ """
134
+ Parses the raw list of CPI series from the BLS.
135
+ """
136
+
137
+ SURVEYS = {
138
+ "CU": "All urban consumers",
139
+ "CW": "Urban wage earners and clerical workers",
140
+ }
141
+
142
+ def parse_id(self, id):
143
+ return dict(
144
+ survey_code=id[:2],
145
+ seasonal_code=id[2:3],
146
+ periodicity_code=id[3:4],
147
+ area_code=id[4:8],
148
+ item_code=id[8:],
149
+ )
150
+
151
+ def parse(self):
152
+ """Parse the data."""
153
+ logger.debug("Parsing series file")
154
+ object_list = []
155
+ for row in self.get_file("cu.series"):
156
+ parsed_id = self.parse_id(row["series_id"])
157
+ d = dict(
158
+ id=row["series_id"],
159
+ title=row["series_title"],
160
+ survey=self.SURVEYS[parsed_id["survey_code"]],
161
+ seasonally_adjusted=row["seasonal"] == "S",
162
+ periodicity=row["periodicity_code"],
163
+ area=row["area_code"],
164
+ items=row["item_code"],
165
+ )
166
+ object_list.append(d)
167
+ return object_list
168
+
169
+
170
+ class ParseIndex(BaseParser):
171
+ """Parse indexes."""
172
+
173
+ FILE_LIST = [
174
+ "cu.data.0.Current",
175
+ "cu.data.1.AllItems",
176
+ "cu.data.2.Summaries",
177
+ "cu.data.3.AsizeNorthEast",
178
+ "cu.data.4.AsizeNorthCentral",
179
+ "cu.data.5.AsizeSouth",
180
+ "cu.data.6.AsizeWest",
181
+ "cu.data.7.OtherNorthEast",
182
+ "cu.data.8.OtherNorthCentral",
183
+ "cu.data.9.OtherSouth",
184
+ "cu.data.10.OtherWest",
185
+ "cu.data.11.USFoodBeverage",
186
+ "cu.data.12.USHousing",
187
+ "cu.data.13.USApparel",
188
+ "cu.data.14.USTransportation",
189
+ "cu.data.15.USMedical",
190
+ "cu.data.16.USRecreation",
191
+ "cu.data.17.USEducationAndCommunication",
192
+ "cu.data.18.USOtherGoodsAndServices",
193
+ "cu.data.19.PopulationSize",
194
+ "cu.data.20.USCommoditiesServicesSpecial",
195
+ ]
196
+
197
+ def parse(self):
198
+ logger.debug("Parsing index files")
199
+ # Loop through all the files ...
200
+ object_list = []
201
+ for file in self.FILE_LIST:
202
+ # ... and for each file ...
203
+ for row in self.get_file(file):
204
+ # Skip rows where value is '-' (missing data) or None
205
+ if row["value"] == "-" or row["value"] is None:
206
+ continue
207
+ # Create an object
208
+ d = dict(
209
+ series=row["series_id"],
210
+ year=int(row["year"]),
211
+ period=row["period"],
212
+ value=float(row["value"]),
213
+ )
214
+ object_list.append(d)
215
+ return object_list
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: cpi
3
+ Version: 2.1.0
4
+ Summary: Quickly adjust U.S. dollars for inflation using the Consumer Price Index (CPI)
5
+ Author-email: Ben Welsh <b@palewi.re>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://palewi.re/docs/cpi/
8
+ Project-URL: Maintainer, https://github.com/palewire
9
+ Project-URL: Source, https://github.com/palewire/cpi
10
+ Project-URL: Tracker, https://github.com/palewire/cpi/issues
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: click
21
+ Requires-Dist: pandas
22
+ Requires-Dist: python-dateutil
23
+ Requires-Dist: requests
24
+ Dynamic: license-file
25
+
26
+ ### Links
27
+
28
+ * Docs: [palewi.re/docs/cpi/](https://palewi.re/docs/cpi/)
29
+ * Code: [github.com/palewire/cpi](https://github.com/palewire/cpi/)
30
+ * Issues: [github.com/palewire/cpi/issues](https://github.com/palewire/cpi/issues)
31
+ * Packaging: [pypi.python.org/pypi/cpi](https://pypi.python.org/pypi/cpi)
32
+ * Testing: [github.com/palewire/cpi/actions](https://github.com/palewire/cpi/actions)
@@ -0,0 +1,14 @@
1
+ cpi/__init__.py,sha256=_4B1P9fcyzc5Ht-xSc1poMunvuP2Kc_MvVQJ4f2xqig,5900
2
+ cpi/cli.py,sha256=LsYT2aZr41W26ODAY5XBHPI2WypunNMPzD5RrFu6KFk,1811
3
+ cpi/cpi.db,sha256=KKMjAxauPyoFZkKPFNjWQCwlq1cC9j-rVl1J4UMnahw,63467520
4
+ cpi/defaults.py,sha256=kzpp1JsVJGlOO8fngt52vuyW1Um4-jOVZjNhgbUjPc8,490
5
+ cpi/download.py,sha256=as7Q3GEJgLUo_k7QqDKo5uymJHPklhczwBPLAnbbt8E,5898
6
+ cpi/errors.py,sha256=CKP9J93kC6o_KjNZ_CObKBFr-tyRF4yKB58R8xQxzyU,432
7
+ cpi/models.py,sha256=MXvGc_ssgTTOldzXVfH2YWG8zG62KDjQS12znrzetZs,13101
8
+ cpi/parsers.py,sha256=zJJIk43hv9eOJ9MgmBCqjgsIzZAUQZxhI0AxZ5zmHjE,5937
9
+ cpi-2.1.0.dist-info/licenses/LICENSE,sha256=rL8rZwFTGsHogbAIXzBuGPZrc7tRu-aJT7pU9z6kjX8,1076
10
+ cpi-2.1.0.dist-info/METADATA,sha256=Ex3xZM4sajvR0YhZAiWFq9BPJKXXXr1poE480zG2j34,1314
11
+ cpi-2.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ cpi-2.1.0.dist-info/entry_points.txt,sha256=QK4gpcr7Qk2WAwXKvSTAHKQqi3b-oXnqCRcTTie9HCc,44
13
+ cpi-2.1.0.dist-info/top_level.txt,sha256=9TRAFyj1-KCSznuPFdi9rBS8IzNiz97gvDjVhQIg-s4,4
14
+ cpi-2.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inflate = cpi.cli:inflate
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Ben Welsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cpi