econenv 0.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.

Potentially problematic release.


This version of econenv might be problematic. Click here for more details.

econenv/__init__.py ADDED
@@ -0,0 +1,194 @@
1
+ """EconEnv — One Notebook. Multiple Econometric Engines.
2
+
3
+ Python, R, Stata and EViews in a single Jupyter session, with one Python kernel.
4
+
5
+ %load_ext econenv
6
+
7
+ %%R
8
+ fit <- lm(y ~ x, data = df)
9
+
10
+ %%stata
11
+ regress y x
12
+
13
+ %%eviews
14
+ equation eq1.ls y c x
15
+
16
+ EconEnv distributes **no** commercial software. Stata and EViews must be
17
+ installed and licensed independently; see the LICENSE and README.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Optional
23
+
24
+ from ._logging import configure as configure_logging
25
+ from ._logging import get_logger
26
+ from ._version import __version__
27
+ from .exceptions import (
28
+ CapabilityError,
29
+ ConfigurationError,
30
+ DataTransferError,
31
+ EconEnvError,
32
+ EngineError,
33
+ EngineExecutionError,
34
+ EngineNotConfiguredError,
35
+ EngineNotFoundError,
36
+ EngineStartError,
37
+ EngineTimeoutError,
38
+ EngineUnavailableError,
39
+ LossyConversionError,
40
+ ModelSpecificationError,
41
+ SessionError,
42
+ UnsupportedDataTypeError,
43
+ )
44
+ from .results import ExecutionResult, Figure, ModelResult
45
+ from .schema import ColumnSchema, ConversionReport, DatasetMetadata, LogicalType
46
+
47
+ __all__ = [
48
+ "CapabilityError",
49
+ "ColumnSchema",
50
+ "ComparisonResult",
51
+ "ConfigurationError",
52
+ "ConversionReport",
53
+ "DataTransferError",
54
+ "DatasetMetadata",
55
+ "EconEnvError",
56
+ "EngineError",
57
+ "EngineExecutionError",
58
+ "EngineNotConfiguredError",
59
+ "EngineNotFoundError",
60
+ "EngineStartError",
61
+ "EngineTimeoutError",
62
+ "EngineUnavailableError",
63
+ "ExecutionResult",
64
+ "Figure",
65
+ "LogicalType",
66
+ "LossyConversionError",
67
+ "ModelResult",
68
+ "ModelSpec",
69
+ "ModelSpecificationError",
70
+ "SessionError",
71
+ "UnsupportedDataTypeError",
72
+ "__version__",
73
+ "compare_ols",
74
+ "configure_logging",
75
+ "doctor",
76
+ "engine",
77
+ "engines",
78
+ "get_logger",
79
+ "load_ipython_extension",
80
+ "move",
81
+ "pull",
82
+ "push",
83
+ "snapshot",
84
+ "status",
85
+ "unload_ipython_extension",
86
+ ]
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # top-level convenience API
91
+ # --------------------------------------------------------------------------- #
92
+ def engine(name: str, **options: Any):
93
+ """Return the engine registered as *name*, creating its instance if needed."""
94
+ from .engines import registry
95
+
96
+ return registry.get(name, **options)
97
+
98
+
99
+ def engines() -> list:
100
+ """Snapshot of every registered engine."""
101
+ from .engines import registry
102
+
103
+ return [info.to_dict() for info in registry.info()]
104
+
105
+
106
+ def status():
107
+ """The engine table, ready for display."""
108
+ from . import services
109
+
110
+ return services.status_frame()
111
+
112
+
113
+ def doctor(engine_name: Optional[str] = None, *, deep: bool = False):
114
+ """Run the diagnostics (brief §14)."""
115
+ from . import diagnostics
116
+
117
+ return diagnostics.run(engine_name, deep=deep)
118
+
119
+
120
+ def snapshot(**kwargs: Any) -> dict:
121
+ """A reproducibility snapshot of the whole environment (brief §20)."""
122
+ from . import transfer
123
+
124
+ return transfer.snapshot(**kwargs)
125
+
126
+
127
+ def push(engine_name: str, name: str, obj: Any, **kwargs: Any) -> None:
128
+ """Send a Python object into an engine."""
129
+ from . import transfer
130
+
131
+ transfer.push(engine_name, name, obj, **kwargs)
132
+
133
+
134
+ def pull(engine_name: str, name: Optional[str] = None, **kwargs: Any):
135
+ """Fetch an object out of an engine."""
136
+ from . import transfer
137
+
138
+ return transfer.pull(engine_name, name, **kwargs)
139
+
140
+
141
+ def move(source: str, target: str, name: str, **kwargs: Any):
142
+ """Move a dataset from one engine to another without touching a file."""
143
+ from . import transfer
144
+
145
+ return transfer.move(source, target, name, **kwargs)
146
+
147
+
148
+ def compare_ols(*args: Any, **kwargs: Any):
149
+ """Run the same OLS in every available engine and compare (brief §22)."""
150
+ from .models import compare_ols as _compare
151
+
152
+ return _compare(*args, **kwargs)
153
+
154
+
155
+ def __getattr__(name: str) -> Any:
156
+ """Lazily expose the model classes without importing pandas machinery early."""
157
+ if name == "ModelSpec":
158
+ from .models.spec import ModelSpec
159
+
160
+ return ModelSpec
161
+ if name == "ComparisonResult":
162
+ from .models.compare import ComparisonResult
163
+
164
+ return ComparisonResult
165
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
166
+
167
+
168
+ # --------------------------------------------------------------------------- #
169
+ # IPython extension entry point
170
+ # --------------------------------------------------------------------------- #
171
+ def load_ipython_extension(ipython: Any) -> None:
172
+ """``%load_ext econenv``.
173
+
174
+ Registers every magic and reports which implementation won each name. No
175
+ engine is started here — loading the extension must be fast and must not
176
+ consume a Stata or EViews licence seat.
177
+ """
178
+ from ._logging import configure, level_from_env
179
+ from .magics import register_all
180
+
181
+ configure(level_from_env())
182
+ registration = register_all(ipython)
183
+
184
+ print(f"EconEnv {__version__} loaded — one notebook, multiple econometric engines.")
185
+ for magic_name, owner in registration.items():
186
+ print(f" {magic_name:<22} {owner}")
187
+ print(" %econ status · %econ doctor · %econ help")
188
+
189
+
190
+ def unload_ipython_extension(ipython: Any) -> None:
191
+ """Stop every engine when the extension is unloaded."""
192
+ from .engines import registry
193
+
194
+ registry.stop_all()
econenv/_logging.py ADDED
@@ -0,0 +1,71 @@
1
+ """Logging for EconEnv.
2
+
3
+ Everything goes through the ``econenv`` logger hierarchy. Nothing is configured
4
+ at import time — a library that reconfigures the root logger is a library that
5
+ breaks somebody's notebook.
6
+
7
+ Brief §33: licence keys, serial numbers and credentials must never be logged.
8
+ :func:`redact` is applied to anything that could plausibly carry one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import re
16
+ from typing import Optional
17
+
18
+ _ROOT_NAME = "econenv"
19
+
20
+ # Patterns that must never reach a log record.
21
+ _REDACTIONS = [
22
+ (re.compile(r"(?i)\b(serial|licen[cs]e|key|token|password|secret)\b\s*[:=]\s*\S+"), r"\1=***"),
23
+ (re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{4}\b"), "****-****-****-****"),
24
+ ]
25
+
26
+
27
+ def redact(text: str) -> str:
28
+ """Blank out anything that looks like a credential or serial number."""
29
+ for pattern, replacement in _REDACTIONS:
30
+ text = pattern.sub(replacement, text)
31
+ return text
32
+
33
+
34
+ class _RedactingFilter(logging.Filter):
35
+ def filter(self, record: logging.LogRecord) -> bool:
36
+ if isinstance(record.msg, str):
37
+ record.msg = redact(record.msg)
38
+ return True
39
+
40
+
41
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
42
+ """Return the ``econenv`` logger, or a child of it."""
43
+ logger = logging.getLogger(_ROOT_NAME if name is None else f"{_ROOT_NAME}.{name}")
44
+ if not any(isinstance(f, _RedactingFilter) for f in logger.filters):
45
+ logger.addFilter(_RedactingFilter())
46
+ return logger
47
+
48
+
49
+ def configure(level: str = "WARNING", *, force: bool = False) -> logging.Logger:
50
+ """Attach a stderr handler to the ``econenv`` logger.
51
+
52
+ Called by the CLI and by ``%econ config log <level>``. Library code never
53
+ calls this on import.
54
+ """
55
+ root = get_logger()
56
+ if force:
57
+ for handler in list(root.handlers):
58
+ root.removeHandler(handler)
59
+ if not root.handlers:
60
+ handler = logging.StreamHandler()
61
+ handler.setFormatter(logging.Formatter("%(levelname)-7s %(name)s: %(message)s"))
62
+ handler.addFilter(_RedactingFilter())
63
+ root.addHandler(handler)
64
+ root.setLevel(level.upper())
65
+ root.propagate = False
66
+ return root
67
+
68
+
69
+ def level_from_env(default: str = "WARNING") -> str:
70
+ """Read ``ECONENV_LOG_LEVEL`` from the environment."""
71
+ return os.environ.get("ECONENV_LOG_LEVEL", default).upper()
econenv/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the EconEnv version."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,24 @@
1
+ """Data bridges: pandas in, engine-native out, and back again (brief §10).
2
+
3
+ Every bridge exposes the same two functions::
4
+
5
+ push_frame(engine, name, df, **kwargs) -> ConversionReport
6
+ pull_frame(engine, name, **kwargs) -> DataFrame
7
+
8
+ ``pandas.DataFrame`` is the canonical interchange object. The transport
9
+ underneath differs per engine — PyStata's in-memory API for Stata, COM arrays
10
+ for EViews, a typed file handshake for R — but the contract above never does, so
11
+ :mod:`econenv.transfer` can move data between *any* pair of engines without
12
+ knowing which two.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from .eviews_bridge import pull_frame as pull_eviews # noqa: F401
21
+ from .r_bridge import pull_frame as pull_r # noqa: F401
22
+ from .stata_bridge import pull_frame as pull_stata # noqa: F401
23
+
24
+ __all__ = ["eviews_bridge", "r_bridge", "stata_bridge"]
@@ -0,0 +1,409 @@
1
+ """pandas <-> EViews, over COM.
2
+
3
+ The single most important thing in this module is :func:`_variant`.
4
+
5
+ Measured behaviour (Phase 0 audit §5.5)::
6
+
7
+ app.PutSeries("zz", [1.0, 2.0, 3.0]) # returns without error
8
+ app.GetSeries("zz") -> (None, None, None) # every value lost
9
+
10
+ A plain Python list marshals into a VARIANT that EViews reads as empty, and
11
+ nothing anywhere reports a problem. Wrapping the values in an explicit
12
+ ``comtypes.automation.VARIANT`` fixes it. Because a silent all-NA dataset is the
13
+ worst possible failure mode for an econometrics tool, every push is also **read
14
+ back and verified** before it is called a success.
15
+
16
+ The other EViews-specific pieces here:
17
+
18
+ * ``GetSeries`` returns ``None`` for ``NA`` — mapped to ``NaN``.
19
+ * ``GetGroup`` needs a BSTR SAFEARRAY and rejects a Python list, so transfers go
20
+ series by series.
21
+ * A dated pandas index becomes a dated workfile page (``create q 2000Q1 2010Q4``)
22
+ and comes back through ``@otod`` so the dates survive the round trip.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from typing import Any, Dict, Iterable, List, Optional, Sequence
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ from ..exceptions import DataTransferError, LossyConversionError, com_message
34
+ from ..schema import (
35
+ ConversionReport,
36
+ LogicalType,
37
+ Severity,
38
+ describe_frame,
39
+ logical_type_of,
40
+ )
41
+
42
+ #: EViews object names are limited to 24 characters and cannot be a command.
43
+ MAX_NAME_LEN = 24
44
+ # fmt: off
45
+ EVIEWS_RESERVED = {
46
+ "c", "resid", "abs", "log", "exp", "sqr", "d", "dlog", "na", "nrnd", "rnd",
47
+ "trend", "obs", "mean", "sum", "var", "cor", "cov", "series", "equation",
48
+ "group", "matrix", "scalar", "vector", "sample", "smpl",
49
+ }
50
+ # fmt: on
51
+
52
+
53
+ def sanitise_name(name: str, taken: Optional[set] = None) -> str:
54
+ """Return an EViews-legal object name derived from *name*."""
55
+ import re
56
+
57
+ cleaned = re.sub(r"[^A-Za-z0-9_]", "_", str(name))
58
+ if not cleaned or cleaned[0].isdigit():
59
+ cleaned = f"v_{cleaned}"
60
+ cleaned = cleaned[:MAX_NAME_LEN]
61
+ if cleaned.lower() in EVIEWS_RESERVED:
62
+ cleaned = f"{cleaned}_"[:MAX_NAME_LEN]
63
+ if taken is not None:
64
+ base, suffix = cleaned, 1
65
+ while cleaned.lower() in taken:
66
+ tail = f"_{suffix}"
67
+ cleaned = base[: MAX_NAME_LEN - len(tail)] + tail
68
+ suffix += 1
69
+ taken.add(cleaned.lower())
70
+ return cleaned
71
+
72
+
73
+ def _variant(values: Iterable[Any]):
74
+ """Wrap *values* in a COM VARIANT.
75
+
76
+ Not optional and not a style preference: passing a bare Python list to
77
+ ``PutSeries`` writes NA for every observation without raising. See the
78
+ module docstring.
79
+ """
80
+ from comtypes.automation import VARIANT
81
+
82
+ variant = VARIANT()
83
+ variant.value = [float(v) if v is not None and not _isnan(v) else None for v in values]
84
+ return variant
85
+
86
+
87
+ def _isnan(value: Any) -> bool:
88
+ try:
89
+ return bool(math.isnan(float(value)))
90
+ except (TypeError, ValueError):
91
+ return False
92
+
93
+
94
+ # --------------------------------------------------------------------------- #
95
+ # workfile creation
96
+ # --------------------------------------------------------------------------- #
97
+ def _page_spec(index: pd.Index, n: int) -> str:
98
+ """The ``create`` argument for a workfile matching *index*."""
99
+ from ..engines.eviews_engine import PANDAS_TO_FREQ
100
+
101
+ if isinstance(index, pd.PeriodIndex):
102
+ index = index.to_timestamp()
103
+ if isinstance(index, pd.DatetimeIndex) and len(index):
104
+ freq = getattr(index, "freqstr", None) or pd.infer_freq(index)
105
+ letter = PANDAS_TO_FREQ.get(str(freq).split("-")[0]) if freq else None
106
+ if letter:
107
+ return f"{letter} {_eviews_date(index[0], letter)} {_eviews_date(index[-1], letter)}"
108
+ return f"u {n}"
109
+
110
+
111
+ def _eviews_date(stamp: pd.Timestamp, letter: str) -> str:
112
+ """Format a timestamp the way EViews writes that frequency."""
113
+ if letter == "A":
114
+ return f"{stamp.year}"
115
+ if letter == "Q":
116
+ return f"{stamp.year}Q{stamp.quarter}"
117
+ if letter == "M":
118
+ return f"{stamp.year}M{stamp.month:02d}"
119
+ return stamp.strftime("%m/%d/%Y")
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # push
124
+ # --------------------------------------------------------------------------- #
125
+ def push_frame(
126
+ engine,
127
+ name: str,
128
+ df: pd.DataFrame,
129
+ *,
130
+ new_workfile: bool = True,
131
+ page: Optional[str] = None,
132
+ verify: bool = True,
133
+ **kwargs: Any,
134
+ ) -> ConversionReport:
135
+ """Create an EViews workfile page from *df* and write every column into it.
136
+
137
+ Parameters
138
+ ----------
139
+ new_workfile:
140
+ Create a fresh workfile. When False the active page is reused and must
141
+ already have at least as many observations as *df*.
142
+ verify:
143
+ Read every numeric series back and compare. On by default — see the
144
+ module docstring for why.
145
+ """
146
+ report = ConversionReport(engine="eviews", direction="push")
147
+ app = engine._app
148
+ if app is None:
149
+ raise DataTransferError("The EViews session is not running.", engine="eviews", name=name)
150
+
151
+ n = len(df)
152
+ if n == 0:
153
+ raise DataTransferError("Refusing to push an empty frame.", engine="eviews", name=name)
154
+
155
+ frame = df
156
+ if not isinstance(frame.index, pd.RangeIndex) and not isinstance(
157
+ frame.index, (pd.DatetimeIndex, pd.PeriodIndex)
158
+ ):
159
+ frame = frame.reset_index()
160
+ report.add(Severity.INFO, "non-date index written as ordinary column(s)")
161
+
162
+ if new_workfile:
163
+ spec = _page_spec(frame.index, n)
164
+ engine.execute(f"create {spec}", capture_graphs=False)
165
+ report.add(Severity.INFO, f"created workfile page: create {spec}")
166
+ if spec.startswith("u "):
167
+ report.add(
168
+ Severity.WARNING,
169
+ "the frame has no recognised date index, so the page is undated; "
170
+ "EViews time-series operators (d(), lags, @trend on dates) will "
171
+ "treat observations as unordered",
172
+ )
173
+ if page:
174
+ engine.execute(f"pageselect {page}", capture_graphs=False)
175
+
176
+ taken: set = set()
177
+ written: Dict[str, str] = {}
178
+ for column in frame.columns:
179
+ series = frame[column]
180
+ target = sanitise_name(column, taken)
181
+ if target != str(column):
182
+ report.add(Severity.WARNING, f"renamed to {target!r} for EViews", column=str(column))
183
+
184
+ ltype = logical_type_of(series)
185
+ if ltype in (LogicalType.FLOAT, LogicalType.INTEGER, LogicalType.BOOLEAN):
186
+ values = pd.to_numeric(series, errors="coerce").astype(float).to_numpy()
187
+ if ltype is LogicalType.BOOLEAN:
188
+ report.add(Severity.INFO, "boolean stored as 0/1", column=str(column))
189
+ _put_series(engine, target, values, report, str(column))
190
+ elif ltype is LogicalType.CATEGORICAL:
191
+ codes = series.cat.codes.astype(float).replace(-1.0, np.nan).to_numpy()
192
+ _put_series(engine, target, codes, report, str(column))
193
+ levels = list(series.cat.categories)
194
+ report.add(
195
+ Severity.WARNING,
196
+ "categorical stored as integer codes; EViews has no factor type",
197
+ column=str(column),
198
+ detail=f"codes 0..{len(levels) - 1} = {levels[:10]}",
199
+ )
200
+ report.notes[-1].detail = f"codes 0..{len(levels) - 1} = {levels[:10]}"
201
+ elif ltype is LogicalType.STRING:
202
+ _put_alpha(engine, target, series, report, str(column))
203
+ elif ltype in (LogicalType.DATE, LogicalType.DATETIME):
204
+ stamps = pd.to_datetime(series, errors="coerce")
205
+ # EViews date numbers are days since 01/01/0001 (its @dateval basis).
206
+ values = (stamps - pd.Timestamp("1970-01-01")).dt.total_seconds() / 86400.0
207
+ _put_series(engine, target, values.to_numpy(), report, str(column))
208
+ report.add(
209
+ Severity.WARNING,
210
+ "datetime stored as days since 1970-01-01 (a plain numeric series)",
211
+ column=str(column),
212
+ )
213
+ else:
214
+ raise DataTransferError(
215
+ f"dtype {series.dtype} cannot be represented in EViews",
216
+ engine="eviews",
217
+ name=str(column),
218
+ )
219
+ written[str(column)] = target
220
+
221
+ if verify:
222
+ _verify(engine, frame, written, report)
223
+
224
+ report.add(Severity.INFO, f"{n} observations x {len(written)} series written")
225
+ report.emit()
226
+ return report
227
+
228
+
229
+ def _put_series(
230
+ engine, target: str, values: np.ndarray, report: ConversionReport, column: str
231
+ ) -> None:
232
+ try:
233
+ engine._app.PutSeries(target, _variant(values))
234
+ except Exception as exc:
235
+ raise DataTransferError(
236
+ com_message(exc) or str(exc), engine="eviews", name=column, raw=exc
237
+ ) from exc
238
+
239
+
240
+ def _put_alpha(
241
+ engine, target: str, series: pd.Series, report: ConversionReport, column: str
242
+ ) -> None:
243
+ """Write a string column as an EViews alpha series.
244
+
245
+ There is no ``PutAlpha``, so the values go in through ``@recode``-free
246
+ per-observation assignment. That is slow, so it is capped and reported.
247
+ """
248
+ values = series.astype(object).where(series.notna(), None).tolist()
249
+ limit = 5000
250
+ if len(values) > limit:
251
+ raise LossyConversionError(
252
+ f"string column has {len(values)} rows; EViews alpha transfer is capped at {limit}",
253
+ engine="eviews",
254
+ name=column,
255
+ hint="Encode it as a categorical first, or drop the column before pushing.",
256
+ )
257
+ engine.execute(f"alpha {target}", capture_graphs=False)
258
+ for i, value in enumerate(values, start=1):
259
+ if value is None:
260
+ continue
261
+ escaped = str(value).replace('"', '""')
262
+ engine.execute(f'{target}({i}) = "{escaped}"', capture_graphs=False)
263
+ report.add(
264
+ Severity.INFO,
265
+ "string column written as an EViews alpha series, one observation at a time",
266
+ column=column,
267
+ )
268
+
269
+
270
+ def _verify(engine, frame: pd.DataFrame, written: Dict[str, str], report: ConversionReport) -> None:
271
+ """Read the numeric series back and fail loudly on any mismatch."""
272
+ for original, target in written.items():
273
+ series = frame[original]
274
+ if logical_type_of(series) not in (
275
+ LogicalType.FLOAT,
276
+ LogicalType.INTEGER,
277
+ LogicalType.BOOLEAN,
278
+ ):
279
+ continue
280
+ expected = pd.to_numeric(series, errors="coerce").astype(float).to_numpy()
281
+ try:
282
+ actual = np.array(
283
+ [np.nan if v is None else float(v) for v in engine._app.GetSeries(target)],
284
+ dtype=float,
285
+ )
286
+ except Exception as exc:
287
+ raise DataTransferError(
288
+ f"could not read {target!r} back for verification: {com_message(exc) or exc}",
289
+ engine="eviews",
290
+ name=original,
291
+ raw=exc,
292
+ ) from exc
293
+ if actual.size != expected.size:
294
+ raise DataTransferError(
295
+ f"EViews stored {actual.size} observations, expected {expected.size}",
296
+ engine="eviews",
297
+ name=original,
298
+ hint="The active page is shorter than the frame; push with new_workfile=True.",
299
+ )
300
+ if np.isnan(actual).all() and not np.isnan(expected).all():
301
+ raise DataTransferError(
302
+ "EViews stored NA for every observation — the COM VARIANT marshalling failed",
303
+ engine="eviews",
304
+ name=original,
305
+ hint="This is the known PutSeries trap; report it as an EconEnv bug.",
306
+ )
307
+ if not np.allclose(actual, expected, rtol=1e-12, atol=0.0, equal_nan=True):
308
+ worst = float(np.nanmax(np.abs(actual - expected)))
309
+ report.add(
310
+ Severity.WARNING,
311
+ f"values differ after the round trip by up to {worst:.3g}",
312
+ column=original,
313
+ )
314
+
315
+
316
+ # --------------------------------------------------------------------------- #
317
+ # pull
318
+ # --------------------------------------------------------------------------- #
319
+ def pull_frame(
320
+ engine,
321
+ name: Optional[str] = None,
322
+ *,
323
+ columns: Optional[Sequence[str]] = None,
324
+ dated_index: bool = True,
325
+ **kwargs: Any,
326
+ ) -> pd.DataFrame:
327
+ """Read the active EViews page (or the named series) back into pandas."""
328
+ app = engine._app
329
+ if app is None:
330
+ raise DataTransferError("The EViews session is not running.", engine="eviews", name=name)
331
+
332
+ if columns is not None:
333
+ names = list(columns)
334
+ elif name and name not in {"*", "page", "workfile"}:
335
+ names = name.split()
336
+ else:
337
+ listing = engine._eval('@wlookup("*","series")')
338
+ names = str(listing).split() if listing else []
339
+
340
+ if not names:
341
+ raise DataTransferError(
342
+ "The active EViews page contains no series.", engine="eviews", name=name
343
+ )
344
+
345
+ n_obs = engine._eval("@obsrange")
346
+ n = int(n_obs) if n_obs else 0
347
+
348
+ data: Dict[str, List[float]] = {}
349
+ for series_name in names:
350
+ try:
351
+ raw = app.GetSeries(series_name)
352
+ except Exception as exc:
353
+ raise DataTransferError(
354
+ com_message(exc) or str(exc), engine="eviews", name=series_name, raw=exc
355
+ ) from exc
356
+ data[series_name] = [np.nan if v is None else float(v) for v in raw]
357
+
358
+ df = pd.DataFrame(data)
359
+ if dated_index:
360
+ index = _date_index(engine, n or len(df))
361
+ if index is not None and len(index) == len(df):
362
+ df.index = index
363
+
364
+ report = ConversionReport(engine="eviews", direction="pull")
365
+ report.add(Severity.INFO, "EViews NA read as NaN")
366
+ report.add(
367
+ Severity.INFO,
368
+ "alpha (string) series are not returned by GetSeries; "
369
+ "read them with `%eviews_pull` per observation if needed",
370
+ )
371
+ df.attrs["econenv_conversion"] = report
372
+
373
+ meta = describe_frame(df, name=name, source_engine="eviews")
374
+ meta.frequency = _pandas_freq(engine)
375
+ meta.notes["workfile"] = engine._eval("@wfname")
376
+ meta.notes["page"] = engine._eval("@pagename")
377
+ df.attrs["econenv_metadata"] = meta
378
+ return df
379
+
380
+
381
+ def _date_index(engine, n: int) -> Optional[pd.Index]:
382
+ """Rebuild a pandas index from EViews' own observation labels."""
383
+ freq = engine._eval("@pagefreq")
384
+ if not freq or str(freq).upper().startswith("U"):
385
+ return None
386
+ labels = []
387
+ for i in range(1, n + 1):
388
+ label = engine._eval(f"@otod({i})")
389
+ if label is None:
390
+ return None
391
+ labels.append(str(label))
392
+ try:
393
+ return pd.PeriodIndex(labels, freq=_period_freq(str(freq))).to_timestamp()
394
+ except (ValueError, TypeError):
395
+ try:
396
+ return pd.to_datetime(labels)
397
+ except (ValueError, TypeError):
398
+ return None
399
+
400
+
401
+ def _period_freq(page_freq: str) -> str:
402
+ letter = page_freq.strip().upper()[:1]
403
+ return {"A": "Y", "Q": "Q", "M": "M", "W": "W", "D": "D", "H": "h"}.get(letter, "D")
404
+
405
+
406
+ def _pandas_freq(engine) -> Optional[str]:
407
+ from ..engines.eviews_engine import parse_frequency
408
+
409
+ return parse_frequency(engine._eval("@pagefreq"))