sqlbuddy-nwi 0.3.3__tar.gz → 0.3.4__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 (29) hide show
  1. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/PKG-INFO +34 -13
  2. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/README.md +33 -12
  3. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/_version.py +1 -1
  4. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/connections/manager.py +19 -2
  5. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/pyproject.toml +1 -1
  6. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/sqlbuddy_nwi.egg-info/PKG-INFO +34 -13
  7. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/sqlbuddy_nwi.egg-info/SOURCES.txt +1 -0
  8. sqlbuddy_nwi-0.3.4/tests/test_client_timeouts.py +103 -0
  9. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/LICENSE +0 -0
  10. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/cli.py +0 -0
  11. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/connections/__init__.py +0 -0
  12. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/connections/config_util.py +0 -0
  13. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/connections/readonly.py +0 -0
  14. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/connections/runtime.py +0 -0
  15. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/connections/wrappers.py +0 -0
  16. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/db_query.py +0 -0
  17. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/db_security.py +0 -0
  18. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/init.py +0 -0
  19. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/server.py +0 -0
  20. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/setup.cfg +0 -0
  21. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/sqlbuddy_nwi.egg-info/dependency_links.txt +0 -0
  22. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/sqlbuddy_nwi.egg-info/entry_points.txt +0 -0
  23. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/sqlbuddy_nwi.egg-info/requires.txt +0 -0
  24. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/sqlbuddy_nwi.egg-info/top_level.txt +0 -0
  25. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/test_vendor.py +0 -0
  26. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/tests/test_get_info.py +0 -0
  27. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/tests/test_parse_create_table.py +0 -0
  28. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/tests/test_sanitize_search.py +0 -0
  29. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.3.4}/tunnel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlbuddy-nwi
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: MCP server for read-only database access (MySQL, ClickHouse)
5
5
  Author: ying.yuxiang
6
6
  License: MIT
@@ -67,19 +67,40 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
67
67
 
68
68
  ## Quickstart
69
69
 
70
+ **Install** (current release **0.3.3**, Python ≥ 3.11):
71
+
70
72
  ```bash
71
- git clone <this-repo-url> sql-buddy && cd sql-buddy
72
- uv run server.py # installs deps into an isolated .venv, then serves (Ctrl+C to stop)
73
- uv run sql-buddy init # interactive wizard → writes connections/config/db.ini
74
- uv run sql-buddy doctor --connect # green-checks the venv, db.ini, tunnels, MCP client config
75
- uv run sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
73
+ # From PyPI (recommended):
74
+ pip install sqlbuddy-nwi # provides the `sql-buddy` command
75
+ # …or run the latest instantly without installing:
76
+ uvx --from sqlbuddy-nwi sql-buddy
76
77
  ```
77
78
 
79
+ ```bash
80
+ # From source (for development):
81
+ git clone https://github.com/yyx462/sql-buddy && cd sql-buddy
82
+ uv run server.py # auto-provisions an isolated .venv, then serves
83
+ ```
84
+
85
+ **Configure & connect:**
86
+
87
+ ```bash
88
+ sql-buddy init # interactive wizard → writes connections/config/db.ini
89
+ sql-buddy doctor --connect # green-checks db.ini, tunnels, MCP client config
90
+ sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
91
+ ```
92
+
93
+ > From a **git checkout**, prefix these with `uv run` (e.g. `uv run sql-buddy init`).
94
+ > From a **pip / uvx install**, run `sql-buddy` directly — it's on your PATH.
95
+
78
96
  Restart your client, then ask your agent *"list the tables in the `ck`
79
97
  connection"* — if it answers, you're live. 🎉
80
98
 
81
99
  > **Bare `sql-buddy` (no command) runs the MCP server** — that's what your AI
82
100
  > client invokes, so configs hold no fragile absolute `.venv/bin/python` path.
101
+ > `sql-buddy mcp add` auto-detects how you installed and writes the right
102
+ > command: `uvx --from sqlbuddy-nwi sql-buddy` for a pip/uvx install,
103
+ > `uv --directory … run server.py` for a checkout.
83
104
  > Other commands: `version`, `doctor [--connect]`, `mcp print|add`, `update-check`, `init`.
