Skip to content

Resource Data Sources

Resource data sources load tabular data from CKAN resources. They are used automatically by the Resource View feature but can also be instantiated manually when you need to build a table backed by a resource file or the Datastore.

All file-based sources extend BaseResourceDataSource, which handles source path resolution (local upload vs. remote URL) and pluggable caching. The CKAN Datastore source (DataStoreDataSource) is separate and queries the Datastore API directly without any caching.


File-based sources

CsvUrlDataSource

Reads a CSV file from a local path or remote URL. The delimiter is detected automatically.

from ckanext.tables.shared import CsvUrlDataSource

# From a direct URL
source = CsvUrlDataSource(url="https://example.com/data.csv")

# From a CKAN resource dict (resolves upload path automatically)
source = CsvUrlDataSource(resource=resource_dict)
Source code in ckanext/tables/data_sources.py
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
class CsvUrlDataSource(BaseResourceDataSource):
    @staticmethod
    def _read_csv(path: str, **kwargs: Any) -> pd.DataFrame:
        """Read a local CSV file, using the fast C engine when possible.

        Sniffs the delimiter from the first few lines so the C engine can be
        used for the actual read; only falls back to pandas' own slower
        ``engine="python"`` auto-detection when sniffing fails.
        """
        delimiter = _sniff_csv_delimiter(path)
        if delimiter is not None:
            return pd.read_csv(path, sep=delimiter, **kwargs)

        return pd.read_csv(path, sep=None, engine="python", **kwargs)

    def fetch_dataframe(self) -> pd.DataFrame:
        try:
            with self._open_source() as path:
                return self._read_csv(path)
        except Exception:
            log.exception("Error fetching CSV from %s", self.get_source_path())
            return pd.DataFrame()

    def get_columns(self) -> list[str]:
        if self._load_from_cache():
            return list(self._df.columns) if self._df is not None else []

        try:
            with self._open_source() as path:
                df_preview = self._read_csv(path, nrows=0)
        except (OSError, ValueError, pd.errors.ParserError):
            log.exception("Failed fast CSV schema read, falling back to full load")
            self._ensure_loaded()
            return list(self._df.columns) if self._df is not None else []

        return list(df_preview.columns)

XlsxUrlDataSource

Reads the first sheet of an Excel workbook (.xlsx).

from ckanext.tables.shared import XlsxUrlDataSource

source = XlsxUrlDataSource(url="https://example.com/report.xlsx")
Source code in ckanext/tables/data_sources.py
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
class XlsxUrlDataSource(BaseResourceDataSource):
    def fetch_dataframe(self) -> pd.DataFrame:
        try:
            with self._open_source() as path:
                return pd.read_excel(path)
        except Exception:
            log.exception("Error fetching XLSX from %s", self.get_source_path())
            return pd.DataFrame()

    def get_columns(self) -> list[str]:
        if self._load_from_cache():
            return list(self._df.columns) if self._df is not None else []

        try:
            with self._open_source() as path:
                df_preview = pd.read_excel(path, nrows=0)
        except (OSError, ValueError):
            log.exception("Failed fast XLSX schema read, falling back to full load")
            self._ensure_loaded()
            return list(self._df.columns) if self._df is not None else []

        return list(df_preview.columns)

OrcUrlDataSource

Reads an Apache ORC columnar file.

from ckanext.tables.shared import OrcUrlDataSource

source = OrcUrlDataSource(url="https://example.com/data.orc")
Source code in ckanext/tables/data_sources.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
class OrcUrlDataSource(BaseResourceDataSource):
    def fetch_dataframe(self) -> pd.DataFrame:
        try:
            with self._open_source() as path:
                return pd.read_orc(path)
        except Exception:
            log.exception("Error fetching ORC from %s", self.get_source_path())
            return pd.DataFrame()

    def get_columns(self) -> list[str]:
        if self._load_from_cache():
            return list(self._df.columns) if self._df is not None else []

        try:
            with self._open_source() as path:
                schema_names = orc.ORCFile(path).schema.names
        except (OSError, ValueError, pa.ArrowInvalid):
            log.exception("Failed fast ORC schema read, falling back to full load")
            self._ensure_loaded()
            return list(self._df.columns) if self._df is not None else []

        return schema_names

ParquetUrlDataSource

Reads an Apache Parquet columnar file.

