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 +194 -0
- econenv/_logging.py +71 -0
- econenv/_version.py +3 -0
- econenv/bridges/__init__.py +24 -0
- econenv/bridges/eviews_bridge.py +409 -0
- econenv/bridges/r_bridge.py +363 -0
- econenv/bridges/stata_bridge.py +253 -0
- econenv/cli.py +198 -0
- econenv/config.py +224 -0
- econenv/diagnostics.py +583 -0
- econenv/discovery.py +398 -0
- econenv/engines/__init__.py +15 -0
- econenv/engines/base.py +422 -0
- econenv/engines/eviews_engine.py +564 -0
- econenv/engines/python_engine.py +235 -0
- econenv/engines/r_engine.py +732 -0
- econenv/engines/registry.py +181 -0
- econenv/engines/stata_engine.py +534 -0
- econenv/exceptions.py +174 -0
- econenv/magics/__init__.py +62 -0
- econenv/magics/_common.py +86 -0
- econenv/magics/econ_magic.py +260 -0
- econenv/magics/eviews_magic.py +168 -0
- econenv/magics/r_magic.py +174 -0
- econenv/magics/stata_magic.py +100 -0
- econenv/models/__init__.py +14 -0
- econenv/models/compare.py +259 -0
- econenv/models/registry.py +217 -0
- econenv/models/spec.py +167 -0
- econenv/results.py +304 -0
- econenv/schema.py +299 -0
- econenv/services.py +144 -0
- econenv/transfer.py +202 -0
- econenv-0.1.0.dist-info/METADATA +488 -0
- econenv-0.1.0.dist-info/RECORD +38 -0
- econenv-0.1.0.dist-info/WHEEL +4 -0
- econenv-0.1.0.dist-info/entry_points.txt +8 -0
- econenv-0.1.0.dist-info/licenses/LICENSE +51 -0
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,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"))
|