84
105
  > On **Windows**, see [`docs/windows-trae.md`](docs/windows-trae.md) and use
85
106
  > `sql-buddy mcp add --client trae`.
@@ -91,8 +112,8 @@ connection"* — if it answers, you're live. 🎉
91
112
  Run the wizard (easiest) or copy the template:
92
113
 
93
114
  ```bash
94
- uv run sql-buddy init
95
- # or by hand:
115
+ sql-buddy init
116
+ # or, from a git checkout, copy the template and edit by hand:
96
117
  cp connections/config/db.ini.example connections/config/db.ini
97
118
  $EDITOR connections/config/db.ini
98
119
  ```
@@ -140,13 +161,13 @@ reference, SSH troubleshooting, and External (`ssh -N`) mode, see
140
161
  ## Connect your AI agent
141
162
 
142
163
  ```bash
143
- uv run sql-buddy mcp add # Claude Code project → ./.mcp.json
144
- uv run sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
145
- uv run sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
146
- uv run sql-buddy mcp add --client trae --scope user # Trae user-global
164
+ sql-buddy mcp add # Claude Code project → ./.mcp.json
165
+ sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
166
+ sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
167
+ sql-buddy mcp add --client trae --scope user # Trae user-global
147
168
  ```
148
169
 
149
- Restart your client. Preview the exact block first with `uv run sql-buddy mcp print`
170
+ Restart your client. Preview the exact block first with `sql-buddy mcp print`
150
171
  (paths filled in):
151
172
 
152
173
  ```json
@@ -38,19 +38,40 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
38
38
 
39
39
  ## Quickstart
40
40
 
41
+ **Install** (current release **0.3.3**, Python ≥ 3.11):
42
+
41
43
  ```bash
42
- git clone <this-repo-url> sql-buddy && cd sql-buddy
43
- uv run server.py # installs deps into an isolated .venv, then serves (Ctrl+C to stop)
44
- uv run sql-buddy init # interactive wizard → writes connections/config/db.ini
45
- uv run sql-buddy doctor --connect # green-checks the venv, db.ini, tunnels, MCP client config
46
- uv run sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
44
+ # From PyPI (recommended):
45
+ pip install sqlbuddy-nwi # provides the `sql-buddy` command
46
+ # …or run the latest instantly without installing:
47
+ uvx --from sqlbuddy-nwi sql-buddy
47
48
  ```
48
49
 
50
+ ```bash
51
+ # From source (for development):
52
+ git clone https://github.com/yyx462/sql-buddy && cd sql-buddy
53
+ uv run server.py # auto-provisions an isolated .venv, then serves
54
+ ```
55
+
56
+ **Configure & connect:**
57
+
58
+ ```bash
59
+ sql-buddy init # interactive wizard → writes connections/config/db.ini
60
+ sql-buddy doctor --connect # green-checks db.ini, tunnels, MCP client config
61
+ sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
62
+ ```
63
+
64
+ > From a **git checkout**, prefix these with `uv run` (e.g. `uv run sql-buddy init`).
65
+ > From a **pip / uvx install**, run `sql-buddy` directly — it's on your PATH.
66
+
49
67
  Restart your client, then ask your agent *"list the tables in the `ck`
50
68
  connection"* — if it answers, you're live. 🎉
51
69
 
52
70
  > **Bare `sql-buddy` (no command) runs the MCP server** — that's what your AI
53
71
  > client invokes, so configs hold no fragile absolute `.venv/bin/python` path.
72
+ > `sql-buddy mcp add` auto-detects how you installed and writes the right
73
+ > command: `uvx --from sqlbuddy-nwi sql-buddy` for a pip/uvx install,
74
+ > `uv --directory … run server.py` for a checkout.
54
75
  > Other commands: `version`, `doctor [--connect]`, `mcp print|add`, `update-check`, `init`.
55
76
  > On **Windows**, see [`docs/windows-trae.md`](docs/windows-trae.md) and use
56
77
  > `sql-buddy mcp add --client trae`.
@@ -62,8 +83,8 @@ connection"* — if it answers, you're live. 🎉
62
83
  Run the wizard (easiest) or copy the template:
63
84
 
64
85
  ```bash