from ckanext.tables.shared import ParquetUrlDataSource

source = ParquetUrlDataSource(url="https://example.com/data.parquet")
Source code in ckanext/tables/data_sources.py
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
class ParquetUrlDataSource(BaseResourceDataSource):
    def fetch_dataframe(self) -> pd.DataFrame:
        try:
            with self._open_source() as path:
                return pd.read_parquet(path)
        except Exception:
            log.exception("Error fetching Parquet from %s", self.get_source_path())
            return pd.DataFrame()

    def get_columns(self) -> list[str]:
        if self._load_from_cache():
            return list(self._df.columns) if self._df is not None else []

        try:
            with self._open_source() as path:
                schema_names = pq.read_schema(path).names
        except (OSError, ValueError, pa.ArrowInvalid):
            log.exception("Failed fast Parquet schema read, falling back to full load")
            self._ensure_loaded()
            return list(self._df.columns) if self._df is not None else []

        return schema_names

FeatherUrlDataSource

Reads an Apache Arrow Feather file.

from ckanext.tables.shared import FeatherUrlDataSource

source = FeatherUrlDataSource(url="https://example.com/data.feather")
Source code in ckanext/tables/data_sources.py
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
class FeatherUrlDataSource(BaseResourceDataSource):
    def fetch_dataframe(self) -> pd.DataFrame:
        try:
            with self._open_source() as path:
                return pd.read_feather(path)
        except Exception:
            log.exception("Error fetching Feather from %s", self.get_source_path())
            return pd.DataFrame()

    def get_columns(self) -> list[str]:
        if self._load_from_cache():
            return list(self._df.columns) if self._df is not None else []

        try:
            with self._open_source() as path:
                schema_names = feather.read_table(path, columns=[]).schema.names
        except (OSError, ValueError, pa.ArrowInvalid):
            log.exception("Failed fast Feather schema read, falling back to full load")
            self._ensure_loaded()
            return list(self._df.columns) if self._df is not None else []

        return schema_names

Caching

All file-based sources inherit from BaseResourceDataSource, which caches the fetched DataFrame to avoid re-downloading on every request. The cache backend and TTL are controlled globally via configuration (see Configuration):

ckanext.tables.cache.backend = feather  # or "parquet", "pickle", "redis"
ckanext.tables.cache.cache_dir = /var/cache/ckanext-tables

You can override the backend or TTL per instance:

from ckanext.tables.shared import CsvUrlDataSource, RedisCacheBackend

source = CsvUrlDataSource(
    url="https://example.com/data.csv",
    cache_backend=RedisCacheBackend(),
    cache_ttl=120,  # seconds
)

A data source that loads resource data from a file or URL.

The cache backend defaults to the value of ckanext.tables.cache.backend ("feather" by default). Pass an explicit cache_backend to override for a specific instance.

Override cache_ttl on a subclass or pass it to the constructor to change the expiry.

PARAMETER DESCRIPTION
url

Direct URL to fetch data from.

TYPE: str | None DEFAULT: None

resource

The CKAN resource dictionary.

TYPE: dict[str, Any] | None DEFAULT: None

cache_backend

Override the configured cache backend for this instance.

TYPE: CacheBackend | None DEFAULT: None

cache_ttl

Override the default TTL (seconds) for this instance.

TYPE: int | None DEFAULT: None

