readabs.grab_abs_url

Find and extract DataFrames from an ABS webpage.

  1"""Find and extract DataFrames from an ABS webpage."""
  2
  3# --- imports ---
  4# standard library imports
  5import zipfile
  6from functools import cache
  7from io import BytesIO
  8from typing import Any, Unpack
  9from pathlib import Path
 10
 11# analytic imports
 12import pandas as pd
 13from pandas import DataFrame
 14
 15from readabs.abs_catalogue import abs_catalogue
 16from readabs.download_cache import get_file
 17
 18# local imports
 19from readabs.get_abs_links import get_abs_links, get_table_name
 20from readabs.read_support import HYPHEN, ReadArgs, check_kwargs, get_args
 21
 22# --- constants ---
 23# File extensions for ABS data files
 24ZIP_EXTENSION = ".zip"
 25EXCEL_EXTENSION = ".xlsx"
 26
 27# Processing order: ZIP files must be processed before Excel files
 28# This prevents duplicate data when ZIP files contain Excel files
 29FILE_EXTENSIONS_PROCESSING_ORDER = (ZIP_EXTENSION, EXCEL_EXTENSION)
 30
 31# Default values and limits
 32EMPTY_BYTES_LENGTH = 0
 33
 34
 35# --- public - primary entry point for this module
 36@cache  # minimise slowness with repeat business
 37def grab_abs_url(
 38    cat: str = "",
 39    url: str = "",
 40    **kwargs: Unpack[ReadArgs],
 41) -> dict[str, DataFrame]:
 42    """For a given URL, extract the data from the Excel and ZIP file links found on that page.
 43
 44    The data is returned as a dictionary of DataFrames. The Excel files are converted
 45    into DataFrames, with each sheet in each Excel file becoming a separate DataFrame.
 46    ZIP files are examined for Excel files, which are similarly converted into
 47    DataFrames. The dictionary of DataFrames is returned.
 48
 49    The preferred mechanism for reading ABS data is to use the `read_abs_cat()`
 50    or `read_abs_series()` functions. This function is provided for those
 51    cases where the data is not available in the ABS catalogue, where the
 52    data is not a timeseries, or where the user wants to extract data from
 53    a specific ABS landingpage.
 54
 55
 56    Parameters
 57    ----------
 58    url : str = ""
 59        A URL for an ABS Catalogue landing page. Either a url or
 60        a catalogue number must be provided. If both are provided, the
 61        URL will be used.
 62
 63    cat : str = ""
 64        An ABS Catalogue number. If provided, and the URL is not
 65        provided, then the Catalogue number will be used to get the URL.
 66
 67    **kwargs : Unpack[ReadArgs]
 68        Accepts the same keyword arguments as `read_abs_cat()`.
 69
 70    Returns
 71    -------
 72    dict[str, DataFrame]
 73        A dictionary of DataFrames.
 74
 75    """
 76    # check/get the keyword arguments
 77    url = _get_url(url, cat)
 78    check_kwargs(kwargs, "grab_abs_url")  # warn if invalid kwargs
 79    args = get_args(kwargs, "grab_abs_url")  # get the valid kwargs
 80    if verbose := args["verbose"]:
 81        print(f"grab_abs_url(): {url=}, {args=}")
 82
 83    # get the URL links to the relevant ABS data files on that webpage
 84    links = get_abs_links(url, **args)
 85    if not links:
 86        print(f"No data files found at URL: {url}")
 87        return {}  # return an empty Dictionary
 88
 89    # read the data files into a dictionary of DataFrames
 90    abs_dict: dict[str, DataFrame] = {}
 91
 92    # Process single file requests first
 93    abs_dict = _process_single_files(abs_dict, links, args, verbose=verbose)
 94    if abs_dict:  # If single file was found and processed, return it
 95        return abs_dict
 96
 97    # Process all files based on configuration
 98    return _process_all_files(abs_dict, links, args)
 99