65
- uv run sql-buddy init
66
- # or by hand:
86
+ sql-buddy init
87
+ # or, from a git checkout, copy the template and edit by hand:
67
88
  cp connections/config/db.ini.example connections/config/db.ini
68
89
  $EDITOR connections/config/db.ini
69
90
  ```
@@ -111,13 +132,13 @@ reference, SSH troubleshooting, and External (`ssh -N`) mode, see
111
132
  ## Connect your AI agent
112
133
 
113
134
  ```bash
114
- uv run sql-buddy mcp add # Claude Code project → ./.mcp.json
115
- uv run sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
116
- uv run sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
117
- uv run sql-buddy mcp add --client trae --scope user # Trae user-global
135
+ sql-buddy mcp add # Claude Code project → ./.mcp.json
136
+ sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
137
+ sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
138
+ sql-buddy mcp add --client trae --scope user # Trae user-global
118
139
  ```
119
140
 
120
- Restart your client. Preview the exact block first with `uv run sql-buddy mcp print`
141
+ Restart your client. Preview the exact block first with `sql-buddy mcp print`
121
142
  (paths filled in):
122
143
 
123
144
  ```json
@@ -7,4 +7,4 @@ still report a version. `cli.get_version()` prefers installed metadata when
7
7
  available and falls back to this.