Source code in ckanext/tables/data_sources.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
class BaseResourceDataSource(CachedDataSourceMixin, PandasDataSource):
    """A data source that loads resource data from a file or URL.

    The cache backend defaults to the value of ``ckanext.tables.cache.backend``
    (``"feather"`` by default). Pass an explicit *cache_backend* to
    override for a specific instance.

    Override ``cache_ttl`` on a subclass or pass it to the constructor to
    change the expiry.

    Args:
        url: Direct URL to fetch data from.
        resource: The CKAN resource dictionary.
        cache_backend: Override the configured cache backend for this instance.
        cache_ttl: Override the default TTL (seconds) for this instance.
    """

    def __init__(
        self,
        url: str | None = None,
        resource: dict[str, Any] | None = None,
        cache_backend: CacheBackend | None = None,
        cache_ttl: int | None = None,
    ):
        super().__init__()

        if not url and not resource:
            raise ValueError("Either url or resource_id must be provided")

        self.url = url
        self.resource = resource
        self._source_path: str = ""
        self._upload_storage: Any | None = None
        self.cache_backend = cache_backend if cache_backend is not None else get_cache_backend()
        self.cache_ttl = cache_ttl if cache_ttl is not None else get_cache_ttl()

    def get_cache_key(self) -> str:
        return f"resource-{self.resource['id']}" if self.resource else f"url-{self.url}"

    def get_source_path(self) -> str:
        if self._source_path:
            return self._source_path

        if self.resource:
            try:
                if self.resource.get("url_type") == "upload":
                    upload = uploader.get_resource_uploader(self.resource)
                    self._source_path = upload.get_path(self.resource["id"])

                    # On CKAN 2.12+ with file-keeper storage, get_path() returns a
                    # storage-relative Location, not a filesystem path — stash the
                    # storage so _open_source() reads through its API instead of
                    # handing that relative string straight to pandas.
                    fk_upload_cls = getattr(uploader, "FKResourceUpload", None)
                    if fk_upload_cls is not None and isinstance(upload, fk_upload_cls):
                        self._upload_storage = getattr(upload, "storage", None)

                    return self._source_path

                if self.resource.get("url"):
                    self._source_path = self._ensure_remote_url(self.resource["url"])
                    return self._source_path

            except (OSError, TypeError, tk.ValidationError, tk.ObjectNotFound):
                log.warning(
                    "Failed to resolve path for resource %s, falling back to provided url",
                    self.resource.get("id"),
                    exc_info=True,
                )

        if self.url:
            self._source_path = self._ensure_remote_url(self.url)
            return self._source_path

        raise ValueError("Could not resolve source path")

    @staticmethod
    def _ensure_remote_url(url: str) -> str:
        """Reject anything that is not a well-formed http(s) URL.

        These readers (``pd.read_csv``, ``read_excel``, ``read_parquet``,
        ``read_feather``, ``read_orc``) all accept local filesystem paths and
        ``file://`` URLs, not just http(s) URLs. A resource's ``url`` (or a
        resource view's ``file_url``) is set by dataset editors, not
        sysadmins, so without this check anyone able to edit a resource could
        make the server read and return the contents of an arbitrary local
        file (e.g. ``ckan.ini``, secrets, other users' uploads).
        """
        parsed = urlparse(url)

        if parsed.scheme not in _ALLOWED_URL_SCHEMES or not parsed.netloc:
            raise ValueError(f"Unsupported or unsafe URL: {url!r}. Only http(s) URLs are allowed.")

        return url

    @contextlib.contextmanager
    def _open_source(self) -> Iterator[str]:
        """Yield a local filesystem path for the source.

        A remote http(s) URL is downloaded to a guarded temporary file first
        (timeouts, size cap, SSRF checks — see :mod:`ckanext.tables.net`); a
        file-keeper-backed upload is read via its storage's real path when
        that storage is on local disk, or streamed into a temporary file
        otherwise (e.g. S3, where there's no local path to read from); a
        plain local path (resolved via the legacy CKAN uploader) is yielded
        unchanged. Any temp file created along the way is removed once the
        caller is done.
        """
        source = self.get_source_path()

        if source.startswith(("http://", "https://")):
            with fetch_remote_file(source) as local_path:
                yield local_path
        elif self._upload_storage is not None:
            with self._read_fk_upload(self._upload_storage, source) as local_path:
                yield local_path
        else:
            yield source

    @staticmethod
    @contextlib.contextmanager
    def _read_fk_upload(storage: Any, location: str) -> Iterator[str]:
        """Yield a local path for a file-keeper storage object.

        ``storage.full_path()`` is a cheap, generic ``join(base, location)`` —
        it doesn't confirm the storage is actually on local disk, so it's only
        trusted when the result exists on disk; otherwise (e.g. an S3-backed
        storage) fall back to streaming the content into a temp file.
        """
        # Resolve (and fully exit) this before yielding — a caller's exception while
        # using the yielded path must propagate normally, not get swallowed here.
        local_path = None
        with contextlib.suppress(Exception):
            candidate = storage.full_path(location)
            if os.path.isfile(candidate):
                local_path = candidate

        if local_path is not None:
            yield local_path
            return

        from ckan.lib import files  # noqa: PLC0415 — only importable on CKAN 2.12+

        fd, tmp_path = tempfile.mkstemp(prefix="ckanext-tables-")

        try:
            with os.fdopen(fd, "wb") as tmp_file:
                for chunk in storage.stream(files.FileData(files.Location(location))):
                    tmp_file.write(chunk)
            yield tmp_path
        finally:
            with contextlib.suppress(FileNotFoundError):
                os.remove(tmp_path)

