tesorotools-python 0.0.38__tar.gz → 0.0.40__tar.gz

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.
Files changed (70) hide show
  1. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/pyproject.toml +1 -1
  3. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/__init__.py +10 -2
  4. tesorotools_python-0.0.40/src/tesorotools/_build_context.py +59 -0
  5. tesorotools_python-0.0.40/src/tesorotools/orchestration.py +100 -0
  6. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/providers/__init__.py +7 -2
  7. tesorotools_python-0.0.40/src/tesorotools/providers/base.py +213 -0
  8. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/providers/bde.py +3 -1
  9. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/providers/ecb.py +3 -1
  10. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/table.py +35 -52
  11. tesorotools_python-0.0.38/src/tesorotools/_build_context.py +0 -49
  12. tesorotools_python-0.0.38/src/tesorotools/providers/base.py +0 -114
  13. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/.gitignore +0 -0
  14. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/_registry.py +0 -0
  15. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/artists/__init__.py +0 -0
  16. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/artists/_common.py +0 -0
  17. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/artists/barh_plot.py +0 -0
  18. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/artists/line_plot.py +0 -0
  19. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/artists/stacked.py +0 -0
  20. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/artists/type_curve.py +0 -0
  21. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/README.md +0 -0
  22. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  23. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  24. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  25. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  26. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  27. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  28. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  29. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  30. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/fonts/README.md +0 -0
  31. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/plots.yaml +0 -0
  32. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  33. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/data_sources/__init__.py +0 -0
  34. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/data_sources/debug.py +0 -0
  35. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/database/__init__.py +0 -0
  36. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/database/local.py +0 -0
  37. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/database/push.py +0 -0
  38. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/database/shared.py +0 -0
  39. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/dependencies/__init__.py +0 -0
  40. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/dependencies/node.py +0 -0
  41. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/dependencies/resolution.py +0 -0
  42. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/driver.py +0 -0
  43. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/manifest.py +0 -0
  44. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/offsets/__init__.py +0 -0
  45. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/offsets/offsets.py +0 -0
  46. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/offsets/outliers.py +0 -0
  47. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/pipeline/__init__.py +0 -0
  48. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/pipeline/diagnose.py +0 -0
  49. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/pipeline/engine.py +0 -0
  50. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/pipeline/rules.py +0 -0
  51. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/py.typed +0 -0
  52. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/__init__.py +0 -0
  53. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/__init__.py +0 -0
  54. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/content.py +0 -0
  55. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/images.py +0 -0
  56. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/section.py +0 -0
  57. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/subtitle.py +0 -0
  58. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/text.py +0 -0
  59. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/content/title.py +0 -0
  60. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/render/report.py +0 -0
  61. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/testing/__init__.py +0 -0
  62. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/testing/compare.py +0 -0
  63. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/__init__.py +0 -0
  64. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/config.py +0 -0
  65. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/format.py +0 -0
  66. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/globals.py +0 -0
  67. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/matplotlib.py +0 -0
  68. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/series.py +0 -0
  69. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/shortcuts.py +0 -0
  70. {tesorotools_python-0.0.38 → tesorotools_python-0.0.40}/src/tesorotools/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.38
3
+ Version: 0.0.40
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tesorotools-python"
3
3
  requires-python = ">=3.13"