100
101def grab_abs_zip(
102    zip_path: Path | str,
103    **kwargs: Unpack[ReadArgs]
104) -> dict[str, DataFrame]:
105    """Grab and process a single ABS ZIP file from a file system location.
106
107    This is a convenience function that opens an ABS ZIP file from a local
108    filesystem path. Expect to be used rarely.
109
110    Parameters
111    ----------
112    zip_path : Path | str
113        The local filesystem path of the ABS ZIP file to open and process.
114
115    **kwargs : Unpack[ReadArgs]
116        Additional keyword arguments for file retrieval and processing.
117
118    Returns
119    -------
120    dict[str, DataFrame]
121        A dictionary of DataFrames extracted from the ZIP file.
122
123    """
124    check_kwargs(kwargs, "grab_abs_zip")  # warn if invalid kwargs
125    args = get_args(kwargs, "grab_abs_zip")  # get the valid kwargs
126
127    zp: Path = zip_path if isinstance(zip_path, Path) else Path(zip_path)
128    zip_bytes = zp.read_bytes()
129    abs_dict: dict[str, DataFrame] = {}
130    return _process_zip(abs_dict, zip_bytes, **args)
131
132
133# --- private
134def _process_single_files(
135    abs_dict: dict[str, DataFrame],
136    links: dict[str, list[str]],
137    args: dict[str, Any],  # ReadArgs after processing
138    *,
139    verbose: bool,
140) -> dict[str, DataFrame]:
141    """Process single file requests (single_excel_only or single_zip_only)."""
142    if args["single_excel_only"]:
143        link = _find_url(links, EXCEL_EXTENSION, args["single_excel_only"], verbose=verbose)
144        if link:
145            return _add_excel(abs_dict, link, **args)
146
147    if args["single_zip_only"]:
148        link = _find_url(links, ZIP_EXTENSION, args["single_zip_only"], verbose=verbose)
149        if link:
150            return _add_zip(abs_dict, link, **args)
151
152    return abs_dict
153
154
155def _process_all_files(
156    abs_dict: dict[str, DataFrame],
157    links: dict[str, list[str]],
158    args: dict[str, Any],  # ReadArgs after processing
159) -> dict[str, DataFrame]:
160    """Process all files based on configuration (get_zip, get_excel, etc.)."""
161    for link_type in FILE_EXTENSIONS_PROCESSING_ORDER:
162        for link in links.get(link_type, []):
163            if link_type == ZIP_EXTENSION and args["get_zip"]:
164                abs_dict = _add_zip(abs_dict, link, **args)
165            elif link_type == EXCEL_EXTENSION and _should_process_excel_file(args, links):
166                abs_dict = _add_excel(abs_dict, link, **args)
167    return abs_dict
168
169
170def _should_process_excel_file(args: dict[str, Any], links: dict[str, list[str]]) -> bool:
171    """Determine if Excel files should be processed based on configuration.
172
173    Excel files are processed if:
174    1. get_excel is explicitly True, or
175    2. get_excel_if_no_zip is True AND (get_zip is False OR no ZIP files are available)
176
177    Args:
178        args: Configuration arguments from user
179        links: Dictionary of available file links by type
180
181    Returns:
182        bool: True if Excel files should be processed
183
184    """
185    # Always process if explicitly requested
186    if args["get_excel"]:
187        return True
188
189    # Process Excel if requested when no ZIP files, and either:
190    # - ZIP processing is disabled, or
191    # - No ZIP files are available
192    if args["get_excel_if_no_zip"]:
193        zip_processing_disabled = not args["get_zip"]
194        no_zip_files_available = not links.get(ZIP_EXTENSION, [])
195        return zip_processing_disabled or no_zip_files_available
196
197    return False
198
199
200def _find_url(links: dict[str, list[str]], targ_type: str, target: str, *, verbose: bool = False) -> str:
201    """Find the URL for a target file type.
202
203    Args:
204        links: Dictionary mapping file types to lists of URLs
205        targ_type: Target file extension (e.g., '.xlsx', '.zip')
206        target: Target filename without extension
207        verbose: Whether to print debug information
208
209    Returns:
210        str: The matching URL if found, otherwise an empty string
211
212    """
213    targ_list = links.get(targ_type, [])
214    if not targ_list:
215        return ""
216    goal = f"{target}{targ_type}"
217    if verbose:
218        print(f"_find_url(): looking for {goal} in {targ_list}.")
219    for link in targ_list:
220        if link.endswith(goal):
221            return link
222    return ""
223
224
225def _get_url(url: str, cat: str) -> str:
226    """Get URL from provided URL or catalogue number.
227
228    If an ABS catalogue number is provided and URL is not provided,
229    get the URL for the ABS data files on the ABS webpage.
230    Otherwise, return the URL provided. Either the 'url' or
231    'cat' argument must be provided.
232
233    Args:
234        url: The URL to use if provided
235        cat: The catalogue number to use if URL is not provided
236
237    Returns:
238        str: The URL to use for data retrieval
239
240    Raises:
241        ValueError: If neither URL nor valid catalogue number is provided
242
243    """
244    if not url and cat:
245        try:
246            cat_map = abs_catalogue()
247            if cat in cat_map.index:
248                url = str(cat_map.loc[cat, "URL"])
249        except (KeyError, IndexError) as e:
250            raise ValueError(f"Catalogue number '{cat}' not found in ABS catalogue: {e}") from e
251        except (ConnectionError, TimeoutError) as e:
252            raise ValueError(f"Network error retrieving catalogue for '{cat}': {e}") from e
253        except (ValueError, TypeError) as e:
254            raise ValueError(f"Invalid catalogue data for '{cat}': {e}") from e
255
256    if not url:
257        raise ValueError("_get_url(): no URL or valid catalogue number provided.")
258
259    return url
260
261
262def _process_zip(
263    abs_dict: dict[str, DataFrame],
264    zip_contents: bytes,
265    **args: Any,  # ReadArgs compatible
266) -> dict[str, DataFrame]:
267    """Read and process a ZIP file's contents from bytes."""
268
269    if len(zip_contents) == EMPTY_BYTES_LENGTH:
270        return abs_dict
271
272    with zipfile.ZipFile(BytesIO(zip_contents)) as zipped:
273        for element in zipped.infolist():
274            # get the zipfile into pandas
275            table_name = get_table_name(url=element.filename)
276            raw_bytes = zipped.read(element.filename)
277            abs_dict = _add_excel_bytes(abs_dict, raw_bytes, table_name, args)
278
279    return abs_dict
280
281
282def _add_zip(
283    abs_dict: dict[str, DataFrame],
284    link: str,
285    **args: Any,  # ReadArgs compatible
286) -> dict[str, DataFrame]:
287    """Read and process a ZIP file from a URL.
288
289    Downloads the ZIP file and iterates over its contents, calling
290    _add_excel_bytes() to extract Excel files and add their contents
291    to the DataFrames dictionary.
292
293    Args:
294        abs_dict: Dictionary to store extracted DataFrames
295        link: URL to the ZIP file
296        **args: Additional arguments passed to file retrieval functions
297
298    Returns:
299        dict[str, DataFrame]: Updated dictionary with new DataFrames from ZIP contents
300
301    """
302    zip_contents = get_file(link, **args)
303    return _process_zip(abs_dict, zip_contents, **args)
304
305
306def _add_excel_bytes(
307    abs_dict: dict[str, DataFrame],
308    raw_bytes: bytes,
309    name: str,
310    args: dict[str, Any],  # ReadArgs after processing
311) -> dict[str, DataFrame]:
312    """Convert Excel file bytes to DataFrames and add to dictionary.
313
314    Processes the bytes as an Excel file, converting each sheet to a DataFrame
315    and adding them to the dictionary using 'name---sheet_name' as keys.
316
317    Args:
318        abs_dict: Dictionary to store extracted DataFrames
319        raw_bytes: Bytes content of the Excel file
320        name: Base name for the Excel file
321        args: Dictionary of processing arguments
322
323    Returns:
324        dict[str, DataFrame]: Updated dictionary with new DataFrames from Excel sheets
325
326    """
327    verbose = args.get("verbose", False)
328
329    if len(raw_bytes) == EMPTY_BYTES_LENGTH:
330        if verbose:
331            print("_add_excel_bytes(): the raw bytes are empty.")
332        return abs_dict
333
334    # convert the raw bytes into a pandas ExcelFile
335    try:
336        excel = pd.ExcelFile(BytesIO(raw_bytes))
337    except (ValueError, TypeError) as e:
338        message = f"With {name}: could not convert raw bytes to ExcelFile.\n{e}"
339        print(message)
340        return abs_dict
341
342    # iterate over the sheets in the Excel file
343    for sheet_name in excel.sheet_names:
344        # grab and go - no treatment of the data
345        sheet_data = excel.parse(
346            sheet_name,
347        )
348        if len(sheet_data) == EMPTY_BYTES_LENGTH:
349            if verbose:
350                print(f"_add_excel_bytes(): sheet {sheet_name} in {name} is empty.")
351            continue
352        abs_dict[f"{name}{HYPHEN}{sheet_name}"] = sheet_data
353
354    # return the dictionary of DataFrames
355    return abs_dict
356
357
358def _add_excel(
359    abs_dict: dict[str, DataFrame],
360    link: str,
361    **args: Any,  # ReadArgs compatible
362) -> dict[str, DataFrame]:
363    """Read in an Excel file at the URL in the 'link' argument.
364
365    Pass those bytes to _add_excel_bytes() to put the contents
366    into the dictionary of DataFrames given by 'abs_dict'. When done,
367    return the dictionary of DataFrames.
368    """
369    name = get_table_name(link)
370
371    if name in abs_dict:
372        # table already in the dictionary
373        return abs_dict
374
375    raw_bytes = get_file(link, **args)
376
377    return _add_excel_bytes(abs_dict, raw_bytes, name, args)
378
379
380# --- main ---
381if __name__ == "__main__":
382
383    def simple_test() -> None:
384        """Test the grab_abs_url and grab_abs_zip functions."""
385
386        def test(name: str, **kwargs: Any) -> None:  # ReadArgs compatible
387            print(f"TEST -- {name}")
388            try:
389                data_dict = grab_abs_url(**kwargs)
390                print("---")
391                if not data_dict:
392                    print("PROBLEM -- No data found.")
393                else:
394                    print(f"SUCCESS -- Found {len(data_dict)} datasets: {list(data_dict.keys())}")
395            except Exception as e:  # pylint: disable=broad-except
396                print(f"ERROR -- Test failed with exception: {e}")
397            print(f"Done.\n{'=' * 20}\n")
398
399        def test_zip(zip_path: Path, **kwargs: Any) -> None:  # ReadArgs compatible
400            print(f"TEST -- grab_abs_zip() with {zip_path}")
401            try:
402                data_dict = grab_abs_zip(zip_path, **kwargs)
403                print("---")
404                if not data_dict:
405                    print("PROBLEM -- No data found.")
406                else:
407                    print(f"SUCCESS -- Found {len(data_dict)} datasets: {list(data_dict.keys())}")
408            except Exception as e:  # pylint: disable=broad-except
409                print(f"ERROR -- Test failed with exception: {e}")
410            print(f"Done.\n{'=' * 20}\n")
411
412        # 4 -- grab from url
413        _name = ".test-data/Qrtly-CPI-Time-series-spreadsheets-all.zip"
414        _zip_path = Path(_name)
415        test_zip(_zip_path, verbose=True)
416
417        # --- various grab_abs_url() tests
418
419        name = "1 -- grab a single zip file"
420        test(
421            name,
422            cat="6291.0.55.001",
423            single_zip_only="p6291_all_quarterly_spreadsheets",
424            get_zip=True,
425            verbose=True,
426        )
427
428        name = "2 -- grab a single Excel file"
429        test(
430            name,
431            cat="6202.0",
432            get_excel=True,
433            single_excel_only="6202001",
434            verbose=False,
435        )
436
437        # 3 -- grab the whole shebang
438        urls = [
439            "https://www.abs.gov.au/statistics/labour/jobs/weekly-payroll-jobs/latest-release",
440            "https://www.abs.gov.au/statistics/people/population/national-state-and-territory-population/dec-2023",
441        ]
442        for i, url_ in enumerate(urls):
443            name = f"3.{i} -- grab the whole shebang {url_}"
444            test(name, url=url_, verbose=True)
445
446    simple_test()
ZIP_EXTENSION = '.zip'
EXCEL_EXTENSION = '.xlsx'
FILE_EXTENSIONS_PROCESSING_ORDER = ('.zip', '.xlsx')
EMPTY_BYTES_LENGTH = 0
@cache
def grab_abs_url( cat: str = '', url: str = '', **kwargs: Unpack[readabs.ReadArgs]) -> dict[str, pandas.core.frame.DataFrame]:
37@cache  # minimise slowness with repeat business
38def grab_abs_url(
39    cat: str = "",
40    url: str = "",
41    **kwargs: Unpack[ReadArgs],
42) -> dict[str, DataFrame]:
43    """For a given URL, extract the data from the Excel and ZIP file links found on that page.
44
45    The data is returned as a dictionary of DataFrames. The Excel files are converted
46    into DataFrames, with each sheet in each Excel file becoming a separate DataFrame.
47    ZIP files are examined for Excel files, which are similarly converted into
48    DataFrames. The dictionary of DataFrames is returned.
49
50    The preferred mechanism for reading ABS data is to use the `read_abs_cat()`
51    or `read_abs_series()` functions. This function is provided for those
52    cases where the data is not available in the ABS catalogue, where the
53    data is not a timeseries, or where the user wants to extract data from
54    a specific ABS landingpage.
55
56
57    Parameters
58    ----------
59    url : str = ""
60        A URL for an ABS Catalogue landing page. Either a url or
61        a catalogue number must be provided. If both are provided, the
62        URL will be used.
63
64    cat : str = ""
65        An ABS Catalogue number. If provided, and the URL is not
66        provided, then the Catalogue number will be used to get the URL.
67
68    **kwargs : Unpack[ReadArgs]
69        Accepts the same keyword arguments as `read_abs_cat()`.
70
71    Returns
72    -------
73    dict[str, DataFrame]
74        A dictionary of DataFrames.
75
76    """
77    # check/get the keyword arguments
78    url = _get_url(url, cat)
79    check_kwargs(kwargs, "grab_abs_url")  # warn if invalid kwargs
80    args = get_args(kwargs, "grab_abs_url")  # get the valid kwargs
81    if verbose := args["verbose"]:
82        print(f"grab_abs_url(): {url=}, {args=}")
83
84    # get the URL links to the relevant ABS data files on that webpage
85    links = get_abs_links(url, **args)
86    if not links:
87        print(f"No data files found at URL: {url}")
88        return {}  # return an empty Dictionary
89
90    # read the data files into a dictionary of DataFrames
91    abs_dict: dict[str, DataFrame] = {}
92
93    # Process single file requests first
94    abs_dict = _process_single_files(abs_dict, links, args, verbose=verbose)
95    if abs_dict:  # If single file was found and processed, return it
96        return abs_dict
97
98    # Process all files based on configuration
99    return _process_all_files(abs_dict, links, args)