DataStoreDataSource

Queries the CKAN Datastore API directly. This source is used automatically when a resource has datastore_active = True. It does not use any caching, as the data is already stored in the database.

from ckanext.tables.shared import DataStoreDataSource

source = DataStoreDataSource(resource_id="<resource-id>")

Filtering, sorting, and pagination are translated into datastore_search parameters:

  • = → exact match filter
  • like → full-text search (partial word match via PostgreSQL FTS with :*)
  • Other comparison operators are not supported by datastore_search and are silently ignored.

Note

DataStoreDataSource requires the datastore plugin to be enabled in CKAN. If it is not active, all methods return empty results gracefully.

A data source that fetches records directly from the CKAN Datastore.

Source code in ckanext/tables/data_sources.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
class DataStoreDataSource(BaseDataSource):
    """A data source that fetches records directly from the CKAN Datastore."""

    def __init__(self, resource_id: str):
        self.resource_id = resource_id

        self._filters: dict[str, Any] = {}
        self._q: dict[str, str] = {}
        self._sort: str | None = None
        self._limit: int | None = None
        self._offset: int | None = None

        self._datastore_enabled = "datastore" in tk.g.plugins

    def filter(self, filters: list[FilterItem]) -> Self:
        self._filters = {}
        self._q = {}

        for filter_item in filters:
            if filter_item.operator == "=":
                self._filters[filter_item.field] = filter_item.value
            elif filter_item.operator == "like":
                # replace non-alphanumeric characters (except dots) with FTS wildcard (_)
                v = str(filter_item.value)
                v = re.sub(r"[^\w\-\.]+", "_", v)
                # append ':*' so we can do partial FTS searches
                self._q[filter_item.field] = v + ":*"
            # Other operators like <, > might require datastore_search_sql
            # which is more complex, so we skip them unless necessary.
        return self

    def sort(self, sort_by: str | None, sort_order: str | None) -> Self:
        self._sort = f"{sort_by} {sort_order or 'asc'}" if sort_by else None
        return self

    def paginate(self, page: int, size: int) -> Self:
        if page and size:
            self._limit = size
            self._offset = (page - 1) * size
        return self

    def all(self) -> list[dict[str, Any]]:
        if not self._datastore_enabled:
            return []

        data_dict: dict[str, Any] = {"resource_id": self.resource_id}

        if self._filters:
            data_dict["filters"] = self._filters

        if self._q:
            data_dict["q"] = json.dumps(self._q)
            data_dict["plain"] = False
            data_dict["language"] = "simple"

        if self._sort:
            data_dict["sort"] = self._sort

        if self._limit is not None:
            data_dict["limit"] = self._limit

        if self._offset is not None:
            data_dict["offset"] = self._offset

        try:
            result = tk.get_action("datastore_search")({}, data_dict)
            return result.get("records", [])
        except (tk.ObjectNotFound, tk.NotAuthorized, tk.ValidationError):
            # ValidationError covers an invalid sort/filter field name — both come
            # straight from unvalidated request params (see filter()/sort() above).
            return []

    def count(self) -> int:
        if not self._datastore_enabled:
            return 0

        data_dict: dict[str, Any] = {"resource_id": self.resource_id, "limit": 0}

        if self._filters:
            data_dict["filters"] = self._filters

        if self._q:
            data_dict["q"] = json.dumps(self._q)
            data_dict["plain"] = False
            data_dict["language"] = "simple"

        try:
            result = tk.get_action("datastore_search")({}, data_dict)
            return result.get("total", 0)
        except (tk.ObjectNotFound, tk.NotAuthorized, tk.ValidationError):
            return 0

    def get_columns(self) -> list[str]:
        if not self._datastore_enabled:
            return []

        data_dict = {"resource_id": self.resource_id, "limit": 0}

        try:
            result = tk.get_action("datastore_search")({}, data_dict)
            return [f["id"] for f in result.get("fields", []) if f["id"] != "_id"]
        except (tk.ObjectNotFound, tk.NotAuthorized):
            return []