4
- version = "0.0.38"
4
+ version = "0.0.40"
5
5
  dependencies = [
6
6
  # database and ORM
7
7
  "psycopg[binary]>=3.1",
@@ -51,7 +51,12 @@ from tesorotools.artists import (
51
51
  StackedBarPlot,
52
52
  TypeCurve,
53
53
  )
54
- from tesorotools.providers.base import DataProvider, DataProviderProtocol
54
+ from tesorotools.orchestration import CompositeRegistry, iter_contexts
55
+ from tesorotools.providers.base import (
56
+ DataProvider,
57
+ RegistryProtocol,
58
+ bootstrap_providers,
59
+ )
55
60
  from tesorotools.render import (
56
61
  Content,
57
62
  Image,
@@ -91,9 +96,9 @@ __all__ = [
91
96
  "Artist",
92
97
  "BdeProvider",
93
98
  "BuildContext",
99
+ "CompositeRegistry",
94
100
  "Content",
95
101
  "DataProvider",
96
- "DataProviderProtocol",
97
102
  "EcbProvider",
98
103
  "Format",
99
104
  "HorizontalBarChart",
@@ -101,6 +106,7 @@ __all__ = [
101
106
  "Images",
102
107
  "Legend",
103
108
  "LinePlot",
109
+ "RegistryProtocol",
104
110
  "Report",
105
111
  "Section",
106
112
  "StackedAreaPlot",
@@ -114,9 +120,11 @@ __all__ = [
114
120
  "all_artists",
115
121
  "all_providers",
116
122
  "all_tags",
123
+ "bootstrap_providers",
117
124
  "get_artist",
118
125
  "get_provider",
119
126
  "iter_artists",
127
+ "iter_contexts",
120
128
  "iter_providers",
121
129
  "iter_tags",
122
130
  "register_artist",
@@ -0,0 +1,59 @@
1
+ """Shared context object for ``build_for`` provider factories.
2
+
3
+ Each provider class has a :meth:`tesorotools.DataProvider.build_for`
4
+ classmethod that decides whether to instantiate itself based
5
+ on the registry and runtime context. ``BuildContext`` is the
6
+ shared input to that method.
7
+
8
+ The base fields cover the cross-project minimum:
9
+
10
+ * ``registry`` -- the catalog the orchestrator uses to decide
11
+ which series each provider must serve. Typed as
12
+ :class:`tesorotools.providers.base.RegistryProtocol` so
13
+ that ``build_for`` bodies are statically checked.
14
+ * ``consumer`` -- a free-form string identifying the calling
15
+ workflow (e.g. ``"diary"``, ``"weekly"``). ``build_for``
16
+ implementations dispatch on it via
17
+ ``registry.all_cids_for_provider(ctx.consumer, ...)``.
18
+ * ``mock`` / ``mock_seed`` -- toggle for deterministic
19
+ fixtures during tests and demos.
20
+ * ``provider_config`` -- per-provider knobs keyed by
21
+ :attr:`tesorotools.DataProvider.PROVIDER_NAME`. Lets each
22
+ provider read its own sub-config (API keys, fallback
23
+ policy, historical file paths, ...) without polluting the
24
+ shared context surface.
25
+
26
+ Cross-provider settings that genuinely belong to the context
27
+ (a project-wide cache directory, say) can still go on a
28
+ ``BuildContext`` subclass:
29
+
30
+ .. code-block:: python
31
+
32
+ @dataclass(frozen=True)
33
+ class DiaryBuildContext(BuildContext):
34
+ cache_dir: Path = Path(".cache")
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Mapping
40
+ from dataclasses import dataclass, field
41
+ from typing import TYPE_CHECKING, Any
42
+
43
+ if TYPE_CHECKING:
44
+ from tesorotools.providers.base import RegistryProtocol
45
+
46
+
47
+ def _empty_provider_config() -> dict[str, Mapping[str, Any]]:
48
+ return {}
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class BuildContext:
53
+ registry: "RegistryProtocol"
54
+ consumer: str
55
+ mock: bool = False
56
+ mock_seed: int | None = None
57
+ provider_config: Mapping[str, Mapping[str, Any]] = field(
58
+ default_factory=_empty_provider_config
59
+ )
@@ -0,0 +1,100 @@
1
+ """Optional orchestration helpers for multi-consumer setups.
2
+
3
+ The single-consumer flow is already short with the core
4
+ contract (one ``BuildContext``, one
5
+ :func:`tesorotools.providers.base.bootstrap_providers` call).
6
+ Projects that fan out across several consumers and several
7
+ registries end up rewriting the same wiring: pick a registry
8
+ list, wrap it in a composite when there is more than one,
9
+ build the context, populate ``provider_config``, call
10
+ ``bootstrap_providers``. The two helpers below collapse that
11
+ to a one-liner per consumer.
12
+
13
+ These helpers are deliberately optional. A project that
14
+ ignores them is unaffected; a project building its second or
15
+ third consumer can pull them in without rewriting any
16
+ provider.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Iterator, Mapping
22
+ from typing import Any
23
+
24
+ from tesorotools._build_context import BuildContext
25
+ from tesorotools.providers.base import RegistryProtocol
26
+
27
+
28
+ class CompositeRegistry:
29
+ """Concatenate several :class:`RegistryProtocol` instances
30
+ as if they were one.
31
+
32
+ Iteration order over *parts* is the lookup order for
33
+ :meth:`get_provider_meta` (first match wins). For
34
+ :meth:`all_cids_for_provider` the result is the
35
+ concatenation; if the same canonical ID appears in two
36
+ parts, both are returned -- duplicates should be resolved
37
+ upstream by the consumer's registry layout, not here.
38
+
39
+ The class itself satisfies :class:`RegistryProtocol`, so
40
+ callers cannot tell whether they are talking to one
41
+ registry or several.
42
+ """
43
+
44
+ def __init__(self, parts: list[RegistryProtocol]) -> None:
45
+ self._parts = parts
46
+
47
+ def all_cids_for_provider(
48
+ self, consumer: str, provider_name: str
49
+ ) -> list[str]:
50
+ return [
51
+ cid
52
+ for r in self._parts
53
+ for cid in r.all_cids_for_provider(consumer, provider_name)
54
+ ]
55
+
56
+ def get_provider_meta(
57
+ self, canonical_id: str, provider_name: str
58
+ ) -> dict[str, Any]:
59
+ for r in self._parts:
60
+ try:
61
+ return r.get_provider_meta(canonical_id, provider_name)
62
+ except KeyError:
63
+ continue
64
+ raise KeyError(f"unknown cid {canonical_id!r}")
65
+
66
+
67
+ def iter_contexts(
68
+ consumer_registries: Mapping[str, list[RegistryProtocol]],
69
+ provider_config_for: Callable[
70
+ [str], Mapping[str, Mapping[str, Any]]
71
+ ] = lambda _: {},
72
+ *,
73
+ mock: bool = False,
74
+ mock_seed: int | None = None,
75
+ ) -> Iterator[BuildContext]:
76
+ """Yield one :class:`BuildContext` per consumer.
77
+
78
+ Multi-registry consumers are wrapped in
79
+ :class:`CompositeRegistry` transparently; single-registry
80
+ consumers receive their registry unchanged so that
81
+ ``ctx.registry is registries[0]`` holds when the consumer
82
+ only has one.
83
+
84
+ *provider_config_for* is called once per consumer and its
85
+ result is attached as :attr:`BuildContext.provider_config`.
86
+ The default returns an empty mapping for every consumer.
87
+ """
88
+ for consumer, registries in consumer_registries.items():
89
+ registry: RegistryProtocol = (
90
+ registries[0]
91
+ if len(registries) == 1
92
+ else CompositeRegistry(registries)
93
+ )
94
+ yield BuildContext(
95
+ registry=registry,
96
+ consumer=consumer,
97
+ mock=mock,
98
+ mock_seed=mock_seed,
99
+ provider_config=provider_config_for(consumer),
100
+ )
@@ -9,7 +9,11 @@ extras.
9
9
 
10
10
  from typing import TYPE_CHECKING, Any
11
11
 
12
- from tesorotools.providers.base import DataProvider, DataProviderProtocol
12
+ from tesorotools.providers.base import (
13
+ DataProvider,
14
+ RegistryProtocol,
15
+ bootstrap_providers,
16
+ )
13
17
 
14
18
  if TYPE_CHECKING:
15
19
  from tesorotools.providers.bde import BdeProvider
@@ -18,8 +22,9 @@ if TYPE_CHECKING:
18
22
  __all__ = [
19
23
  "BdeProvider",
20
24
  "DataProvider",
21
- "DataProviderProtocol",
22
25
  "EcbProvider",
26
+ "RegistryProtocol",
27
+ "bootstrap_providers",
23
28
  ]
24
29
 
25
30
 
@@ -0,0 +1,213 @@
1
+ """Abstract base class for data providers and the contract
2
+ glue around it.
3
+
4
+ A provider knows how to download time series from a specific
5
+ external source (Bank of Spain, Eikon, ECB, etc.). It does
6
+ not know anything about local storage, incremental updates,
7
+ or presentation -- those concerns belong elsewhere.
8
+
9
+ Every provider returns data in the same shape: a DataFrame
10
+ with a DatetimeIndex and one column per series code.
11
+
12
+ In addition to ``fetch`` and ``is_available``, this module
13
+ formalises the construction contract that consumer projects
14
+ were already converging on informally:
15
+
16
+ * :attr:`DataProvider.PROVIDER_NAME` -- the stable identifier
17
+ every consumer ends up adding to its provider classes.
18
+ Required at definition time; ``__init_subclass__`` raises
19
+ ``TypeError`` if a concrete subclass forgets it.
20
+ * :class:`RegistryProtocol` -- the two methods consumers
21
+ uniformly call on whatever catalog they pass through
22
+ ``BuildContext.registry``. Formalising them lets
23
+ ``build_for`` be statically typed.
24
+ * :meth:`DataProvider.build_for` -- a default factory that
25
+ covers the simple "no constructor args, single instance"
26
+ case and skips construction when the registry has no
27
+ canonical IDs for this provider in the current consumer.
28
+ Providers that need configuration, multiple instances, or
29
+ fallback logic override it.
30
+ * :func:`bootstrap_providers` -- the trivial loop every
31
+ consumer ends up writing on top of ``build_for``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from abc import ABC, abstractmethod
37
+ from collections.abc import Iterable
38
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol
39
+
40
+ import pandas as pd
41
+
42
+ if TYPE_CHECKING:
43
+ from tesorotools._build_context import BuildContext
44
+
45
+
46
+ class RegistryProtocol(Protocol):
47
+ """Structural type for whatever catalog a project passes
48
+ through :attr:`BuildContext.registry`.
49
+
50
+ Only the two methods :meth:`DataProvider.build_for` reads
51
+ are formalised. Projects with idiosyncratic registries
52
+ (composite layouts, different storage backends) keep
53
+ working as long as they expose these two methods.
54
+ """
55
+
56
+ def all_cids_for_provider(
57
+ self, consumer: str, provider_name: str
58
+ ) -> list[str]:
59
+ """Return every canonical ID this provider must serve
60
+ for *consumer*. Empty list means "this provider is
61
+ not needed for this consumer"."""
62
+ ...
63
+
64
+ def get_provider_meta(
65
+ self, canonical_id: str, provider_name: str
66
+ ) -> dict[str, Any]:
67
+ """Return the per-instrument metadata block this
68
+ provider expects for *canonical_id*."""
69
+ ...
70
+
71
+
72
+ class DataProvider(ABC):
73
+ """Base class for all data providers.
74
+
75
+ Subclasses must:
76
+
77
+ * set :attr:`PROVIDER_NAME` (enforced at class-definition
78
+ time by :meth:`__init_subclass__`);
79
+ * implement :meth:`fetch` and :meth:`is_available`
80
+ (enforced at instantiation time by ``ABC``).
81
+
82
+ They MAY override :meth:`build_for` to customise
83
+ construction (multiple instances, constructor arguments,
84
+ fallback logic).
85
+ """
86
+
87
+ #: Stable, lowercase identifier under which this provider
88
+ #: registers in the global ``register_provider`` index.
89
+ #: Concrete subclasses MUST set it; abstract intermediates
90
+ #: may leave it unset.
91
+ PROVIDER_NAME: ClassVar[str]
92
+
93
+ def __init_subclass__(cls, **kwargs: Any) -> None:
94
+ super().__init_subclass__(**kwargs)
95
+ # ``ABCMeta.__new__`` populates ``__abstractmethods__``
96
+ # *after* ``__init_subclass__`` runs, so we compute
97
+ # the set ourselves to detect intermediate ABCs that
98
+ # still have abstract methods. Those will never be
99
+ # instantiated; forcing them to declare
100
+ # ``PROVIDER_NAME`` would be noise.
101
+ if _has_abstract_methods(cls):
102
+ return
103
+ if "PROVIDER_NAME" not in cls.__dict__ and not any(
104
+ "PROVIDER_NAME" in base.__dict__ for base in cls.__mro__[1:-1]
105
+ ):
106
+ raise TypeError(
107
+ f"{cls.__name__} must define a class-level "
108
+ "``PROVIDER_NAME: ClassVar[str]`` "
109
+ "(required by tesorotools.DataProvider)."
110
+ )
111
+
112
+ @abstractmethod
113
+ def fetch(
114
+ self,
115
+ codes: list[str],
116
+ start: str | None = None,
117
+ end: str | None = None,
118
+ ) -> pd.DataFrame:
119
+ """Download series data for a date range.
120
+
121
+ Parameters
122
+ ----------
123
+ codes
124
+ Provider-specific series codes to download.
125
+ start
126
+ Start date as ISO string (e.g. ``"2025-01-01"``).
127
+ If ``None``, the provider decides the earliest
128
+ date (typically the full available history).
129
+ end
130
+ End date as ISO string. If ``None``, the
131
+ provider fetches up to the latest available data.
132
+
133
+ Returns
134
+ -------
135
+ pd.DataFrame
136
+ - Index: ``DatetimeIndex`` named ``"date"``,
137
+ tz-naive, normalized to midnight.
138
+ - Columns: one per code in ``codes``.
139
+ - Values: ``float64``, with ``NaN`` for missing
140
+ observations.
141
+ """
142
+ ...
143
+
144
+ @abstractmethod
145
+ def is_available(self) -> bool:
146
+ """Check whether this provider can serve data.
147
+
148
+ Returns ``True`` if the external service is reachable
149
+ and ready. Useful for fail-fast checks before
150
+ starting a long download, or for choosing between
151
+ a primary provider and a fallback.
152
+ """
153
+ ...
154
+
155
+ @classmethod
156
+ def build_for(cls, ctx: "BuildContext") -> dict[str, "DataProvider"]:
157
+ """Default factory used by :func:`bootstrap_providers`.
158
+
159
+ Skips construction entirely if no canonical ID asks
160
+ for this provider in *ctx.consumer*; otherwise
161
+ instantiates with no arguments and registers under
162
+ :attr:`PROVIDER_NAME`.
163
+
164
+ Override when the provider needs constructor
165
+ arguments, returns multiple instances, or has
166
+ fallback logic. Calling ``cls()`` on a class with
167
+ required ``__init__`` arguments raises ``TypeError``;
168
+ that is intentional -- a provider that needs
169
+ configuration is forced to override this method.
170
+ """
171
+ if not ctx.registry.all_cids_for_provider(
172
+ ctx.consumer, cls.PROVIDER_NAME
173
+ ):
174
+ return {}
175
+ return {cls.PROVIDER_NAME: cls()}
176
+
177
+
178
+ def _has_abstract_methods(cls: type) -> bool:
179
+ """Return True if *cls* still has abstract methods.
180
+
181
+ ``ABCMeta`` populates ``__abstractmethods__`` after
182
+ ``__init_subclass__`` runs, so we compute the abstract
183
+ set manually by walking the MRO bottom-up. An abstract
184
+ method introduced higher in the MRO is "discharged" when
185
+ a lower class overrides it with a concrete implementation.
186
+ """
187
+ abstracts: set[str] = set()
188
+ for base in reversed(cls.__mro__):
189
+ for name, value in vars(base).items():
190
+ if getattr(value, "__isabstractmethod__", False):
191
+ abstracts.add(name)
192
+ elif name in abstracts:
193
+ abstracts.discard(name)
194
+ return bool(abstracts)
195
+
196
+
197
+ def bootstrap_providers(
198
+ ctx: "BuildContext",
199
+ classes: Iterable[type[DataProvider]],
200
+ ) -> dict[str, DataProvider]:
201
+ """Run :meth:`DataProvider.build_for` over *classes* and
202
+ merge the results.
203
+
204
+ Iteration order over *classes* is preserved for log
205
+ readability; behaviour does not depend on it. When two
206
+ classes return the same key, the later one wins, matching
207
+ plain ``dict.update`` semantics; that is the consumer's
208
+ problem to avoid.
209
+ """
210
+ out: dict[str, DataProvider] = {}
211
+ for cls in classes:
212
+ out.update(cls.build_for(ctx))
213
+ return out
@@ -30,7 +30,7 @@ Notes
30
30
  from __future__ import annotations
31
31
 
32
32
  import logging
33
- from typing import TypedDict, cast
33
+ from typing import ClassVar, TypedDict, cast
34
34
 
35
35
  import pandas as pd
36
36
  import requests
@@ -72,6 +72,8 @@ class BdeProvider(DataProvider):
72
72
  Maximum seconds to wait per HTTP request.
73
73
  """
74
74
 
75
+ PROVIDER_NAME: ClassVar[str] = "bde"
76
+
75
77
  def __init__(
76
78
  self,
77
79
  *,
@@ -36,7 +36,7 @@ from __future__ import annotations
36
36
  import csv
37
37
  import io
38
38
  import logging
39
- from typing import cast
39
+ from typing import ClassVar, cast
40
40
 
41
41
  import pandas as pd
42
42
  import requests
@@ -106,6 +106,8 @@ class EcbProvider(DataProvider):
106
106
  tests or for custom retry policies.
107
107
  """
108
108
 
109
+ PROVIDER_NAME: ClassVar[str] = "ecb"
110
+
109
111
  def __init__(
110
112
  self,
111
113
  *,
@@ -15,6 +15,7 @@ from docx.oxml.ns import nsdecls, qn
15
15
  from docx.shared import Inches, Pt, RGBColor
16
16
  from docx.table import Table as TableDocx
17
17
  from docx.table import _Cell as TableCell
18
+ from docx.table import _Row as TableRow
18
19
  from yaml import MappingNode
19
20
 
20
21
  from tesorotools.utils.config import read_config
@@ -49,46 +50,6 @@ TEXTO_TABLAS: int = 9
49
50
  CENTER = WD_ALIGN_PARAGRAPH.CENTER
50
51
 
51
52
 
52
- def _set_cell_border(cell: TableCell, **kwargs: Any) -> None:
53
- """
54
- Set cell`s border
55
- Usage:
56
-
57
- set_cell_border(
58
- cell,
59
- top={"sz": 12, "val": "single", "color": "#FF0000", "space": "0"},
60
- bottom={"sz": 12, "color": "#00FF00", "val": "single"},
61
- start={"sz": 24, "val": "dashed", "shadow": "true"},
62
- end={"sz": 12, "val": "dashed"},
63
- )
64
- """
65
- tc = cell._tc
66
- tcPr = tc.get_or_add_tcPr()
67
-
68
- # check for tag existence, if none found, create one
69
- tcBorders: Any = tcPr.first_child_found_in("w:tcBorders") # type: ignore[reportUnknownMemberType]
70
- if tcBorders is None:
71
- tcBorders = OxmlElement("w:tcBorders") # type: ignore[reportUnknownVariableType]
72
- tcPr.append(tcBorders) # type: ignore[reportUnknownMemberType]
73
-
74
- # list over all available tags
75
- for edge in ("start", "top", "end", "bottom", "insideH", "insideV"):
76
- edge_data: Any = kwargs.get(edge)
77
- if edge_data:
78
- tag = "w:{}".format(edge)
79
-
80
- # check for tag existence, if none found, then create one
81
- element: Any = tcBorders.find(qn(tag)) # type: ignore[reportUnknownMemberType]
82
- if element is None:
83
- element = OxmlElement(tag) # type: ignore[reportUnknownVariableType]
84
- tcBorders.append(element) # type: ignore[reportUnknownMemberType]
85
-
86
- # looks like order of attributes is important
87
- for key in ["sz", "val", "color", "space", "shadow"]:
88
- if key in edge_data:
89
- element.set(qn("w:{}".format(key)), str(edge_data[key])) # type: ignore[reportUnknownMemberType]
90
-
91
-
92
53
  def _style_horizontal_blocks_header(cell: TableCell) -> None:
93
54
  cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
94
55
  cell.paragraphs[0].runs[0].font.size = Pt(12)
@@ -192,25 +153,47 @@ def _separate_blocks(
192
153
  "got a flat Index. Set block_sep=False or wrap rows in a "
193
154
  "MultiIndex with the block name as level 0."
194
155
  )
156
+ _disable_implicit_last_row(table_docx)
195
157
  blocks: list[str] = list(index.get_level_values(level=0).unique())
196
158
  previous_rows: int = 0
197
159
  for block in blocks[:-1]:
198
160
  block_size: int = len(index[index.get_level_values(level=0) == block])
199
- for cell in table_docx.rows[block_size + previous_rows].cells:
200
- _separate_cell(cell)
161
+ _separate_row(table_docx.rows[block_size + previous_rows])
201
162
  previous_rows += block_size
202
163
 
203
164
 
204
- def _separate_cell(cell: TableCell) -> None:
205
- _set_cell_border(
206
- cell,
207
- bottom={
208
- "sz": 1,
209
- "val": "double",
210
- "color": "#000000",
211
- "space": 2,
212
- },
213
- )
165
+ def _separate_row(row: TableRow) -> None:
166
+ """Mark *row* as ``lastRow`` via conditional-formatting reference.
167
+
168
+ The visible separator border belongs to the active table style
169
+ (``<w:tblStylePr w:type="lastRow">`` in ``styles.xml``). Emitting
170
+ only ``<w:cnfStyle>`` on ``<w:trPr>`` keeps direct formatting out of
171
+ ``<w:tcPr>``, which is the construct Word's co-authoring merge
172
+ engine corrupts in shared OneDrive/SharePoint documents. The
173
+ consumer's ``template.docx`` must define the ``lastRow`` conditional
174
+ formatting on the table style referenced by ``plots.yaml``.
175
+ """
176
+ trPr: Any = row._tr.get_or_add_trPr() # type: ignore[reportUnknownMemberType]
177
+ cnfStyle: Any = OxmlElement("w:cnfStyle") # type: ignore[reportUnknownVariableType]
178
+ # 12-bit mask, ECMA-376 §17.4.7. Bit 1 (zero-indexed) = lastRow.
179
+ cnfStyle.set(qn("w:val"), "010000000000") # type: ignore[reportUnknownMemberType]
180
+ trPr.append(cnfStyle) # type: ignore[reportUnknownMemberType]
181
+
182
+
183
+ def _disable_implicit_last_row(table_docx: TableDocx) -> None:
184
+ """Force ``<w:tblLook w:lastRow="0"/>`` so the real last row stays plain.
185
+
186
+ With block_sep we mark *interior* rows as ``lastRow`` via
187
+ ``cnfStyle``. The table's own last row must not pick up the same
188
+ conditional formatting automatically, so we disable the implicit
189
+ ``lastRow`` flag at the ``<w:tblLook>`` level.
190
+ """
191
+ tblPr: Any = table_docx._element.tblPr # type: ignore[reportUnknownMemberType]
192
+ tblLook: Any = tblPr.find(qn("w:tblLook")) # type: ignore[reportUnknownMemberType]
193
+ if tblLook is None:
194
+ tblLook = OxmlElement("w:tblLook") # type: ignore[reportUnknownVariableType]
195
+ tblPr.append(tblLook) # type: ignore[reportUnknownMemberType]
196
+ tblLook.set(qn("w:lastRow"), "0") # type: ignore[reportUnknownMemberType]
214
197
 
215
198
 
216
199
  def _is_bright(hex_color: str) -> bool:
@@ -1,49 +0,0 @@
1
- """Shared context object for ``build_for`` provider/artist factories.
2
-
3
- Consumer projects increasingly converge on a pattern where
4
- each provider/artist class has a ``build_for(cls, ctx) ->
5
- dict[str, ...]`` classmethod that decides whether to
6
- instantiate itself based on the catalog and runtime context.
7
-
8
- ``BuildContext`` is the minimal shape we expect callers to
9
- share across that pattern. It deliberately stays small so
10
- projects can subclass or compose freely:
11
-
12
- .. code-block:: python
13
-
14
- @dataclass(frozen=True)
15
- class DiaryBuildContext(BuildContext):
16
- lseg_fallback: Literal["mock", "raise"] = "mock"
17
-
18
- The base fields capture the cross-project minimum:
19
-
20
- * ``registry`` -- whatever catalog the orchestrator uses to
21
- decide which series each provider must serve. Typed as
22
- ``Any`` because each project's catalog has a different
23
- shape.
24
- * ``consumer`` -- a free-form string identifying the calling
25
- workflow (e.g. ``"diary"``, ``"weekly"``). ``build_for``
26
- implementations dispatch on this.
27
- * ``mock`` / ``mock_seed`` -- toggle for deterministic
28
- fixtures during tests and demos.
29
- * ``historic_file`` -- optional path to a historical dump
30
- used by some providers as a fallback.
31
-
32
- See ``docs/extending.md`` for the recommended ``build_for``
33
- classmethod shape.
34
- """
35
-
36
- from __future__ import annotations
37
-
38
- from dataclasses import dataclass
39
- from pathlib import Path
40
- from typing import Any
41
-
42
-
43
- @dataclass(frozen=True)
44
- class BuildContext:
45
- registry: Any
46
- consumer: str
47
- mock: bool = False
48
- mock_seed: int | None = None
49
- historic_file: Path | None = None
@@ -1,114 +0,0 @@
1
- """Abstract base class for data providers.
2
-
3
- A provider knows how to download time series from a specific
4
- external source (Bank of Spain, Eikon, ECB, etc.). It does
5
- not know anything about local storage, incremental updates,
6
- or presentation -- those concerns belong elsewhere.
7
-
8
- The interface is intentionally minimal: ``fetch`` to download
9
- data and ``is_available`` to check connectivity. Any
10
- provider-specific configuration (API keys, rate limits, etc.)
11
- belongs in the concrete class constructor, not in the
12
- abstract interface.
13
-
14
- Every provider returns data in the same shape: a DataFrame
15
- with a DatetimeIndex and one column per series code.
16
-
17
- This module exposes both ``DataProvider`` (an ``ABC`` --
18
- recommended base class because it enforces ``fetch`` and
19
- ``is_available`` at instantiation time) and
20
- ``DataProviderProtocol`` (a ``runtime_checkable`` Protocol
21
- mirroring the same surface). The Protocol exists for
22
- callers that need to compose Protocols, e.g.
23
-
24
- .. code-block:: python
25
-
26
- class DiaryProvider(DataProviderProtocol, Protocol):
27
- PROVIDER_NAME: ClassVar[str]
28
- @classmethod
29
- def build_for(cls, ctx: BuildContext) -> dict[str, ...]: ...
30
-
31
- without dragging in the ABC's runtime enforcement.
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- from abc import ABC, abstractmethod
37
- from typing import Protocol, runtime_checkable
38
-
39
- import pandas as pd
40
-
41
-
42
- class DataProvider(ABC):
43
- """Base class for all data providers.
44
-
45
- Subclasses must implement ``fetch`` and ``is_available``.
46
- """
47
-
48
- @abstractmethod
49
- def fetch(
50
- self,
51
- codes: list[str],
52
- start: str | None = None,
53
- end: str | None = None,
54
- ) -> pd.DataFrame:
55
- """Download series data for a date range.
56
-
57
- Parameters
58
- ----------
59
- codes
60
- Provider-specific series codes to download.
61
- start
62
- Start date as ISO string (e.g. ``"2025-01-01"``).
63
- If ``None``, the provider decides the earliest
64
- date (typically the full available history).
65
- end
66
- End date as ISO string. If ``None``, the
67
- provider fetches up to the latest available data.
68
-
69
- Returns
70
- -------
71
- pd.DataFrame
72
- - Index: ``DatetimeIndex`` named ``"date"``,
73
- tz-naive, normalized to midnight.
74
- - Columns: one per code in ``codes``.
75
- - Values: ``float64``, with ``NaN`` for missing
76
- observations.
77
- """
78
- ...
79
-
80
- @abstractmethod
81
- def is_available(self) -> bool:
82
- """Check whether this provider can serve data.
83
-
84
- Returns ``True`` if the external service is reachable
85
- and ready. Useful for fail-fast checks before
86
- starting a long download, or for choosing between
87
- a primary provider and a fallback.
88
- """
89
- ...
90
-
91
-
92
- @runtime_checkable
93
- class DataProviderProtocol(Protocol):
94
- """Structural counterpart of :class:`DataProvider`.
95
-
96
- Anything with ``fetch(codes, start=None, end=None)`` and
97
- ``is_available()`` satisfies this Protocol. Subclassing
98
- is unnecessary; useful when callers need to compose
99
- Protocols (``class DiaryProvider(DataProviderProtocol,
100
- Protocol): ...``) without inheriting an ABC.
101
-
102
- Prefer subclassing :class:`DataProvider` for new concrete
103
- providers: the ABC enforces the contract at
104
- instantiation time, the Protocol does not.
105
- """
106
-
107
- def fetch(
108
- self,
109
- codes: list[str],
110
- start: str | None = None,
111
- end: str | None = None,
112
- ) -> pd.DataFrame: ...
113
-
114
- def is_available(self) -> bool: ...