For a given URL, extract the data from the Excel and ZIP file links found on that page.

The data is returned as a dictionary of DataFrames. The Excel files are converted into DataFrames, with each sheet in each Excel file becoming a separate DataFrame. ZIP files are examined for Excel files, which are similarly converted into DataFrames. The dictionary of DataFrames is returned.

The preferred mechanism for reading ABS data is to use the read_abs_cat() or read_abs_series() functions. This function is provided for those cases where the data is not available in the ABS catalogue, where the data is not a timeseries, or where the user wants to extract data from a specific ABS landingpage.

Parameters

url : str = "" A URL for an ABS Catalogue landing page. Either a url or a catalogue number must be provided. If both are provided, the URL will be used.

cat : str = "" An ABS Catalogue number. If provided, and the URL is not provided, then the Catalogue number will be used to get the URL.

**kwargs : Unpack[ReadArgs] Accepts the same keyword arguments as read_abs_cat().

Returns

dict[str, DataFrame] A dictionary of DataFrames.

def grab_abs_zip( zip_path: pathlib._local.Path | str, **kwargs: Unpack[readabs.ReadArgs]) -> dict[str, pandas.core.frame.DataFrame]:
102def grab_abs_zip(
103    zip_path: Path | str,
104    **kwargs: Unpack[ReadArgs]
105) -> dict[str, DataFrame]:
106    """Grab and process a single ABS ZIP file from a file system location.
107
108    This is a convenience function that opens an ABS ZIP file from a local
109    filesystem path. Expect to be used rarely.
110
111    Parameters
112    ----------
113    zip_path : Path | str
114        The local filesystem path of the ABS ZIP file to open and process.
115
116    **kwargs : Unpack[ReadArgs]
117        Additional keyword arguments for file retrieval and processing.
118
119    Returns
120    -------
121    dict[str, DataFrame]
122        A dictionary of DataFrames extracted from the ZIP file.
123
124    """
125    check_kwargs(kwargs, "grab_abs_zip")  # warn if invalid kwargs
126    args = get_args(kwargs, "grab_abs_zip")  # get the valid kwargs
127
128    zp: Path = zip_path if isinstance(zip_path, Path) else Path(zip_path)
129    zip_bytes = zp.read_bytes()
130    abs_dict: dict[str, DataFrame] = {}
131    return _process_zip(abs_dict, zip_bytes, **args)

Grab and process a single ABS ZIP file from a file system location.

This is a convenience function that opens an ABS ZIP file from a local filesystem path. Expect to be used rarely.

Parameters

zip_path : Path | str The local filesystem path of the ABS ZIP file to open and process.

**kwargs : Unpack[ReadArgs] Additional keyword arguments for file retrieval and processing.

Returns

dict[str, DataFrame] A dictionary of DataFrames extracted from the ZIP file.