8
8
  """
9
9
 
10
- __version__ = "0.3.3"
10
+ __version__ = "0.3.4"
@@ -135,10 +135,27 @@ class DBManager:
135
135
  return conn_config
136
136
 
137
137
  def _create_clickhouse_client(self, section: str) -> Client:
138
- return Client(**self._resolve_conn_config(section, 9000))
138
+ cfg = self._resolve_conn_config(section, 9000)
139
+ # Default timeouts: a black-holed endpoint (dead tunnel forward, silent
140
+ # DB) must raise instead of parking the caller's worker thread in a
141
+ # socket read forever. Explicit keys in the db.ini section win.
142
+ cfg.setdefault('connect_timeout', 10)
143
+ cfg.setdefault('send_receive_timeout', 900)
144
+ for k in ('connect_timeout', 'send_receive_timeout'):
145
+ cfg[k] = int(cfg[k]) # ini values arrive as strings; drivers want ints
146
+ return Client(**cfg)
139
147
 
140
148
  def _create_mysql_client(self, section: str) -> MySQLConnectionWrapper:
141
- return MySQLConnectionWrapper(pymysql.connect(**self._resolve_conn_config(section, 3306)))
149
+ cfg = self._resolve_conn_config(section, 3306)
150
+ # Same bounds for MySQL; read/write must exceed the 600s
151
+ # innodb_lock_wait_timeout the locked wrapper sets, so legit
152
+ # lock-waits are not cut short.
153
+ cfg.setdefault('connect_timeout', 10)
154
+ cfg.setdefault('read_timeout', 900)
155
+ cfg.setdefault('write_timeout', 900)
156
+ for k in ('connect_timeout', 'read_timeout', 'write_timeout'):
157
+ cfg[k] = int(cfg[k]) # ini values arrive as strings; drivers want ints
158
+ return MySQLConnectionWrapper(pymysql.connect(**cfg))
142
159
 
143
160
  def all_clients(self) -> Dict[str, Any]:
144
161
  return {name: entry['client'] for name, entry in self._cache.items()}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlbuddy-nwi"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "MCP server for read-only database access (MySQL, ClickHouse)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlbuddy-nwi
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: MCP server for read-only database access (MySQL, ClickHouse)
5
5
  Author: ying.yuxiang
6
6
  License: MIT
@@ -67,19 +67,40 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
67
67
 
68
68
  ## Quickstart
69
69
 
70
+ **Install** (current release **0.3.3**, Python ≥ 3.11):
71
+
70
72
  ```bash
71
- git clone <this-repo-url> sql-buddy && cd sql-buddy
72
- uv run server.py # installs deps into an isolated .venv, then serves (Ctrl+C to stop)
73
- uv run sql-buddy init # interactive wizard → writes connections/config/db.ini
74
- uv run sql-buddy doctor --connect # green-checks the venv, db.ini, tunnels, MCP client config
75
- uv run sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
73
+ # From PyPI (recommended):
74
+ pip install sqlbuddy-nwi # provides the `sql-buddy` command
75
+ # …or run the latest instantly without installing:
76
+ uvx --from sqlbuddy-nwi sql-buddy
76
77
  ```
77
78
 
79
+ ```bash
80
+ # From source (for development):
81
+ git clone https://github.com/yyx462/sql-buddy && cd sql-buddy
82
+ uv run server.py # auto-provisions an isolated .venv, then serves
83
+ ```
84
+
85
+ **Configure & connect:**
86
+
87
+ ```bash
88
+ sql-buddy init # interactive wizard → writes connections/config/db.ini
89
+ sql-buddy doctor --connect # green-checks db.ini, tunnels, MCP client config
90
+ sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
91
+ ```
92
+
93
+ > From a **git checkout**, prefix these with `uv run` (e.g. `uv run sql-buddy init`).
94
+ > From a **pip / uvx install**, run `sql-buddy` directly — it's on your PATH.
95
+
78
96
  Restart your client, then ask your agent *"list the tables in the `ck`
79
97
  connection"* — if it answers, you're live. 🎉
80
98
 
81
99
  > **Bare `sql-buddy` (no command) runs the MCP server** — that's what your AI
82
100
  > client invokes, so configs hold no fragile absolute `.venv/bin/python` path.
101
+ > `sql-buddy mcp add` auto-detects how you installed and writes the right
102
+ > command: `uvx --from sqlbuddy-nwi sql-buddy` for a pip/uvx install,
103
+ > `uv --directory … run server.py` for a checkout.
83
104
  > Other commands: `version`, `doctor [--connect]`, `mcp print|add`, `update-check`, `init`.
84
105
  > On **Windows**, see [`docs/windows-trae.md`](docs/windows-trae.md) and use
85
106
  > `sql-buddy mcp add --client trae`.
@@ -91,8 +112,8 @@ connection"* — if it answers, you're live. 🎉
91
112
  Run the wizard (easiest) or copy the template:
92
113
 
93
114
  ```bash
94
- uv run sql-buddy init
95
- # or by hand:
115
+ sql-buddy init
116
+ # or, from a git checkout, copy the template and edit by hand:
96
117
  cp connections/config/db.ini.example connections/config/db.ini
97
118
  $EDITOR connections/config/db.ini
98
119
  ```
@@ -140,13 +161,13 @@ reference, SSH troubleshooting, and External (`ssh -N`) mode, see
140
161
  ## Connect your AI agent
141
162
 
142
163
  ```bash
143
- uv run sql-buddy mcp add # Claude Code project → ./.mcp.json
144
- uv run sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
145
- uv run sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
146
- uv run sql-buddy mcp add --client trae --scope user # Trae user-global
164
+ sql-buddy mcp add # Claude Code project → ./.mcp.json
165
+ sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
166
+ sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
167
+ sql-buddy mcp add --client trae --scope user # Trae user-global
147
168
  ```
148
169
 
149
- Restart your client. Preview the exact block first with `uv run sql-buddy mcp print`
170
+ Restart your client. Preview the exact block first with `sql-buddy mcp print`
150
171
  (paths filled in):
151
172
 
152
173
  ```json
@@ -21,6 +21,7 @@ sqlbuddy_nwi.egg-info/dependency_links.txt
21
21
  sqlbuddy_nwi.egg-info/entry_points.txt
22
22
  sqlbuddy_nwi.egg-info/requires.txt
23
23
  sqlbuddy_nwi.egg-info/top_level.txt
24
+ tests/test_client_timeouts.py
24
25
  tests/test_get_info.py
25
26
  tests/test_parse_create_table.py
26
27
  tests/test_sanitize_search.py
@@ -0,0 +1,103 @@
1
+ """Writer/reader contract: _create_* injects default timeouts, db.ini wins.
2
+
3
+ Receipt (2026-11-04): a sql-buddy MCP process pinned the mini at ~950% CPU
4
+ with 83 worker threads parked forever in socket reads — pymysql/clickhouse
5
+ clients were built with no connect/read timeouts, so a black-holed tunnel
6
+ endpoint parked every calling thread permanently. These tests pin the fix:
7
+ defaults are injected, explicit section keys override them.
8
+ """
9
+
10
+ import configparser
11
+ import threading
12
+
13
+ import pytest
14
+
15
+ from connections.manager import DBManager
16
+
17
+
18
+ class _Capture:
19
+ """Records the kwargs it was constructed with; never touches a network."""
20
+
21
+ def __init__(self, **kwargs):
22
+ self.kwargs = kwargs
23
+
24
+
25
+ @pytest.fixture
26
+ def manager(tmp_path, monkeypatch):
27
+ ini = tmp_path / "db.ini"
28
+ ini.write_text(
29
+ "[ck-default]\n"
30
+ "type = clickhouse\n"
31
+ "host = db.example\n"
32
+ "\n"
33
+ "[ck-explicit]\n"
34
+ "type = clickhouse\n"
35
+ "host = db.example\n"
36
+ "connect_timeout = 3\n"
37
+ "send_receive_timeout = 60\n"
38
+ "\n"
39
+ "[mysql-default]\n"
40
+ "type = mysql\n"
41
+ "host = db.example\n"
42
+ "\n"
43
+ "[mysql-explicit]\n"
44
+ "type = mysql\n"
45
+ "host = db.example\n"
46
+ "connect_timeout = 2\n"
47
+ "read_timeout = 30\n"
48
+ )
49
+
50
+ # No tunnels in these sections -> TunnelManager stays idle.
51
+ import connections.manager as m
52
+ monkeypatch.setattr(m, "_get_tunnel_manager",
53
+ lambda: type("T", (), {"block_for": staticmethod(lambda s: None),
54
+ "local_port_for": staticmethod(lambda s: None),
55
+ "generation": staticmethod(lambda b: 0)})())
56
+ return DBManager(str(ini))
57
+
58
+
59
+ def test_clickhouse_defaults_injected(manager, monkeypatch):
60
+ seen = {}
61
+ monkeypatch.setattr("connections.manager.Client",
62
+ lambda **kw: seen.update(kw) or _Capture(**kw))
63
+ manager._create_clickhouse_client("ck-default")
64
+ assert seen["connect_timeout"] == 10
65
+ assert seen["send_receive_timeout"] == 900
66
+
67
+
68
+ def test_clickhouse_section_overrides(manager, monkeypatch):
69
+ seen = {}
70
+ monkeypatch.setattr("connections.manager.Client",
71
+ lambda **kw: seen.update(kw) or _Capture(**kw))
72
+ manager._create_clickhouse_client("ck-explicit")
73
+ assert seen["connect_timeout"] == 3
74
+ assert seen["send_receive_timeout"] == 60
75
+
76
+
77
+ def test_mysql_defaults_injected(manager, monkeypatch):
78
+ seen = {}
79
+
80
+ class _Conn(_Capture):
81
+ pass
82
+
83
+ monkeypatch.setattr("connections.manager.pymysql",
84
+ type("P", (), {"connect": staticmethod(lambda **kw: seen.update(kw) or _Conn(**kw))}))
85
+ manager._create_mysql_client("mysql-default")
86
+ assert seen["connect_timeout"] == 10
87
+ assert seen["read_timeout"] == 900
88
+ assert seen["write_timeout"] == 900
89
+
90
+
91
+ def test_mysql_section_overrides(manager, monkeypatch):
92
+ seen = {}
93
+
94
+ class _Conn(_Capture):
95
+ pass
96
+
97
+ monkeypatch.setattr("connections.manager.pymysql",
98
+ type("P", (), {"connect": staticmethod(lambda **kw: seen.update(kw) or _Conn(**kw))}))
99
+ manager._create_mysql_client("mysql-explicit")
100
+ assert seen["connect_timeout"] == 2
101
+ assert seen["read_timeout"] == 30
102
+ # Not set in the section -> default still injected alongside overrides.
103
+ assert seen["write_timeout"] == 900
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes