樹狀結構修整&各欄位提示資料建立與快取設計

This commit is contained in:
swpa 2026-05-08 16:50:12 +08:00
parent cd5ab201f5
commit e101c615b0
48 changed files with 72369 additions and 80 deletions

View File

@ -0,0 +1,126 @@
"""A Python driver for PostgreSQL
psycopg is a PostgreSQL_ database adapter for the Python_ programming
language. This is version 2, a complete rewrite of the original code to
provide new-style classes for connection and cursor objects and other sweet
candies. Like the original, psycopg 2 was written with the aim of being very
small and fast, and stable as a rock.
Homepage: https://psycopg.org/
.. _PostgreSQL: https://www.postgresql.org/
.. _Python: https://www.python.org/
:Groups:
* `Connections creation`: connect
* `Value objects constructors`: Binary, Date, DateFromTicks, Time,
TimeFromTicks, Timestamp, TimestampFromTicks
"""
# psycopg/__init__.py - initialization of the psycopg module
#
# Copyright (C) 2003-2019 Federico Di Gregorio <fog@debian.org>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
# Import modules needed by _psycopg to allow tools like py2exe to do
# their work without bothering about the module dependencies.
# Note: the first internal import should be _psycopg, otherwise the real cause
# of a failed loading of the C module may get hidden, see
# https://archives.postgresql.org/psycopg/2011-02/msg00044.php
# Import the DBAPI-2.0 stuff into top-level module.
from psycopg2._psycopg import ( # noqa
BINARY, NUMBER, STRING, DATETIME, ROWID,
Binary, Date, Time, Timestamp,
DateFromTicks, TimeFromTicks, TimestampFromTicks,
Error, Warning, DataError, DatabaseError, ProgrammingError, IntegrityError,
InterfaceError, InternalError, NotSupportedError, OperationalError,
_connect, apilevel, threadsafety, paramstyle,
__version__, __libpq_version__,
)
# Register default adapters.
from psycopg2 import extensions as _ext
_ext.register_adapter(tuple, _ext.SQL_IN)
_ext.register_adapter(type(None), _ext.NoneAdapter)
# Register the Decimal adapter here instead of in the C layer.
# This way a new class is registered for each sub-interpreter.
# See ticket #52
from decimal import Decimal # noqa
from psycopg2._psycopg import Decimal as Adapter # noqa
_ext.register_adapter(Decimal, Adapter)
del Decimal, Adapter
def connect(dsn=None, connection_factory=None, cursor_factory=None, **kwargs):
"""
Create a new database connection.
The connection parameters can be specified as a string:
conn = psycopg2.connect("dbname=test user=postgres password=secret")
or using a set of keyword arguments:
conn = psycopg2.connect(database="test", user="postgres", password="secret")
Or as a mix of both. The basic connection parameters are:
- *dbname*: the database name
- *database*: the database name (only as keyword argument)
- *user*: user name used to authenticate
- *password*: password used to authenticate
- *host*: database host address (defaults to UNIX socket if not provided)
- *port*: connection port number (defaults to 5432 if not provided)
Using the *connection_factory* parameter a different class or connections
factory can be specified. It should be a callable object taking a dsn
argument.
Using the *cursor_factory* parameter, a new default cursor factory will be
used by cursor().
Using *async*=True an asynchronous connection will be created. *async_* is
a valid alias (for Python versions where ``async`` is a keyword).
Any other keyword parameter will be passed to the underlying client
library: the list of supported parameters depends on the library version.
"""
kwasync = {}
if 'async' in kwargs:
kwasync['async'] = kwargs.pop('async')
if 'async_' in kwargs:
kwasync['async_'] = kwargs.pop('async_')
dsn = _ext.make_dsn(dsn, **kwargs)
conn = _connect(dsn, connection_factory=connection_factory, **kwasync)
if cursor_factory is not None:
conn.cursor_factory = cursor_factory
return conn

View File

@ -0,0 +1,90 @@
"""Implementation of the ipaddres-based network types adaptation
"""
# psycopg/_ipaddress.py - Ipaddres-based network types adaptation
#
# Copyright (C) 2016-2019 Daniele Varrazzo <daniele.varrazzo@gmail.com>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
from psycopg2.extensions import (
new_type, new_array_type, register_type, register_adapter, QuotedString)
# The module is imported on register_ipaddress
ipaddress = None
# The typecasters are created only once
_casters = None
def register_ipaddress(conn_or_curs=None):
"""
Register conversion support between `ipaddress` objects and `network types`__.
:param conn_or_curs: the scope where to register the type casters.
If `!None` register them globally.
After the function is called, PostgreSQL :sql:`inet` values will be
converted into `~ipaddress.IPv4Interface` or `~ipaddress.IPv6Interface`
objects, :sql:`cidr` values into into `~ipaddress.IPv4Network` or
`~ipaddress.IPv6Network`.
.. __: https://www.postgresql.org/docs/current/static/datatype-net-types.html
"""
global ipaddress
import ipaddress
global _casters
if _casters is None:
_casters = _make_casters()
for c in _casters:
register_type(c, conn_or_curs)
for t in [ipaddress.IPv4Interface, ipaddress.IPv6Interface,
ipaddress.IPv4Network, ipaddress.IPv6Network]:
register_adapter(t, adapt_ipaddress)
def _make_casters():
inet = new_type((869,), 'INET', cast_interface)
ainet = new_array_type((1041,), 'INET[]', inet)
cidr = new_type((650,), 'CIDR', cast_network)
acidr = new_array_type((651,), 'CIDR[]', cidr)
return [inet, ainet, cidr, acidr]
def cast_interface(s, cur=None):
if s is None:
return None
# Py2 version force the use of unicode. meh.
return ipaddress.ip_interface(str(s))
def cast_network(s, cur=None):
if s is None:
return None
return ipaddress.ip_network(str(s))
def adapt_ipaddress(obj):
return QuotedString(str(obj))

View File

@ -0,0 +1,199 @@
"""Implementation of the JSON adaptation objects
This module exists to avoid a circular import problem: pyscopg2.extras depends
on psycopg2.extension, so I can't create the default JSON typecasters in
extensions importing register_json from extras.
"""
# psycopg/_json.py - Implementation of the JSON adaptation objects
#
# Copyright (C) 2012-2019 Daniele Varrazzo <daniele.varrazzo@gmail.com>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
import json
from psycopg2._psycopg import ISQLQuote, QuotedString
from psycopg2._psycopg import new_type, new_array_type, register_type
# oids from PostgreSQL 9.2
JSON_OID = 114
JSONARRAY_OID = 199
# oids from PostgreSQL 9.4
JSONB_OID = 3802
JSONBARRAY_OID = 3807
class Json:
"""
An `~psycopg2.extensions.ISQLQuote` wrapper to adapt a Python object to
:sql:`json` data type.
`!Json` can be used to wrap any object supported by the provided *dumps*
function. If none is provided, the standard :py:func:`json.dumps()` is
used.
"""
def __init__(self, adapted, dumps=None):
self.adapted = adapted
self._conn = None
self._dumps = dumps or json.dumps
def __conform__(self, proto):
if proto is ISQLQuote:
return self
def dumps(self, obj):
"""Serialize *obj* in JSON format.
The default is to call `!json.dumps()` or the *dumps* function
provided in the constructor. You can override this method to create a
customized JSON wrapper.
"""
return self._dumps(obj)
def prepare(self, conn):
self._conn = conn
def getquoted(self):
s = self.dumps(self.adapted)
qs = QuotedString(s)
if self._conn is not None:
qs.prepare(self._conn)
return qs.getquoted()
def __str__(self):
# getquoted is binary
return self.getquoted().decode('ascii', 'replace')
def register_json(conn_or_curs=None, globally=False, loads=None,
oid=None, array_oid=None, name='json'):
"""Create and register typecasters converting :sql:`json` type to Python objects.
:param conn_or_curs: a connection or cursor used to find the :sql:`json`
and :sql:`json[]` oids; the typecasters are registered in a scope
limited to this object, unless *globally* is set to `!True`. It can be
`!None` if the oids are provided
:param globally: if `!False` register the typecasters only on
*conn_or_curs*, otherwise register them globally
:param loads: the function used to parse the data into a Python object. If
`!None` use `!json.loads()`, where `!json` is the module chosen
according to the Python version (see above)
:param oid: the OID of the :sql:`json` type if known; If not, it will be
queried on *conn_or_curs*
:param array_oid: the OID of the :sql:`json[]` array type if known;
if not, it will be queried on *conn_or_curs*
:param name: the name of the data type to look for in *conn_or_curs*
The connection or cursor passed to the function will be used to query the
database and look for the OID of the :sql:`json` type (or an alternative
type if *name* if provided). No query is performed if *oid* and *array_oid*
are provided. Raise `~psycopg2.ProgrammingError` if the type is not found.
"""
if oid is None:
oid, array_oid = _get_json_oids(conn_or_curs, name)
JSON, JSONARRAY = _create_json_typecasters(
oid, array_oid, loads=loads, name=name.upper())
register_type(JSON, not globally and conn_or_curs or None)
if JSONARRAY is not None:
register_type(JSONARRAY, not globally and conn_or_curs or None)
return JSON, JSONARRAY
def register_default_json(conn_or_curs=None, globally=False, loads=None):
"""
Create and register :sql:`json` typecasters for PostgreSQL 9.2 and following.
Since PostgreSQL 9.2 :sql:`json` is a builtin type, hence its oid is known
and fixed. This function allows specifying a customized *loads* function
for the default :sql:`json` type without querying the database.
All the parameters have the same meaning of `register_json()`.
"""
return register_json(conn_or_curs=conn_or_curs, globally=globally,
loads=loads, oid=JSON_OID, array_oid=JSONARRAY_OID)
def register_default_jsonb(conn_or_curs=None, globally=False, loads=None):
"""
Create and register :sql:`jsonb` typecasters for PostgreSQL 9.4 and following.
As in `register_default_json()`, the function allows to register a
customized *loads* function for the :sql:`jsonb` type at its known oid for
PostgreSQL 9.4 and following versions. All the parameters have the same
meaning of `register_json()`.
"""
return register_json(conn_or_curs=conn_or_curs, globally=globally,
loads=loads, oid=JSONB_OID, array_oid=JSONBARRAY_OID, name='jsonb')
def _create_json_typecasters(oid, array_oid, loads=None, name='JSON'):
"""Create typecasters for json data type."""
if loads is None:
loads = json.loads
def typecast_json(s, cur):
if s is None:
return None
return loads(s)
JSON = new_type((oid, ), name, typecast_json)
if array_oid is not None:
JSONARRAY = new_array_type((array_oid, ), f"{name}ARRAY", JSON)
else:
JSONARRAY = None
return JSON, JSONARRAY
def _get_json_oids(conn_or_curs, name='json'):
# lazy imports
from psycopg2.extensions import STATUS_IN_TRANSACTION
from psycopg2.extras import _solve_conn_curs
conn, curs = _solve_conn_curs(conn_or_curs)
# Store the transaction status of the connection to revert it after use
conn_status = conn.status
# column typarray not available before PG 8.3
typarray = conn.info.server_version >= 80300 and "typarray" or "NULL"
# get the oid for the hstore
curs.execute(
"SELECT t.oid, %s FROM pg_type t WHERE t.typname = %%s;"
% typarray, (name,))
r = curs.fetchone()
# revert the status of the connection as before the command
if conn_status != STATUS_IN_TRANSACTION and not conn.autocommit:
conn.rollback()
if not r:
raise conn.ProgrammingError(f"{name} data type not found")
return r

View File

@ -0,0 +1,554 @@
"""Implementation of the Range type and adaptation
"""
# psycopg/_range.py - Implementation of the Range type and adaptation
#
# Copyright (C) 2012-2019 Daniele Varrazzo <daniele.varrazzo@gmail.com>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
import re
from psycopg2._psycopg import ProgrammingError, InterfaceError
from psycopg2.extensions import ISQLQuote, adapt, register_adapter
from psycopg2.extensions import new_type, new_array_type, register_type
class Range:
"""Python representation for a PostgreSQL |range|_ type.
:param lower: lower bound for the range. `!None` means unbound
:param upper: upper bound for the range. `!None` means unbound
:param bounds: one of the literal strings ``()``, ``[)``, ``(]``, ``[]``,
representing whether the lower or upper bounds are included
:param empty: if `!True`, the range is empty
"""
__slots__ = ('_lower', '_upper', '_bounds')
def __init__(self, lower=None, upper=None, bounds='[)', empty=False):
if not empty:
if bounds not in ('[)', '(]', '()', '[]'):
raise ValueError(f"bound flags not valid: {bounds!r}")
self._lower = lower
self._upper = upper
self._bounds = bounds
else:
self._lower = self._upper = self._bounds = None
def __repr__(self):
if self._bounds is None:
return f"{self.__class__.__name__}(empty=True)"
else:
return "{}({!r}, {!r}, {!r})".format(self.__class__.__name__,
self._lower, self._upper, self._bounds)
def __str__(self):
if self._bounds is None:
return 'empty'
items = [
self._bounds[0],
str(self._lower),
', ',
str(self._upper),
self._bounds[1]
]
return ''.join(items)
@property
def lower(self):
"""The lower bound of the range. `!None` if empty or unbound."""
return self._lower
@property
def upper(self):
"""The upper bound of the range. `!None` if empty or unbound."""
return self._upper
@property
def isempty(self):
"""`!True` if the range is empty."""
return self._bounds is None
@property
def lower_inf(self):
"""`!True` if the range doesn't have a lower bound."""
if self._bounds is None:
return False
return self._lower is None
@property
def upper_inf(self):
"""`!True` if the range doesn't have an upper bound."""
if self._bounds is None:
return False
return self._upper is None
@property
def lower_inc(self):
"""`!True` if the lower bound is included in the range."""
if self._bounds is None or self._lower is None:
return False
return self._bounds[0] == '['
@property
def upper_inc(self):
"""`!True` if the upper bound is included in the range."""
if self._bounds is None or self._upper is None:
return False
return self._bounds[1] == ']'
def __contains__(self, x):
if self._bounds is None:
return False
if self._lower is not None:
if self._bounds[0] == '[':
if x < self._lower:
return False
else:
if x <= self._lower:
return False
if self._upper is not None:
if self._bounds[1] == ']':
if x > self._upper:
return False
else:
if x >= self._upper:
return False
return True
def __bool__(self):
return self._bounds is not None
def __eq__(self, other):
if not isinstance(other, Range):
return False
return (self._lower == other._lower
and self._upper == other._upper
and self._bounds == other._bounds)
def __ne__(self, other):
return not self.__eq__(other)
def __hash__(self):
return hash((self._lower, self._upper, self._bounds))
# as the postgres docs describe for the server-side stuff,
# ordering is rather arbitrary, but will remain stable
# and consistent.
def __lt__(self, other):
if not isinstance(other, Range):
return NotImplemented
for attr in ('_lower', '_upper', '_bounds'):
self_value = getattr(self, attr)
other_value = getattr(other, attr)
if self_value == other_value:
pass
elif self_value is None:
return True
elif other_value is None:
return False
else:
return self_value < other_value
return False
def __le__(self, other):
if self == other:
return True
else:
return self.__lt__(other)
def __gt__(self, other):
if isinstance(other, Range):
return other.__lt__(self)
else:
return NotImplemented
def __ge__(self, other):
if self == other:
return True
else:
return self.__gt__(other)
def __getstate__(self):
return {slot: getattr(self, slot)
for slot in self.__slots__ if hasattr(self, slot)}
def __setstate__(self, state):
for slot, value in state.items():
setattr(self, slot, value)
def register_range(pgrange, pyrange, conn_or_curs, globally=False):
"""Create and register an adapter and the typecasters to convert between
a PostgreSQL |range|_ type and a PostgreSQL `Range` subclass.
:param pgrange: the name of the PostgreSQL |range| type. Can be
schema-qualified
:param pyrange: a `Range` strict subclass, or just a name to give to a new
class
:param conn_or_curs: a connection or cursor used to find the oid of the
range and its subtype; the typecaster is registered in a scope limited
to this object, unless *globally* is set to `!True`
:param globally: if `!False` (default) register the typecaster only on
*conn_or_curs*, otherwise register it globally
:return: `RangeCaster` instance responsible for the conversion
If a string is passed to *pyrange*, a new `Range` subclass is created
with such name and will be available as the `~RangeCaster.range` attribute
of the returned `RangeCaster` object.
The function queries the database on *conn_or_curs* to inspect the
*pgrange* type and raises `~psycopg2.ProgrammingError` if the type is not
found. If querying the database is not advisable, use directly the
`RangeCaster` class and register the adapter and typecasters using the
provided functions.
"""
caster = RangeCaster._from_db(pgrange, pyrange, conn_or_curs)
caster._register(not globally and conn_or_curs or None)
return caster
class RangeAdapter:
"""`ISQLQuote` adapter for `Range` subclasses.
This is an abstract class: concrete classes must set a `name` class
attribute or override `getquoted()`.
"""
name = None
def __init__(self, adapted):
self.adapted = adapted
def __conform__(self, proto):
if self._proto is ISQLQuote:
return self
def prepare(self, conn):
self._conn = conn
def getquoted(self):
if self.name is None:
raise NotImplementedError(
'RangeAdapter must be subclassed overriding its name '
'or the getquoted() method')
r = self.adapted
if r.isempty:
return b"'empty'::" + self.name.encode('utf8')
if r.lower is not None:
a = adapt(r.lower)
if hasattr(a, 'prepare'):
a.prepare(self._conn)
lower = a.getquoted()
else:
lower = b'NULL'
if r.upper is not None:
a = adapt(r.upper)
if hasattr(a, 'prepare'):
a.prepare(self._conn)
upper = a.getquoted()
else:
upper = b'NULL'
return self.name.encode('utf8') + b'(' + lower + b', ' + upper \
+ b", '" + r._bounds.encode('utf8') + b"')"
class RangeCaster:
"""Helper class to convert between `Range` and PostgreSQL range types.
Objects of this class are usually created by `register_range()`. Manual
creation could be useful if querying the database is not advisable: in
this case the oids must be provided.
"""
def __init__(self, pgrange, pyrange, oid, subtype_oid, array_oid=None):
self.subtype_oid = subtype_oid
self._create_ranges(pgrange, pyrange)
name = self.adapter.name or self.adapter.__class__.__name__
self.typecaster = new_type((oid,), name, self.parse)
if array_oid is not None:
self.array_typecaster = new_array_type(
(array_oid,), name + "ARRAY", self.typecaster)
else:
self.array_typecaster = None
def _create_ranges(self, pgrange, pyrange):
"""Create Range and RangeAdapter classes if needed."""
# if got a string create a new RangeAdapter concrete type (with a name)
# else take it as an adapter. Passing an adapter should be considered
# an implementation detail and is not documented. It is currently used
# for the numeric ranges.
self.adapter = None
if isinstance(pgrange, str):
self.adapter = type(pgrange, (RangeAdapter,), {})
self.adapter.name = pgrange
else:
try:
if issubclass(pgrange, RangeAdapter) \
and pgrange is not RangeAdapter:
self.adapter = pgrange
except TypeError:
pass
if self.adapter is None:
raise TypeError(
'pgrange must be a string or a RangeAdapter strict subclass')
self.range = None
try:
if isinstance(pyrange, str):
self.range = type(pyrange, (Range,), {})
if issubclass(pyrange, Range) and pyrange is not Range:
self.range = pyrange
except TypeError:
pass
if self.range is None:
raise TypeError(
'pyrange must be a type or a Range strict subclass')
@classmethod
def _from_db(self, name, pyrange, conn_or_curs):
"""Return a `RangeCaster` instance for the type *pgrange*.
Raise `ProgrammingError` if the type is not found.
"""
from psycopg2.extensions import STATUS_IN_TRANSACTION
from psycopg2.extras import _solve_conn_curs
conn, curs = _solve_conn_curs(conn_or_curs)
if conn.info.server_version < 90200:
raise ProgrammingError("range types not available in version %s"
% conn.info.server_version)
# Store the transaction status of the connection to revert it after use
conn_status = conn.status
# Use the correct schema
if '.' in name:
schema, tname = name.split('.', 1)
else:
tname = name
schema = 'public'
# get the type oid and attributes
curs.execute("""\
select rngtypid, rngsubtype, typarray
from pg_range r
join pg_type t on t.oid = rngtypid
join pg_namespace ns on ns.oid = typnamespace
where typname = %s and ns.nspname = %s;
""", (tname, schema))
rec = curs.fetchone()
if not rec:
# The above algorithm doesn't work for customized seach_path
# (#1487) The implementation below works better, but, to guarantee
# backwards compatibility, use it only if the original one failed.
try:
savepoint = False
# Because we executed statements earlier, we are either INTRANS
# or we are IDLE only if the transaction is autocommit, in
# which case we don't need the savepoint anyway.
if conn.status == STATUS_IN_TRANSACTION:
curs.execute("SAVEPOINT register_type")
savepoint = True
curs.execute("""\
SELECT rngtypid, rngsubtype, typarray, typname, nspname
from pg_range r
join pg_type t on t.oid = rngtypid
join pg_namespace ns on ns.oid = typnamespace
WHERE t.oid = %s::regtype
""", (name, ))
except ProgrammingError:
pass
else:
rec = curs.fetchone()
if rec:
tname, schema = rec[3:]
finally:
if savepoint:
curs.execute("ROLLBACK TO SAVEPOINT register_type")
# revert the status of the connection as before the command
if conn_status != STATUS_IN_TRANSACTION and not conn.autocommit:
conn.rollback()
if not rec:
raise ProgrammingError(
f"PostgreSQL range '{name}' not found")
type, subtype, array = rec[:3]
return RangeCaster(name, pyrange,
oid=type, subtype_oid=subtype, array_oid=array)
_re_range = re.compile(r"""
( \(|\[ ) # lower bound flag
(?: # lower bound:
" ( (?: [^"] | "")* ) " # - a quoted string
| ( [^",]+ ) # - or an unquoted string
)? # - or empty (not catched)
,
(?: # upper bound:
" ( (?: [^"] | "")* ) " # - a quoted string
| ( [^"\)\]]+ ) # - or an unquoted string
)? # - or empty (not catched)
( \)|\] ) # upper bound flag
""", re.VERBOSE)
_re_undouble = re.compile(r'(["\\])\1')
def parse(self, s, cur=None):
if s is None:
return None
if s == 'empty':
return self.range(empty=True)
m = self._re_range.match(s)
if m is None:
raise InterfaceError(f"failed to parse range: '{s}'")
lower = m.group(3)
if lower is None:
lower = m.group(2)
if lower is not None:
lower = self._re_undouble.sub(r"\1", lower)
upper = m.group(5)
if upper is None:
upper = m.group(4)
if upper is not None:
upper = self._re_undouble.sub(r"\1", upper)
if cur is not None:
lower = cur.cast(self.subtype_oid, lower)
upper = cur.cast(self.subtype_oid, upper)
bounds = m.group(1) + m.group(6)
return self.range(lower, upper, bounds)
def _register(self, scope=None):
register_type(self.typecaster, scope)
if self.array_typecaster is not None:
register_type(self.array_typecaster, scope)
register_adapter(self.range, self.adapter)
class NumericRange(Range):
"""A `Range` suitable to pass Python numeric types to a PostgreSQL range.
PostgreSQL types :sql:`int4range`, :sql:`int8range`, :sql:`numrange` are
casted into `!NumericRange` instances.
"""
pass
class DateRange(Range):
"""Represents :sql:`daterange` values."""
pass
class DateTimeRange(Range):
"""Represents :sql:`tsrange` values."""
pass
class DateTimeTZRange(Range):
"""Represents :sql:`tstzrange` values."""
pass
# Special adaptation for NumericRange. Allows to pass number range regardless
# of whether they are ints, floats and what size of ints are, which are
# pointless in Python world. On the way back, no numeric range is casted to
# NumericRange, but only to their subclasses
class NumberRangeAdapter(RangeAdapter):
"""Adapt a range if the subtype doesn't need quotes."""
def getquoted(self):
r = self.adapted
if r.isempty:
return b"'empty'"
if not r.lower_inf:
# not exactly: we are relying that none of these object is really
# quoted (they are numbers). Also, I'm lazy and not preparing the
# adapter because I assume encoding doesn't matter for these
# objects.
lower = adapt(r.lower).getquoted().decode('ascii')
else:
lower = ''
if not r.upper_inf:
upper = adapt(r.upper).getquoted().decode('ascii')
else:
upper = ''
return (f"'{r._bounds[0]}{lower},{upper}{r._bounds[1]}'").encode('ascii')
# TODO: probably won't work with infs, nans and other tricky cases.
register_adapter(NumericRange, NumberRangeAdapter)
# Register globally typecasters and adapters for builtin range types.
# note: the adapter is registered more than once, but this is harmless.
int4range_caster = RangeCaster(NumberRangeAdapter, NumericRange,
oid=3904, subtype_oid=23, array_oid=3905)
int4range_caster._register()
int8range_caster = RangeCaster(NumberRangeAdapter, NumericRange,
oid=3926, subtype_oid=20, array_oid=3927)
int8range_caster._register()
numrange_caster = RangeCaster(NumberRangeAdapter, NumericRange,
oid=3906, subtype_oid=1700, array_oid=3907)
numrange_caster._register()
daterange_caster = RangeCaster('daterange', DateRange,
oid=3912, subtype_oid=1082, array_oid=3913)
daterange_caster._register()
tsrange_caster = RangeCaster('tsrange', DateTimeRange,
oid=3908, subtype_oid=1114, array_oid=3909)
tsrange_caster._register()
tstzrange_caster = RangeCaster('tstzrange', DateTimeTZRange,
oid=3910, subtype_oid=1184, array_oid=3911)
tstzrange_caster._register()

View File

@ -0,0 +1,455 @@
"""Error codes for PostgreSQL
This module contains symbolic names for all PostgreSQL error codes.
"""
# psycopg2/errorcodes.py - PostgreSQL error codes
#
# Copyright (C) 2006-2019 Johan Dahlin <jdahlin@async.com.br>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
#
# Based on:
#
# https://www.postgresql.org/docs/current/static/errcodes-appendix.html
#
def lookup(code, _cache={}):
"""Lookup an error code or class code and return its symbolic name.
Raise `KeyError` if the code is not found.
"""
if _cache:
return _cache[code]
# Generate the lookup map at first usage.
tmp = {}
for k, v in globals().items():
if isinstance(v, str) and len(v) in (2, 5):
# Strip trailing underscore used to disambiguate duplicate values
tmp[v] = k.rstrip("_")
assert tmp
# Atomic update, to avoid race condition on import (bug #382)
_cache.update(tmp)
return _cache[code]
# autogenerated data: do not edit below this point.
# Error classes
CLASS_SUCCESSFUL_COMPLETION = '00'
CLASS_WARNING = '01'
CLASS_NO_DATA = '02'
CLASS_SQL_STATEMENT_NOT_YET_COMPLETE = '03'
CLASS_CONNECTION_EXCEPTION = '08'
CLASS_TRIGGERED_ACTION_EXCEPTION = '09'
CLASS_FEATURE_NOT_SUPPORTED = '0A'
CLASS_INVALID_TRANSACTION_INITIATION = '0B'
CLASS_LOCATOR_EXCEPTION = '0F'
CLASS_INVALID_GRANTOR = '0L'
CLASS_INVALID_ROLE_SPECIFICATION = '0P'
CLASS_DIAGNOSTICS_EXCEPTION = '0Z'
CLASS_XQUERY_ERROR = '10'
CLASS_CASE_NOT_FOUND = '20'
CLASS_CARDINALITY_VIOLATION = '21'
CLASS_DATA_EXCEPTION = '22'
CLASS_INTEGRITY_CONSTRAINT_VIOLATION = '23'
CLASS_INVALID_CURSOR_STATE = '24'
CLASS_INVALID_TRANSACTION_STATE = '25'
CLASS_INVALID_SQL_STATEMENT_NAME = '26'
CLASS_TRIGGERED_DATA_CHANGE_VIOLATION = '27'
CLASS_INVALID_AUTHORIZATION_SPECIFICATION = '28'
CLASS_DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST = '2B'
CLASS_INVALID_TRANSACTION_TERMINATION = '2D'
CLASS_SQL_ROUTINE_EXCEPTION = '2F'
CLASS_INVALID_CURSOR_NAME = '34'
CLASS_EXTERNAL_ROUTINE_EXCEPTION = '38'
CLASS_EXTERNAL_ROUTINE_INVOCATION_EXCEPTION = '39'
CLASS_SAVEPOINT_EXCEPTION = '3B'
CLASS_INVALID_CATALOG_NAME = '3D'
CLASS_INVALID_SCHEMA_NAME = '3F'
CLASS_TRANSACTION_ROLLBACK = '40'
CLASS_SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION = '42'
CLASS_WITH_CHECK_OPTION_VIOLATION = '44'
CLASS_INSUFFICIENT_RESOURCES = '53'
CLASS_PROGRAM_LIMIT_EXCEEDED = '54'
CLASS_OBJECT_NOT_IN_PREREQUISITE_STATE = '55'
CLASS_OPERATOR_INTERVENTION = '57'
CLASS_SYSTEM_ERROR = '58'
CLASS_SNAPSHOT_FAILURE = '72'
CLASS_CONFIGURATION_FILE_ERROR = 'F0'
CLASS_FOREIGN_DATA_WRAPPER_ERROR = 'HV'
CLASS_PL_PGSQL_ERROR = 'P0'
CLASS_INTERNAL_ERROR = 'XX'
# Class 00 - Successful Completion
SUCCESSFUL_COMPLETION = '00000'
# Class 01 - Warning
WARNING = '01000'
NULL_VALUE_ELIMINATED_IN_SET_FUNCTION = '01003'
STRING_DATA_RIGHT_TRUNCATION_ = '01004'
PRIVILEGE_NOT_REVOKED = '01006'
PRIVILEGE_NOT_GRANTED = '01007'
IMPLICIT_ZERO_BIT_PADDING = '01008'
DYNAMIC_RESULT_SETS_RETURNED = '0100C'
DEPRECATED_FEATURE = '01P01'
# Class 02 - No Data (this is also a warning class per the SQL standard)
NO_DATA = '02000'
NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED = '02001'
# Class 03 - SQL Statement Not Yet Complete
SQL_STATEMENT_NOT_YET_COMPLETE = '03000'
# Class 08 - Connection Exception
CONNECTION_EXCEPTION = '08000'
SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION = '08001'
CONNECTION_DOES_NOT_EXIST = '08003'
SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION = '08004'
CONNECTION_FAILURE = '08006'
TRANSACTION_RESOLUTION_UNKNOWN = '08007'
PROTOCOL_VIOLATION = '08P01'
# Class 09 - Triggered Action Exception
TRIGGERED_ACTION_EXCEPTION = '09000'
# Class 0A - Feature Not Supported
FEATURE_NOT_SUPPORTED = '0A000'
# Class 0B - Invalid Transaction Initiation
INVALID_TRANSACTION_INITIATION = '0B000'
# Class 0F - Locator Exception
LOCATOR_EXCEPTION = '0F000'
INVALID_LOCATOR_SPECIFICATION = '0F001'
# Class 0L - Invalid Grantor
INVALID_GRANTOR = '0L000'
INVALID_GRANT_OPERATION = '0LP01'
# Class 0P - Invalid Role Specification
INVALID_ROLE_SPECIFICATION = '0P000'
# Class 0Z - Diagnostics Exception
DIAGNOSTICS_EXCEPTION = '0Z000'
STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER = '0Z002'
# Class 10 - XQuery Error
INVALID_ARGUMENT_FOR_XQUERY = '10608'
# Class 20 - Case Not Found
CASE_NOT_FOUND = '20000'
# Class 21 - Cardinality Violation
CARDINALITY_VIOLATION = '21000'
# Class 22 - Data Exception
DATA_EXCEPTION = '22000'
STRING_DATA_RIGHT_TRUNCATION = '22001'
NULL_VALUE_NO_INDICATOR_PARAMETER = '22002'
NUMERIC_VALUE_OUT_OF_RANGE = '22003'
NULL_VALUE_NOT_ALLOWED_ = '22004'
ERROR_IN_ASSIGNMENT = '22005'
INVALID_DATETIME_FORMAT = '22007'
DATETIME_FIELD_OVERFLOW = '22008'
INVALID_TIME_ZONE_DISPLACEMENT_VALUE = '22009'
ESCAPE_CHARACTER_CONFLICT = '2200B'
INVALID_USE_OF_ESCAPE_CHARACTER = '2200C'
INVALID_ESCAPE_OCTET = '2200D'
ZERO_LENGTH_CHARACTER_STRING = '2200F'
MOST_SPECIFIC_TYPE_MISMATCH = '2200G'
SEQUENCE_GENERATOR_LIMIT_EXCEEDED = '2200H'
NOT_AN_XML_DOCUMENT = '2200L'
INVALID_XML_DOCUMENT = '2200M'
INVALID_XML_CONTENT = '2200N'
INVALID_XML_COMMENT = '2200S'
INVALID_XML_PROCESSING_INSTRUCTION = '2200T'
INVALID_INDICATOR_PARAMETER_VALUE = '22010'
SUBSTRING_ERROR = '22011'
DIVISION_BY_ZERO = '22012'
INVALID_PRECEDING_OR_FOLLOWING_SIZE = '22013'
INVALID_ARGUMENT_FOR_NTILE_FUNCTION = '22014'
INTERVAL_FIELD_OVERFLOW = '22015'
INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION = '22016'
INVALID_CHARACTER_VALUE_FOR_CAST = '22018'
INVALID_ESCAPE_CHARACTER = '22019'
INVALID_REGULAR_EXPRESSION = '2201B'
INVALID_ARGUMENT_FOR_LOGARITHM = '2201E'
INVALID_ARGUMENT_FOR_POWER_FUNCTION = '2201F'
INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION = '2201G'
INVALID_ROW_COUNT_IN_LIMIT_CLAUSE = '2201W'
INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE = '2201X'
INVALID_LIMIT_VALUE = '22020'
CHARACTER_NOT_IN_REPERTOIRE = '22021'
INDICATOR_OVERFLOW = '22022'
INVALID_PARAMETER_VALUE = '22023'
UNTERMINATED_C_STRING = '22024'
INVALID_ESCAPE_SEQUENCE = '22025'
STRING_DATA_LENGTH_MISMATCH = '22026'
TRIM_ERROR = '22027'
ARRAY_SUBSCRIPT_ERROR = '2202E'
INVALID_TABLESAMPLE_REPEAT = '2202G'
INVALID_TABLESAMPLE_ARGUMENT = '2202H'
DUPLICATE_JSON_OBJECT_KEY_VALUE = '22030'
INVALID_ARGUMENT_FOR_SQL_JSON_DATETIME_FUNCTION = '22031'
INVALID_JSON_TEXT = '22032'
INVALID_SQL_JSON_SUBSCRIPT = '22033'
MORE_THAN_ONE_SQL_JSON_ITEM = '22034'
NO_SQL_JSON_ITEM = '22035'
NON_NUMERIC_SQL_JSON_ITEM = '22036'
NON_UNIQUE_KEYS_IN_A_JSON_OBJECT = '22037'
SINGLETON_SQL_JSON_ITEM_REQUIRED = '22038'
SQL_JSON_ARRAY_NOT_FOUND = '22039'
SQL_JSON_MEMBER_NOT_FOUND = '2203A'
SQL_JSON_NUMBER_NOT_FOUND = '2203B'
SQL_JSON_OBJECT_NOT_FOUND = '2203C'
TOO_MANY_JSON_ARRAY_ELEMENTS = '2203D'
TOO_MANY_JSON_OBJECT_MEMBERS = '2203E'
SQL_JSON_SCALAR_REQUIRED = '2203F'
SQL_JSON_ITEM_CANNOT_BE_CAST_TO_TARGET_TYPE = '2203G'
FLOATING_POINT_EXCEPTION = '22P01'
INVALID_TEXT_REPRESENTATION = '22P02'
INVALID_BINARY_REPRESENTATION = '22P03'
BAD_COPY_FILE_FORMAT = '22P04'
UNTRANSLATABLE_CHARACTER = '22P05'
NONSTANDARD_USE_OF_ESCAPE_CHARACTER = '22P06'
# Class 23 - Integrity Constraint Violation
INTEGRITY_CONSTRAINT_VIOLATION = '23000'
RESTRICT_VIOLATION = '23001'
NOT_NULL_VIOLATION = '23502'
FOREIGN_KEY_VIOLATION = '23503'
UNIQUE_VIOLATION = '23505'
CHECK_VIOLATION = '23514'
EXCLUSION_VIOLATION = '23P01'
# Class 24 - Invalid Cursor State
INVALID_CURSOR_STATE = '24000'
# Class 25 - Invalid Transaction State
INVALID_TRANSACTION_STATE = '25000'
ACTIVE_SQL_TRANSACTION = '25001'
BRANCH_TRANSACTION_ALREADY_ACTIVE = '25002'
INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION = '25003'
INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION = '25004'
NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION = '25005'
READ_ONLY_SQL_TRANSACTION = '25006'
SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED = '25007'
HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL = '25008'
NO_ACTIVE_SQL_TRANSACTION = '25P01'
IN_FAILED_SQL_TRANSACTION = '25P02'
IDLE_IN_TRANSACTION_SESSION_TIMEOUT = '25P03'
TRANSACTION_TIMEOUT = '25P04'
# Class 26 - Invalid SQL Statement Name
INVALID_SQL_STATEMENT_NAME = '26000'
# Class 27 - Triggered Data Change Violation
TRIGGERED_DATA_CHANGE_VIOLATION = '27000'
# Class 28 - Invalid Authorization Specification
INVALID_AUTHORIZATION_SPECIFICATION = '28000'
INVALID_PASSWORD = '28P01'
# Class 2B - Dependent Privilege Descriptors Still Exist
DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST = '2B000'
DEPENDENT_OBJECTS_STILL_EXIST = '2BP01'
# Class 2D - Invalid Transaction Termination
INVALID_TRANSACTION_TERMINATION = '2D000'
# Class 2F - SQL Routine Exception
SQL_ROUTINE_EXCEPTION = '2F000'
MODIFYING_SQL_DATA_NOT_PERMITTED_ = '2F002'
PROHIBITED_SQL_STATEMENT_ATTEMPTED_ = '2F003'
READING_SQL_DATA_NOT_PERMITTED_ = '2F004'
FUNCTION_EXECUTED_NO_RETURN_STATEMENT = '2F005'
# Class 34 - Invalid Cursor Name
INVALID_CURSOR_NAME = '34000'
# Class 38 - External Routine Exception
EXTERNAL_ROUTINE_EXCEPTION = '38000'
CONTAINING_SQL_NOT_PERMITTED = '38001'
MODIFYING_SQL_DATA_NOT_PERMITTED = '38002'
PROHIBITED_SQL_STATEMENT_ATTEMPTED = '38003'
READING_SQL_DATA_NOT_PERMITTED = '38004'
# Class 39 - External Routine Invocation Exception
EXTERNAL_ROUTINE_INVOCATION_EXCEPTION = '39000'
INVALID_SQLSTATE_RETURNED = '39001'
NULL_VALUE_NOT_ALLOWED = '39004'
TRIGGER_PROTOCOL_VIOLATED = '39P01'
SRF_PROTOCOL_VIOLATED = '39P02'
EVENT_TRIGGER_PROTOCOL_VIOLATED = '39P03'
# Class 3B - Savepoint Exception
SAVEPOINT_EXCEPTION = '3B000'
INVALID_SAVEPOINT_SPECIFICATION = '3B001'
# Class 3D - Invalid Catalog Name
INVALID_CATALOG_NAME = '3D000'
# Class 3F - Invalid Schema Name
INVALID_SCHEMA_NAME = '3F000'
# Class 40 - Transaction Rollback
TRANSACTION_ROLLBACK = '40000'
SERIALIZATION_FAILURE = '40001'
TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION = '40002'
STATEMENT_COMPLETION_UNKNOWN = '40003'
DEADLOCK_DETECTED = '40P01'
# Class 42 - Syntax Error or Access Rule Violation
SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION = '42000'
INSUFFICIENT_PRIVILEGE = '42501'
SYNTAX_ERROR = '42601'
INVALID_NAME = '42602'
INVALID_COLUMN_DEFINITION = '42611'
NAME_TOO_LONG = '42622'
DUPLICATE_COLUMN = '42701'
AMBIGUOUS_COLUMN = '42702'
UNDEFINED_COLUMN = '42703'
UNDEFINED_OBJECT = '42704'
DUPLICATE_OBJECT = '42710'
DUPLICATE_ALIAS = '42712'
DUPLICATE_FUNCTION = '42723'
AMBIGUOUS_FUNCTION = '42725'
GROUPING_ERROR = '42803'
DATATYPE_MISMATCH = '42804'
WRONG_OBJECT_TYPE = '42809'
INVALID_FOREIGN_KEY = '42830'
CANNOT_COERCE = '42846'
UNDEFINED_FUNCTION = '42883'
GENERATED_ALWAYS = '428C9'
RESERVED_NAME = '42939'
UNDEFINED_TABLE = '42P01'
UNDEFINED_PARAMETER = '42P02'
DUPLICATE_CURSOR = '42P03'
DUPLICATE_DATABASE = '42P04'
DUPLICATE_PREPARED_STATEMENT = '42P05'
DUPLICATE_SCHEMA = '42P06'
DUPLICATE_TABLE = '42P07'
AMBIGUOUS_PARAMETER = '42P08'
AMBIGUOUS_ALIAS = '42P09'
INVALID_COLUMN_REFERENCE = '42P10'
INVALID_CURSOR_DEFINITION = '42P11'
INVALID_DATABASE_DEFINITION = '42P12'
INVALID_FUNCTION_DEFINITION = '42P13'
INVALID_PREPARED_STATEMENT_DEFINITION = '42P14'
INVALID_SCHEMA_DEFINITION = '42P15'
INVALID_TABLE_DEFINITION = '42P16'
INVALID_OBJECT_DEFINITION = '42P17'
INDETERMINATE_DATATYPE = '42P18'
INVALID_RECURSION = '42P19'
WINDOWING_ERROR = '42P20'
COLLATION_MISMATCH = '42P21'
INDETERMINATE_COLLATION = '42P22'
# Class 44 - WITH CHECK OPTION Violation
WITH_CHECK_OPTION_VIOLATION = '44000'
# Class 53 - Insufficient Resources
INSUFFICIENT_RESOURCES = '53000'
DISK_FULL = '53100'
OUT_OF_MEMORY = '53200'
TOO_MANY_CONNECTIONS = '53300'
CONFIGURATION_LIMIT_EXCEEDED = '53400'
# Class 54 - Program Limit Exceeded
PROGRAM_LIMIT_EXCEEDED = '54000'
STATEMENT_TOO_COMPLEX = '54001'
TOO_MANY_COLUMNS = '54011'
TOO_MANY_ARGUMENTS = '54023'
# Class 55 - Object Not In Prerequisite State
OBJECT_NOT_IN_PREREQUISITE_STATE = '55000'
OBJECT_IN_USE = '55006'
CANT_CHANGE_RUNTIME_PARAM = '55P02'
LOCK_NOT_AVAILABLE = '55P03'
UNSAFE_NEW_ENUM_VALUE_USAGE = '55P04'
# Class 57 - Operator Intervention
OPERATOR_INTERVENTION = '57000'
QUERY_CANCELED = '57014'
ADMIN_SHUTDOWN = '57P01'
CRASH_SHUTDOWN = '57P02'
CANNOT_CONNECT_NOW = '57P03'
DATABASE_DROPPED = '57P04'
IDLE_SESSION_TIMEOUT = '57P05'
# Class 58 - System Error (errors external to PostgreSQL itself)
SYSTEM_ERROR = '58000'
IO_ERROR = '58030'
UNDEFINED_FILE = '58P01'
DUPLICATE_FILE = '58P02'
FILE_NAME_TOO_LONG = '58P03'
# Class 72 - Snapshot Failure
SNAPSHOT_TOO_OLD = '72000'
# Class F0 - Configuration File Error
CONFIG_FILE_ERROR = 'F0000'
LOCK_FILE_EXISTS = 'F0001'
# Class HV - Foreign Data Wrapper Error (SQL/MED)
FDW_ERROR = 'HV000'
FDW_OUT_OF_MEMORY = 'HV001'
FDW_DYNAMIC_PARAMETER_VALUE_NEEDED = 'HV002'
FDW_INVALID_DATA_TYPE = 'HV004'
FDW_COLUMN_NAME_NOT_FOUND = 'HV005'
FDW_INVALID_DATA_TYPE_DESCRIPTORS = 'HV006'
FDW_INVALID_COLUMN_NAME = 'HV007'
FDW_INVALID_COLUMN_NUMBER = 'HV008'
FDW_INVALID_USE_OF_NULL_POINTER = 'HV009'
FDW_INVALID_STRING_FORMAT = 'HV00A'
FDW_INVALID_HANDLE = 'HV00B'
FDW_INVALID_OPTION_INDEX = 'HV00C'
FDW_INVALID_OPTION_NAME = 'HV00D'
FDW_OPTION_NAME_NOT_FOUND = 'HV00J'
FDW_REPLY_HANDLE = 'HV00K'
FDW_UNABLE_TO_CREATE_EXECUTION = 'HV00L'
FDW_UNABLE_TO_CREATE_REPLY = 'HV00M'
FDW_UNABLE_TO_ESTABLISH_CONNECTION = 'HV00N'
FDW_NO_SCHEMAS = 'HV00P'
FDW_SCHEMA_NOT_FOUND = 'HV00Q'
FDW_TABLE_NOT_FOUND = 'HV00R'
FDW_FUNCTION_SEQUENCE_ERROR = 'HV010'
FDW_TOO_MANY_HANDLES = 'HV014'
FDW_INCONSISTENT_DESCRIPTOR_INFORMATION = 'HV021'
FDW_INVALID_ATTRIBUTE_VALUE = 'HV024'
FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH = 'HV090'
FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER = 'HV091'
# Class P0 - PL/pgSQL Error
PLPGSQL_ERROR = 'P0000'
RAISE_EXCEPTION = 'P0001'
NO_DATA_FOUND = 'P0002'
TOO_MANY_ROWS = 'P0003'
ASSERT_FAILURE = 'P0004'
# Class XX - Internal Error
INTERNAL_ERROR = 'XX000'
DATA_CORRUPTED = 'XX001'
INDEX_CORRUPTED = 'XX002'

View File

@ -0,0 +1,38 @@
"""Error classes for PostgreSQL error codes
"""
# psycopg/errors.py - SQLSTATE and DB-API exceptions
#
# Copyright (C) 2018-2019 Daniele Varrazzo <daniele.varrazzo@gmail.com>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
#
# NOTE: the exceptions are injected into this module by the C extention.
#
def lookup(code):
"""Lookup an error code and return its exception class.
Raise `!KeyError` if the code is not found.
"""
from psycopg2._psycopg import sqlstate_errors # avoid circular import
return sqlstate_errors[code]

View File

@ -0,0 +1,213 @@
"""psycopg extensions to the DBAPI-2.0
This module holds all the extensions to the DBAPI-2.0 provided by psycopg.
- `connection` -- the new-type inheritable connection class
- `cursor` -- the new-type inheritable cursor class
- `lobject` -- the new-type inheritable large object class
- `adapt()` -- exposes the PEP-246_ compatible adapting mechanism used
by psycopg to adapt Python types to PostgreSQL ones
.. _PEP-246: https://www.python.org/dev/peps/pep-0246/
"""
# psycopg/extensions.py - DBAPI-2.0 extensions specific to psycopg
#
# Copyright (C) 2003-2019 Federico Di Gregorio <fog@debian.org>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
import re as _re
from psycopg2._psycopg import ( # noqa
BINARYARRAY, BOOLEAN, BOOLEANARRAY, BYTES, BYTESARRAY, DATE, DATEARRAY,
DATETIMEARRAY, DECIMAL, DECIMALARRAY, FLOAT, FLOATARRAY, INTEGER,
INTEGERARRAY, INTERVAL, INTERVALARRAY, LONGINTEGER, LONGINTEGERARRAY,
ROWIDARRAY, STRINGARRAY, TIME, TIMEARRAY, UNICODE, UNICODEARRAY,
AsIs, Binary, Boolean, Float, Int, QuotedString, )
from psycopg2._psycopg import ( # noqa
PYDATE, PYDATETIME, PYDATETIMETZ, PYINTERVAL, PYTIME, PYDATEARRAY,
PYDATETIMEARRAY, PYDATETIMETZARRAY, PYINTERVALARRAY, PYTIMEARRAY,
DateFromPy, TimeFromPy, TimestampFromPy, IntervalFromPy, )
from psycopg2._psycopg import ( # noqa
adapt, adapters, encodings, connection, cursor,
lobject, Xid, libpq_version, parse_dsn, quote_ident,
string_types, binary_types, new_type, new_array_type, register_type,
ISQLQuote, Notify, Diagnostics, Column, ConnectionInfo,
QueryCanceledError, TransactionRollbackError,
set_wait_callback, get_wait_callback, encrypt_password, )
"""Isolation level values."""
ISOLATION_LEVEL_AUTOCOMMIT = 0
ISOLATION_LEVEL_READ_UNCOMMITTED = 4
ISOLATION_LEVEL_READ_COMMITTED = 1
ISOLATION_LEVEL_REPEATABLE_READ = 2
ISOLATION_LEVEL_SERIALIZABLE = 3
ISOLATION_LEVEL_DEFAULT = None
"""psycopg connection status values."""
STATUS_SETUP = 0
STATUS_READY = 1
STATUS_BEGIN = 2
STATUS_SYNC = 3 # currently unused
STATUS_ASYNC = 4 # currently unused
STATUS_PREPARED = 5
# This is a useful mnemonic to check if the connection is in a transaction
STATUS_IN_TRANSACTION = STATUS_BEGIN
"""psycopg asynchronous connection polling values"""
POLL_OK = 0
POLL_READ = 1
POLL_WRITE = 2
POLL_ERROR = 3
"""Backend transaction status values."""
TRANSACTION_STATUS_IDLE = 0
TRANSACTION_STATUS_ACTIVE = 1
TRANSACTION_STATUS_INTRANS = 2
TRANSACTION_STATUS_INERROR = 3
TRANSACTION_STATUS_UNKNOWN = 4
def register_adapter(typ, callable):
"""Register 'callable' as an ISQLQuote adapter for type 'typ'."""
adapters[(typ, ISQLQuote)] = callable
# The SQL_IN class is the official adapter for tuples starting from 2.0.6.
class SQL_IN:
"""Adapt any iterable to an SQL quotable object."""
def __init__(self, seq):
self._seq = seq
self._conn = None
def prepare(self, conn):
self._conn = conn
def getquoted(self):
# this is the important line: note how every object in the
# list is adapted and then how getquoted() is called on it
pobjs = [adapt(o) for o in self._seq]
if self._conn is not None:
for obj in pobjs:
if hasattr(obj, 'prepare'):
obj.prepare(self._conn)
qobjs = [o.getquoted() for o in pobjs]
return b'(' + b', '.join(qobjs) + b')'
def __str__(self):
return str(self.getquoted())
class NoneAdapter:
"""Adapt None to NULL.
This adapter is not used normally as a fast path in mogrify uses NULL,
but it makes easier to adapt composite types.
"""
def __init__(self, obj):
pass
def getquoted(self, _null=b"NULL"):
return _null
def make_dsn(dsn=None, **kwargs):
"""Convert a set of keywords into a connection strings."""
if dsn is None and not kwargs:
return ''
# If no kwarg is specified don't mung the dsn, but verify it
if not kwargs:
parse_dsn(dsn)
return dsn
# Override the dsn with the parameters
if 'database' in kwargs:
if 'dbname' in kwargs:
raise TypeError(
"you can't specify both 'database' and 'dbname' arguments")
kwargs['dbname'] = kwargs.pop('database')
# Drop the None arguments
kwargs = {k: v for (k, v) in kwargs.items() if v is not None}
if dsn is not None:
tmp = parse_dsn(dsn)
tmp.update(kwargs)
kwargs = tmp
dsn = " ".join(["{}={}".format(k, _param_escape(str(v)))
for (k, v) in kwargs.items()])
# verify that the returned dsn is valid
parse_dsn(dsn)
return dsn
def _param_escape(s,
re_escape=_re.compile(r"([\\'])"),
re_space=_re.compile(r'\s')):
"""
Apply the escaping rule required by PQconnectdb
"""
if not s:
return "''"
s = re_escape.sub(r'\\\1', s)
if re_space.search(s):
s = "'" + s + "'"
return s
# Create default json typecasters for PostgreSQL 9.2 oids
from psycopg2._json import register_default_json, register_default_jsonb # noqa
try:
JSON, JSONARRAY = register_default_json()
JSONB, JSONBARRAY = register_default_jsonb()
except ImportError:
pass
del register_default_json, register_default_jsonb
# Create default Range typecasters
from psycopg2. _range import Range # noqa
del Range
# Add the "cleaned" version of the encodings to the key.
# When the encoding is set its name is cleaned up from - and _ and turned
# uppercase, so an encoding not respecting these rules wouldn't be found in the
# encodings keys and would raise an exception with the unicode typecaster
for k, v in list(encodings.items()):
k = k.replace('_', '').replace('-', '').upper()
encodings[k] = v
del k, v

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,187 @@
"""Connection pooling for psycopg2
This module implements thread-safe (and not) connection pools.
"""
# psycopg/pool.py - pooling code for psycopg
#
# Copyright (C) 2003-2019 Federico Di Gregorio <fog@debian.org>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
import psycopg2
from psycopg2 import extensions as _ext
class PoolError(psycopg2.Error):
pass
class AbstractConnectionPool:
"""Generic key-based pooling code."""
def __init__(self, minconn, maxconn, *args, **kwargs):
"""Initialize the connection pool.
New 'minconn' connections are created immediately calling 'connfunc'
with given parameters. The connection pool will support a maximum of
about 'maxconn' connections.
"""
self.minconn = int(minconn)
self.maxconn = int(maxconn)
self.closed = False
self._args = args
self._kwargs = kwargs
self._pool = []
self._used = {}
self._rused = {} # id(conn) -> key map
self._keys = 0
for i in range(self.minconn):
self._connect()
def _connect(self, key=None):
"""Create a new connection and assign it to 'key' if not None."""
conn = psycopg2.connect(*self._args, **self._kwargs)
if key is not None:
self._used[key] = conn
self._rused[id(conn)] = key
else:
self._pool.append(conn)
return conn
def _getkey(self):
"""Return a new unique key."""
self._keys += 1
return self._keys
def _getconn(self, key=None):
"""Get a free connection and assign it to 'key' if not None."""
if self.closed:
raise PoolError("connection pool is closed")
if key is None:
key = self._getkey()
if key in self._used:
return self._used[key]
if self._pool:
self._used[key] = conn = self._pool.pop()
self._rused[id(conn)] = key
return conn
else:
if len(self._used) == self.maxconn:
raise PoolError("connection pool exhausted")
return self._connect(key)
def _putconn(self, conn, key=None, close=False):
"""Put away a connection."""
if self.closed:
raise PoolError("connection pool is closed")
if key is None:
key = self._rused.get(id(conn))
if key is None:
raise PoolError("trying to put unkeyed connection")
if len(self._pool) < self.minconn and not close:
# Return the connection into a consistent state before putting
# it back into the pool
if not conn.closed:
status = conn.info.transaction_status
if status == _ext.TRANSACTION_STATUS_UNKNOWN:
# server connection lost
conn.close()
elif status != _ext.TRANSACTION_STATUS_IDLE:
# connection in error or in transaction
conn.rollback()
self._pool.append(conn)
else:
# regular idle connection
self._pool.append(conn)
# If the connection is closed, we just discard it.
else:
conn.close()
# here we check for the presence of key because it can happen that a
# thread tries to put back a connection after a call to close
if not self.closed or key in self._used:
del self._used[key]
del self._rused[id(conn)]
def _closeall(self):
"""Close all connections.
Note that this can lead to some code fail badly when trying to use
an already closed connection. If you call .closeall() make sure
your code can deal with it.
"""
if self.closed:
raise PoolError("connection pool is closed")
for conn in self._pool + list(self._used.values()):
try:
conn.close()
except Exception:
pass
self.closed = True
class SimpleConnectionPool(AbstractConnectionPool):
"""A connection pool that can't be shared across different threads."""
getconn = AbstractConnectionPool._getconn
putconn = AbstractConnectionPool._putconn
closeall = AbstractConnectionPool._closeall
class ThreadedConnectionPool(AbstractConnectionPool):
"""A connection pool that works with the threading module."""
def __init__(self, minconn, maxconn, *args, **kwargs):
"""Initialize the threading lock."""
import threading
AbstractConnectionPool.__init__(
self, minconn, maxconn, *args, **kwargs)
self._lock = threading.Lock()
def getconn(self, key=None):
"""Get a free connection and assign it to 'key' if not None."""
self._lock.acquire()
try:
return self._getconn(key)
finally:
self._lock.release()
def putconn(self, conn=None, key=None, close=False):
"""Put away an unused connection."""
self._lock.acquire()
try:
self._putconn(conn, key, close)
finally:
self._lock.release()
def closeall(self):
"""Close all connections (even the one currently in use.)"""
self._lock.acquire()
try:
self._closeall()
finally:
self._lock.release()

View File

@ -0,0 +1,455 @@
"""SQL composition utility module
"""
# psycopg/sql.py - SQL composition utility module
#
# Copyright (C) 2016-2019 Daniele Varrazzo <daniele.varrazzo@gmail.com>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
import string
from psycopg2 import extensions as ext
_formatter = string.Formatter()
class Composable:
"""
Abstract base class for objects that can be used to compose an SQL string.
`!Composable` objects can be passed directly to `~cursor.execute()`,
`~cursor.executemany()`, `~cursor.copy_expert()` in place of the query
string.
`!Composable` objects can be joined using the ``+`` operator: the result
will be a `Composed` instance containing the objects joined. The operator
``*`` is also supported with an integer argument: the result is a
`!Composed` instance containing the left argument repeated as many times as
requested.
"""
def __init__(self, wrapped):
self._wrapped = wrapped
def __repr__(self):
return f"{self.__class__.__name__}({self._wrapped!r})"
def as_string(self, context):
"""
Return the string value of the object.
:param context: the context to evaluate the string into.
:type context: `connection` or `cursor`
The method is automatically invoked by `~cursor.execute()`,
`~cursor.executemany()`, `~cursor.copy_expert()` if a `!Composable` is
passed instead of the query string.
"""
raise NotImplementedError
def __add__(self, other):
if isinstance(other, Composed):
return Composed([self]) + other
if isinstance(other, Composable):
return Composed([self]) + Composed([other])
else:
return NotImplemented
def __mul__(self, n):
return Composed([self] * n)
def __eq__(self, other):
return type(self) is type(other) and self._wrapped == other._wrapped
def __ne__(self, other):
return not self.__eq__(other)
class Composed(Composable):
"""
A `Composable` object made of a sequence of `!Composable`.
The object is usually created using `!Composable` operators and methods.
However it is possible to create a `!Composed` directly specifying a
sequence of `!Composable` as arguments.
Example::
>>> comp = sql.Composed(
... [sql.SQL("insert into "), sql.Identifier("table")])
>>> print(comp.as_string(conn))
insert into "table"
`!Composed` objects are iterable (so they can be used in `SQL.join` for
instance).
"""
def __init__(self, seq):
wrapped = []
for i in seq:
if not isinstance(i, Composable):
raise TypeError(
f"Composed elements must be Composable, got {i!r} instead")
wrapped.append(i)
super().__init__(wrapped)
@property
def seq(self):
"""The list of the content of the `!Composed`."""
return list(self._wrapped)
def as_string(self, context):
rv = []
for i in self._wrapped:
rv.append(i.as_string(context))
return ''.join(rv)
def __iter__(self):
return iter(self._wrapped)
def __add__(self, other):
if isinstance(other, Composed):
return Composed(self._wrapped + other._wrapped)
if isinstance(other, Composable):
return Composed(self._wrapped + [other])
else:
return NotImplemented
def join(self, joiner):
"""
Return a new `!Composed` interposing the *joiner* with the `!Composed` items.
The *joiner* must be a `SQL` or a string which will be interpreted as
an `SQL`.
Example::
>>> fields = sql.Identifier('foo') + sql.Identifier('bar') # a Composed
>>> print(fields.join(', ').as_string(conn))
"foo", "bar"
"""
if isinstance(joiner, str):
joiner = SQL(joiner)
elif not isinstance(joiner, SQL):
raise TypeError(
"Composed.join() argument must be a string or an SQL")
return joiner.join(self)
class SQL(Composable):
"""
A `Composable` representing a snippet of SQL statement.
`!SQL` exposes `join()` and `format()` methods useful to create a template
where to merge variable parts of a query (for instance field or table
names).
The *string* doesn't undergo any form of escaping, so it is not suitable to
represent variable identifiers or values: you should only use it to pass
constant strings representing templates or snippets of SQL statements; use
other objects such as `Identifier` or `Literal` to represent variable
parts.
Example::
>>> query = sql.SQL("select {0} from {1}").format(
... sql.SQL(', ').join([sql.Identifier('foo'), sql.Identifier('bar')]),
... sql.Identifier('table'))
>>> print(query.as_string(conn))
select "foo", "bar" from "table"
"""
def __init__(self, string):
if not isinstance(string, str):
raise TypeError("SQL values must be strings")
super().__init__(string)
@property
def string(self):
"""The string wrapped by the `!SQL` object."""
return self._wrapped
def as_string(self, context):
return self._wrapped
def format(self, *args, **kwargs):
"""
Merge `Composable` objects into a template.
:param `Composable` args: parameters to replace to numbered
(``{0}``, ``{1}``) or auto-numbered (``{}``) placeholders
:param `Composable` kwargs: parameters to replace to named (``{name}``)
placeholders
:return: the union of the `!SQL` string with placeholders replaced
:rtype: `Composed`
The method is similar to the Python `str.format()` method: the string
template supports auto-numbered (``{}``), numbered (``{0}``,
``{1}``...), and named placeholders (``{name}``), with positional
arguments replacing the numbered placeholders and keywords replacing
the named ones. However placeholder modifiers (``{0!r}``, ``{0:<10}``)
are not supported. Only `!Composable` objects can be passed to the
template.
Example::
>>> print(sql.SQL("select * from {} where {} = %s")
... .format(sql.Identifier('people'), sql.Identifier('id'))
... .as_string(conn))
select * from "people" where "id" = %s
>>> print(sql.SQL("select * from {tbl} where {pkey} = %s")
... .format(tbl=sql.Identifier('people'), pkey=sql.Identifier('id'))
... .as_string(conn))
select * from "people" where "id" = %s
"""
rv = []
autonum = 0
for pre, name, spec, conv in _formatter.parse(self._wrapped):
if spec:
raise ValueError("no format specification supported by SQL")
if conv:
raise ValueError("no format conversion supported by SQL")
if pre:
rv.append(SQL(pre))
if name is None:
continue
if name.isdigit():
if autonum:
raise ValueError(
"cannot switch from automatic field numbering to manual")
rv.append(args[int(name)])
autonum = None
elif not name:
if autonum is None:
raise ValueError(
"cannot switch from manual field numbering to automatic")
rv.append(args[autonum])
autonum += 1
else:
rv.append(kwargs[name])
return Composed(rv)
def join(self, seq):
"""
Join a sequence of `Composable`.
:param seq: the elements to join.
:type seq: iterable of `!Composable`
Use the `!SQL` object's *string* to separate the elements in *seq*.
Note that `Composed` objects are iterable too, so they can be used as
argument for this method.
Example::
>>> snip = sql.SQL(', ').join(
... sql.Identifier(n) for n in ['foo', 'bar', 'baz'])
>>> print(snip.as_string(conn))
"foo", "bar", "baz"
"""
rv = []
it = iter(seq)
try:
rv.append(next(it))
except StopIteration:
pass
else:
for i in it:
rv.append(self)
rv.append(i)
return Composed(rv)
class Identifier(Composable):
"""
A `Composable` representing an SQL identifier or a dot-separated sequence.
Identifiers usually represent names of database objects, such as tables or
fields. PostgreSQL identifiers follow `different rules`__ than SQL string
literals for escaping (e.g. they use double quotes instead of single).
.. __: https://www.postgresql.org/docs/current/static/sql-syntax-lexical.html# \
SQL-SYNTAX-IDENTIFIERS
Example::
>>> t1 = sql.Identifier("foo")
>>> t2 = sql.Identifier("ba'r")
>>> t3 = sql.Identifier('ba"z')
>>> print(sql.SQL(', ').join([t1, t2, t3]).as_string(conn))
"foo", "ba'r", "ba""z"
Multiple strings can be passed to the object to represent a qualified name,
i.e. a dot-separated sequence of identifiers.
Example::
>>> query = sql.SQL("select {} from {}").format(
... sql.Identifier("table", "field"),
... sql.Identifier("schema", "table"))
>>> print(query.as_string(conn))
select "table"."field" from "schema"."table"
"""
def __init__(self, *strings):
if not strings:
raise TypeError("Identifier cannot be empty")
for s in strings:
if not isinstance(s, str):
raise TypeError("SQL identifier parts must be strings")
super().__init__(strings)
@property
def strings(self):
"""A tuple with the strings wrapped by the `Identifier`."""
return self._wrapped
@property
def string(self):
"""The string wrapped by the `Identifier`.
"""
if len(self._wrapped) == 1:
return self._wrapped[0]
else:
raise AttributeError(
"the Identifier wraps more than one than one string")
def __repr__(self):
return f"{self.__class__.__name__}({', '.join(map(repr, self._wrapped))})"
def as_string(self, context):
return '.'.join(ext.quote_ident(s, context) for s in self._wrapped)
class Literal(Composable):
"""
A `Composable` representing an SQL value to include in a query.
Usually you will want to include placeholders in the query and pass values
as `~cursor.execute()` arguments. If however you really really need to
include a literal value in the query you can use this object.
The string returned by `!as_string()` follows the normal :ref:`adaptation
rules <python-types-adaptation>` for Python objects.
Example::
>>> s1 = sql.Literal("foo")
>>> s2 = sql.Literal("ba'r")
>>> s3 = sql.Literal(42)
>>> print(sql.SQL(', ').join([s1, s2, s3]).as_string(conn))
'foo', 'ba''r', 42
"""
@property
def wrapped(self):
"""The object wrapped by the `!Literal`."""
return self._wrapped
def as_string(self, context):
# is it a connection or cursor?
if isinstance(context, ext.connection):
conn = context
elif isinstance(context, ext.cursor):
conn = context.connection
else:
raise TypeError("context must be a connection or a cursor")
a = ext.adapt(self._wrapped)
if hasattr(a, 'prepare'):
a.prepare(conn)
rv = a.getquoted()
if isinstance(rv, bytes):
rv = rv.decode(ext.encodings[conn.encoding])
return rv
class Placeholder(Composable):
"""A `Composable` representing a placeholder for query parameters.
If the name is specified, generate a named placeholder (e.g. ``%(name)s``),
otherwise generate a positional placeholder (e.g. ``%s``).
The object is useful to generate SQL queries with a variable number of
arguments.
Examples::
>>> names = ['foo', 'bar', 'baz']
>>> q1 = sql.SQL("insert into table ({}) values ({})").format(
... sql.SQL(', ').join(map(sql.Identifier, names)),
... sql.SQL(', ').join(sql.Placeholder() * len(names)))
>>> print(q1.as_string(conn))
insert into table ("foo", "bar", "baz") values (%s, %s, %s)
>>> q2 = sql.SQL("insert into table ({}) values ({})").format(
... sql.SQL(', ').join(map(sql.Identifier, names)),
... sql.SQL(', ').join(map(sql.Placeholder, names)))
>>> print(q2.as_string(conn))
insert into table ("foo", "bar", "baz") values (%(foo)s, %(bar)s, %(baz)s)
"""
def __init__(self, name=None):
if isinstance(name, str):
if ')' in name:
raise ValueError(f"invalid name: {name!r}")
elif name is not None:
raise TypeError(f"expected string or None as name, got {name!r}")
super().__init__(name)
@property
def name(self):
"""The name of the `!Placeholder`."""
return self._wrapped
def __repr__(self):
if self._wrapped is None:
return f"{self.__class__.__name__}()"
else:
return f"{self.__class__.__name__}({self._wrapped!r})"
def as_string(self, context):
if self._wrapped is not None:
return f"%({self._wrapped})s"
else:
return "%s"
# Literals
NULL = SQL("NULL")
DEFAULT = SQL("DEFAULT")

View File

@ -0,0 +1,158 @@
"""tzinfo implementations for psycopg2
This module holds two different tzinfo implementations that can be used as
the 'tzinfo' argument to datetime constructors, directly passed to psycopg
functions or used to set the .tzinfo_factory attribute in cursors.
"""
# psycopg/tz.py - tzinfo implementation
#
# Copyright (C) 2003-2019 Federico Di Gregorio <fog@debian.org>
# Copyright (C) 2020-2021 The Psycopg Team
#
# psycopg2 is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# In addition, as a special exception, the copyright holders give
# permission to link this program with the OpenSSL library (or with
# modified versions of OpenSSL that use the same license as OpenSSL),
# and distribute linked combinations including the two.
#
# You must obey the GNU Lesser General Public License in all respects for
# all of the code used other than OpenSSL.
#
# psycopg2 is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
import datetime
import time
ZERO = datetime.timedelta(0)
class FixedOffsetTimezone(datetime.tzinfo):
"""Fixed offset in minutes east from UTC.
This is exactly the implementation__ found in Python 2.3.x documentation,
with a small change to the `!__init__()` method to allow for pickling
and a default name in the form ``sHH:MM`` (``s`` is the sign.).
The implementation also caches instances. During creation, if a
FixedOffsetTimezone instance has previously been created with the same
offset and name that instance will be returned. This saves memory and
improves comparability.
.. versionchanged:: 2.9
The constructor can take either a timedelta or a number of minutes of
offset. Previously only minutes were supported.
.. __: https://docs.python.org/library/datetime.html
"""
_name = None
_offset = ZERO
_cache = {}
def __init__(self, offset=None, name=None):
if offset is not None:
if not isinstance(offset, datetime.timedelta):
offset = datetime.timedelta(minutes=offset)
self._offset = offset
if name is not None:
self._name = name
def __new__(cls, offset=None, name=None):
"""Return a suitable instance created earlier if it exists
"""
key = (offset, name)
try:
return cls._cache[key]
except KeyError:
tz = super().__new__(cls, offset, name)
cls._cache[key] = tz
return tz
def __repr__(self):
return "psycopg2.tz.FixedOffsetTimezone(offset=%r, name=%r)" \
% (self._offset, self._name)
def __eq__(self, other):
if isinstance(other, FixedOffsetTimezone):
return self._offset == other._offset
else:
return NotImplemented
def __ne__(self, other):
if isinstance(other, FixedOffsetTimezone):
return self._offset != other._offset
else:
return NotImplemented
def __getinitargs__(self):
return self._offset, self._name
def utcoffset(self, dt):
return self._offset
def tzname(self, dt):
if self._name is not None:
return self._name
minutes, seconds = divmod(self._offset.total_seconds(), 60)
hours, minutes = divmod(minutes, 60)
rv = "%+03d" % hours
if minutes or seconds:
rv += ":%02d" % minutes
if seconds:
rv += ":%02d" % seconds
return rv
def dst(self, dt):
return ZERO
STDOFFSET = datetime.timedelta(seconds=-time.timezone)
if time.daylight:
DSTOFFSET = datetime.timedelta(seconds=-time.altzone)
else:
DSTOFFSET = STDOFFSET
DSTDIFF = DSTOFFSET - STDOFFSET
class LocalTimezone(datetime.tzinfo):
"""Platform idea of local timezone.
This is the exact implementation from the Python 2.3 documentation.
"""
def utcoffset(self, dt):
if self._isdst(dt):
return DSTOFFSET
else:
return STDOFFSET
def dst(self, dt):
if self._isdst(dt):
return DSTDIFF
else:
return ZERO
def tzname(self, dt):
return time.tzname[self._isdst(dt)]
def _isdst(self, dt):
tt = (dt.year, dt.month, dt.day,
dt.hour, dt.minute, dt.second,
dt.weekday(), 0, -1)
stamp = time.mktime(tt)
tt = time.localtime(stamp)
return tt.tm_isdst > 0
LOCAL = LocalTimezone()
# TODO: pre-generate some interesting time zones?

View File

@ -0,0 +1,131 @@
Metadata-Version: 2.4
Name: psycopg2-binary
Version: 2.9.12
Summary: psycopg2 - Python-PostgreSQL Database Adapter
Home-page: https://psycopg.org/
Author: Federico Di Gregorio
Author-email: fog@initd.org
Maintainer: Daniele Varrazzo
Maintainer-email: daniele.varrazzo@gmail.com
License: LGPL with exceptions
Project-URL: Homepage, https://psycopg.org/
Project-URL: Changes, https://www.psycopg.org/docs/news.html
Project-URL: Documentation, https://www.psycopg.org/docs/
Project-URL: Code, https://github.com/psycopg/psycopg2
Project-URL: Issue Tracker, https://github.com/psycopg/psycopg2/issues
Project-URL: Download, https://pypi.org/project/psycopg2/
Platform: any
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: C
Classifier: Programming Language :: SQL
Classifier: Topic :: Database
Classifier: Topic :: Database :: Front-Ends
Classifier: Topic :: Software Development
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: Unix
Requires-Python: >=3.9
License-File: LICENSE
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: maintainer
Dynamic: maintainer-email
Dynamic: platform
Dynamic: project-url
Dynamic: requires-python
Dynamic: summary
Psycopg is the most popular PostgreSQL database adapter for the Python
programming language. Its main features are the complete implementation of
the Python DB API 2.0 specification and the thread safety (several threads can
share the same connection). It was designed for heavily multi-threaded
applications that create and destroy lots of cursors and make a large number
of concurrent "INSERT"s or "UPDATE"s.
Psycopg 2 is mostly implemented in C as a libpq wrapper, resulting in being
both efficient and secure. It features client-side and server-side cursors,
asynchronous communication and notifications, "COPY TO/COPY FROM" support.
Many Python types are supported out-of-the-box and adapted to matching
PostgreSQL data types; adaptation can be extended and customized thanks to a
flexible objects adaptation system.
Psycopg 2 is both Unicode and Python 3 friendly.
.. Note::
The psycopg2 package is still widely used and actively maintained, but it
is not expected to receive new features.
`Psycopg 3`__ is the evolution of psycopg2 and is where `new features are
being developed`__: if you are starting a new project you should probably
start from 3!
.. __: https://pypi.org/project/psycopg/
.. __: https://www.psycopg.org/psycopg3/docs/index.html
Documentation
-------------
Documentation is included in the ``doc`` directory and is `available online`__.
.. __: https://www.psycopg.org/docs/
For any other resource (source code repository, bug tracker, mailing list)
please check the `project homepage`__.
.. __: https://psycopg.org/
Installation
------------
Building Psycopg requires a few prerequisites (a C compiler, some development
packages): please check the install_ and the faq_ documents in the ``doc`` dir
or online for the details.
If prerequisites are met, you can install psycopg like any other Python
package, using ``pip`` to download it from PyPI_::
$ pip install psycopg2
or using ``setup.py`` if you have downloaded the source package locally::
$ python setup.py build
$ sudo python setup.py install
You can also obtain a stand-alone package, not requiring a compiler or
external libraries, by installing the `psycopg2-binary`_ package from PyPI::
$ pip install psycopg2-binary
The binary package is a practical choice for development and testing but in
production it is advised to use the package built from sources.
.. _PyPI: https://pypi.org/project/psycopg2/
.. _psycopg2-binary: https://pypi.org/project/psycopg2-binary/
.. _install: https://www.psycopg.org/docs/install.html#install-from-source
.. _faq: https://www.psycopg.org/docs/faq.html#faq-compile
:Build status: |gh-actions|
.. |gh-actions| image:: https://github.com/psycopg/psycopg2/actions/workflows/tests.yml/badge.svg
:target: https://github.com/psycopg/psycopg2/actions/workflows/tests.yml
:alt: Build status

View File

@ -0,0 +1,46 @@
psycopg2/__init__.py,sha256=9mo5Qd0uWHiEBx2CdogGos2kNqtlNNGzbtYlGC0hWS8,4768
psycopg2/__pycache__/__init__.cpython-312.pyc,,
psycopg2/__pycache__/_ipaddress.cpython-312.pyc,,
psycopg2/__pycache__/_json.cpython-312.pyc,,
psycopg2/__pycache__/_range.cpython-312.pyc,,
psycopg2/__pycache__/errorcodes.cpython-312.pyc,,
psycopg2/__pycache__/errors.cpython-312.pyc,,
psycopg2/__pycache__/extensions.cpython-312.pyc,,
psycopg2/__pycache__/extras.cpython-312.pyc,,
psycopg2/__pycache__/pool.cpython-312.pyc,,
psycopg2/__pycache__/sql.cpython-312.pyc,,
psycopg2/__pycache__/tz.cpython-312.pyc,,
psycopg2/_ipaddress.py,sha256=jkuyhLgqUGRBcLNWDM8QJysV6q1Npc_RYH4_kE7JZPU,2922
psycopg2/_json.py,sha256=XPn4PnzbTg1Dcqz7n1JMv5dKhB5VFV6834GEtxSawt0,7153
psycopg2/_psycopg.cpython-312-x86_64-linux-gnu.so,sha256=35PQyvdsHEV_QAsePHPkbD5ERDLImmjyI7GbvZBWIYc,339185
psycopg2/_range.py,sha256=sXeenGraJEEw2I3mc8RlmNivy2jMg7zWoanDes2Ywp8,18494
psycopg2/errorcodes.py,sha256=8BE_ZAP7bhsISKyLP0gkrWg_NNj1uj37dR356EDe6yo,14512
psycopg2/errors.py,sha256=aAS4dJyTg1bsDzJDCRQAMB_s7zv-Q4yB6Yvih26I-0M,1425
psycopg2/extensions.py,sha256=CG0kG5vL8Ot503UGlDXXJJFdFWLg4HE2_c1-lLOLc8M,6797
psycopg2/extras.py,sha256=oBfrdvtWn8ITxc3x-h2h6IwHUsWdVqCdf4Gphb0JqY8,44215
psycopg2/pool.py,sha256=UGEt8IdP3xNc2PGYNlG4sQvg8nhf4aeCnz39hTR0H8I,6316
psycopg2/sql.py,sha256=OcFEAmpe2aMfrx0MEk4Lx00XvXXJCmvllaOVbJY-yoE,14779
psycopg2/tz.py,sha256=r95kK7eGSpOYr_luCyYsznHMzjl52sLjsnSPXkXLzRI,4870
psycopg2_binary-2.9.12.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
psycopg2_binary-2.9.12.dist-info/METADATA,sha256=iGJmJEDbMQgO-BIiGz5RdsG3iHMOXh0TdUkKyHDMVRQ,4936
psycopg2_binary-2.9.12.dist-info/RECORD,,
psycopg2_binary-2.9.12.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
psycopg2_binary-2.9.12.dist-info/WHEEL,sha256=xZDW7cs1uNrIXupugSX6MuN9g_ZyR5vcfKfhfn3-rY4,151
psycopg2_binary-2.9.12.dist-info/licenses/LICENSE,sha256=lhS4XfyacsWyyjMUTB1-HtOxwpdFnZ-yimpXYsLo1xs,2238
psycopg2_binary-2.9.12.dist-info/sboms/auditwheel.cdx.json,sha256=Brfy_dvl_MZ5EvxyoKv41ox3GS3gs1dhBdlY8vdusBM,5207
psycopg2_binary-2.9.12.dist-info/top_level.txt,sha256=7dHGpLqQ3w-vGmGEVn-7uK90qU9fyrGdWWi7S-gTcnM,9
psycopg2_binary.libs/libcom_err-2abe824b.so.2.1,sha256=VCbctU3QHJ7t2gXiF58ORxFOi0ilNP_p6UkW55Rxslc,17497
psycopg2_binary.libs/libcrypt-13f4f5d0.so.1,sha256=p00ehDjSJOD_8U2gAB9eUBKbDWZqEDv5gbl2dwFY0QY,221657
psycopg2_binary.libs/libcrypto-88208852.so.3,sha256=L9NxVLGTM8C-5G_ySqr6tcOPC5r_S-3BB3U7Z_MWHIA,6498145
psycopg2_binary.libs/libgssapi_krb5-497db0c6.so.2.2,sha256=KnSwMw7pcygbJvjr5KzvDr-e6ZxraEl8-RUf_2xMNOE,345209
psycopg2_binary.libs/libk5crypto-b1f99d5c.so.3.1,sha256=mETlAJ5wpq0vsitYcwaBD-Knsbn2uZItqhx4ujRm3ic,219953
psycopg2_binary.libs/libkeyutils-dfe70bd6.so.1.5,sha256=wp5BsDz0st_7-0lglG4rQvgsDKXVPSMdPw_Fl7onRIg,17913
psycopg2_binary.libs/libkrb5-fcafa220.so.3.3,sha256=sqq1KP9MqyFE5c4BskasCfV0oHKlP_Y-qB1rspsmuPE,1018953
psycopg2_binary.libs/libkrb5support-d0bcff84.so.0.1,sha256=anH1fXSP73m05zbVNIh1VF0KIk-okotdYqPPJkf8EJ8,76873
psycopg2_binary.libs/liblber-314cbfbf.so.2.0.200,sha256=_bpFU48deTqZAnl8X4iLOeel6Le_X-BEfgGrf0h8G8c,60977
psycopg2_binary.libs/libldap-331dad9d.so.2.0.200,sha256=xWGR3ra3Jr9AhjT0ZSLqToKQh7hbumGa4lBxjW7Z_8Y,447321
psycopg2_binary.libs/libpcre-9513aab5.so.1.2.0,sha256=Au2oUOBJMWVtivgfUXG_902L7BVT09hcPTLX_F7-iGQ,406817
psycopg2_binary.libs/libpq-f521cc7d.so.5.17,sha256=wocPdLpZpDVQtRXrcETsNeZtd2dPZcfXhMIkeWbhiT8,387497
psycopg2_binary.libs/libsasl2-84219a89.so.3.0.0,sha256=rj-JZ9X6GR2sfGrl-RMPZZzy9ctmsvflxaGlpHNp_lo,134753
psycopg2_binary.libs/libselinux-0922c95c.so.1,sha256=1PqOf7Ot2WCmgyWlnJaUJErqMhP9c5pQgVywZ8SWVlQ,178337
psycopg2_binary.libs/libssl-fe1b61af.so.3,sha256=Lbg-TzkwZmdMkOv97HNnznBwIJMXVKS6spldKto3fEM,1147337

View File

@ -0,0 +1,6 @@
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Root-Is-Purelib: false
Tag: cp312-cp312-manylinux_2_17_x86_64
Tag: cp312-cp312-manylinux2014_x86_64

View File

@ -0,0 +1,49 @@
psycopg2 and the LGPL
---------------------
psycopg2 is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
psycopg2 is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
License for more details.
In addition, as a special exception, the copyright holders give
permission to link this program with the OpenSSL library (or with
modified versions of OpenSSL that use the same license as OpenSSL),
and distribute linked combinations including the two.
You must obey the GNU Lesser General Public License in all respects for
all of the code used other than OpenSSL. If you modify file(s) with this
exception, you may extend this exception to your version of the file(s),
but you are not obligated to do so. If you do not wish to do so, delete
this exception statement from your version. If you delete this exception
statement from all source files in the program, then also delete it here.
You should have received a copy of the GNU Lesser General Public License
along with psycopg2 (see the doc/ directory.)
If not, see <https://www.gnu.org/licenses/>.
Alternative licenses
--------------------
The following BSD-like license applies (at your option) to the files following
the pattern ``psycopg/adapter*.{h,c}`` and ``psycopg/microprotocol*.{h,c}``:
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this
software in a product, an acknowledgment in the product documentation
would be appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not
be misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.

File diff suppressed because one or more lines are too long

67029
cmts_leaf_options_cache.json Normal file

File diff suppressed because it is too large Load Diff

267
cmts_scraper.py Normal file
View File

@ -0,0 +1,267 @@
# --- cmts_scraper.py ---
import asyncio
import asyncssh
import re
import json
import os
import time
def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
hint_lines = []
options = []
current_value = None
format_desc = None
is_format_only = False
state = "INIT"
has_description = False
has_bracket_value = False
# 🌟 Case-5 特殊處理dhcp-relay 混合型欄位 (選項 + IP輸入)
# 直接在開頭掃描完整輸出,若包含 dhcp-relay強制轉為純文字輸入框
if "dhcp-relay" in output.lower():
is_format_only = True
format_desc = "IP address or options"
for line in output.splitlines():
line = line.strip()
# 過濾雜訊與終端機提示字元
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
continue
if line.startswith('Description:'):
state = "DESC"
has_description = True
hint_lines.append(line)
continue
if line.startswith('Possible completions:'):
state = "COMPLETIONS"
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
continue
if state == "DESC":
hint_lines.append(line)
elif state == "COMPLETIONS":
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
# 規則 1: <格式說明>[當前值]
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format:
is_format_only = True
format_desc = match_format.group(1).strip()
if match_format.group(2):
current_value = match_format.group(2).strip()
continue
# 規則 1.5: 型態, 最小值 .. 最大值
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range:
is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue
# 規則 1.6: IP address 等純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword:
is_format_only = True
format_desc = match_keyword.group(1)
continue
# 規則 2: 選項列表處理
match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current:
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
if not current_value:
current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else:
clean_line = line.strip()
# 🌟 Case-6 防呆:過濾垂直列表的說明文字
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線
if re.search(r"\s{3,}", clean_line):
clean_line = re.split(r"\s{3,}", clean_line)[0]
parts = clean_line.split()
for p in parts:
if p and p not in ["|", ".."]:
options.append(p)
# 🌟 Case 2, 3, 4: 子命令防呆機制
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
is_format_only = True
options = []
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
if current_value and options and current_value not in options:
options.append(current_value)
hint_text = "\n".join(hint_lines).strip()
return {
"hint": hint_text,
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
"options": list(dict.fromkeys(options)) if not is_format_only else [],
"current_value": current_value,
"format_desc": format_desc,
"is_format_only": is_format_only
}
def parse_device_response(output: str) -> dict:
"""支援多種編輯狀態格式解析"""
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
if match:
enum_content = match.group(1)
desc_content = match.group(2)
current_value = match.group(3).strip()
if enum_content:
if enum_content.strip().lower() == "list":
return {"type": "list", "options": [], "current_value": current_value}
else:
return {
"type": "enum",
"options": [opt.strip() for opt in enum_content.split(",") if opt.strip()],
"current_value": current_value
}
elif desc_content:
return {
"type": "string_or_number",
"options": [],
"current_value": current_value,
"format_desc": desc_content.strip()
}
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list) -> dict:
try:
result_data = {}
BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None
try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
async def read_until_quiet(timeout=1.0):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
for path in batch:
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
parsed_data = {
"hint": q_data["hint"],
"options": q_data["options"],
"type": "list" if q_data["options"] else "unknown",
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
# 🌟 配合新規則:如果 '?' 已經明確告訴我們這是 <格式> 欄位,直接跳過 Enter
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
# if q_data.get("format_desc"):
# parsed_data["hint"] += f"\nFormat: {q_data['format_desc']}"
elif not q_data["options"]:
# 原本的 Enter 邏輯
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=1.0)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"]
# if "format_desc" in enter_data:
# parsed_data["hint"] += f"\nFormat: {enter_data['format_desc']}"
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
process.stdin.write("exit\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
except Exception as e:
print(f"❌ 第 {batch_idx + 1} 批次發生錯誤: {str(e)}")
continue
finally:
if conn: conn.close()
try:
cache_file = "cmts_leaf_options_cache.json"
cache_data = {}
if os.path.exists(cache_file):
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
current_ts = int(time.time())
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p]
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, indent=4, ensure_ascii=False)
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!")
except Exception as e:
print(f"⚠️ 提早寫入快取失敗: {e}")
if batch_idx < len(batches) - 1:
await asyncio.sleep(2)
return {"status": "success", "data": result_data}
except Exception as e:
return {"status": "error", "message": f"AsyncSSH 爬蟲錯誤: {str(e)}"}

View File

@ -252,6 +252,22 @@
<div id="form-full-config" class="task-form" style="display: none;"> <div id="form-full-config" class="task-form" style="display: none;">
<h3 style="display: flex; align-items: center; margin-bottom: 15px;"> <h3 style="display: flex; align-items: center; margin-bottom: 15px;">
🌳 完整設備配置樹狀圖 🌳 完整設備配置樹狀圖
<!-- 🌟 一鍵掃描按鈕 -->
<button id="global-scan-btn" onclick="scanAllMissingOptions()" disabled
style="padding: 6px 12px; background-color: #8e44ad; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; margin-left: 20px; transition: all 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
🔍 一鍵掃描缺失選項
</button>
<!-- 🌟 請在這裡加入這段全新的按鈕 -->
<button id="clearCacheBtn" onclick="clearVisibleCache()"
style="padding: 6px 12px; background-color: #f39c12; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; margin-left: 10px; transition: all 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
🔄 清除畫面選項快取
</button>
<!-- 🌟 新增:掃描進度提示 -->
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; margin-left: 15px; font-weight: normal;"></span>
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; margin-left: 15px; font-weight: normal;"> <span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; margin-left: 15px; font-weight: normal;">
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候... ⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
</span> </span>
@ -261,7 +277,12 @@
<div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;"> <div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;">
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) --> <!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
<div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; box-sizing: border-box; overflow-x: auto;"> <!-- <div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; box-sizing: border-box; overflow-x: auto;">
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
</div> -->
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
<div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span> <span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
</div> </div>

66
init_db.py Normal file
View File

@ -0,0 +1,66 @@
import psycopg2
from psycopg2.extras import RealDictCursor
# 💡 資料庫連線設定 (請確認密碼與步驟一設定的相同)
DB_CONFIG = {
"dbname": "cmts_nms",
"user": "swpa",
"password": "swpa4920", # 替換為您的密碼
"host": "127.0.0.1", # 如果 DB 在同一台機器上
"port": "5432"
}
def init_database():
try:
# 建立連線
print("🔄 正在連線到 PostgreSQL...")
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
# 1. 建立 CLI 字典表 (Global-Ready Schema)
print("🛠️ 正在建立 cli_schema_dictionary 資料表...")
cursor.execute("""
CREATE TABLE IF NOT EXISTS cli_schema_dictionary (
id SERIAL PRIMARY KEY,
path VARCHAR(255) UNIQUE NOT NULL,
node_type VARCHAR(50) DEFAULT 'leaf',
options JSONB DEFAULT '[]'::jsonb,
description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_cli_schema_path ON cli_schema_dictionary(path);
""")
# 2. 建立系統狀態表
print("🛠️ 正在建立 system_metadata 資料表...")
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_metadata (
key VARCHAR(50) PRIMARY KEY,
value VARCHAR(255) NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 3. 寫入初始狀態預設值 (使用 ON CONFLICT 避免重複執行時報錯)
print("📝 寫入系統狀態預設值...")
cursor.execute("""
INSERT INTO system_metadata (key, value)
VALUES ('last_schema_sync_time', '1970-01-01 00:00:00')
ON CONFLICT (key) DO NOTHING;
INSERT INTO system_metadata (key, value)
VALUES ('last_schema_sync_cos_version', 'Unknown')
ON CONFLICT (key) DO NOTHING;
""")
# 提交變更並關閉連線
conn.commit()
cursor.close()
conn.close()
print("✅ 資料庫初始化完成!所有資料表已準備就緒。")
except Exception as e:
print(f"❌ 資料庫初始化失敗: {e}")
if __name__ == "__main__":
init_database()

View File

@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock from routers import query, config, terminal, lock, leaf_options
app = FastAPI(title="Harmonic CMTS Manager", version="2.0") app = FastAPI(title="Harmonic CMTS Manager", version="2.0")
@ -17,6 +17,7 @@ app.include_router(query.router, prefix="/api/v1", tags=["Query"])
app.include_router(config.router, prefix="/api/v1", tags=["Config"]) app.include_router(config.router, prefix="/api/v1", tags=["Config"])
app.include_router(terminal.router, tags=["Terminal"]) app.include_router(terminal.router, tags=["Terminal"])
app.include_router(lock.router, prefix="/api/v1/locks") app.include_router(lock.router, prefix="/api/v1/locks")
app.include_router(leaf_options.router)
# 根目錄路由:回傳前端 UI # 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"]) @app.get("/", response_class=HTMLResponse, tags=["UI"])

View File

@ -24,6 +24,7 @@ class DiffItem(BaseModel):
class GenerateCliRequest(BaseModel): class GenerateCliRequest(BaseModel):
diffs: List[DiffItem] diffs: List[DiffItem]
interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單
@router.post("/cmts-config") @router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest): async def execute_cmts_config(req: ConfigRequest):
@ -76,52 +77,63 @@ async def generate_cli(req: GenerateCliRequest):
並自動處理 admin-state 的安全生命週期 ( down up) 並自動處理 admin-state 的安全生命週期 ( down up)
""" """
# 用來將同一個介面的變更群組化 # 用來將同一個介面的變更群組化
# 結構: { "cable ds-rf-port 1:9/0 down-channel 0": [ ("frequency", "350000000"), ... ] }
interface_groups = defaultdict(list) interface_groups = defaultdict(list)
# 用來記錄該介面最終的 admin-state 應該是什麼 (預設為不變,若有改動則更新) # 用來記錄該介面最終的 admin-state 應該是什麼
interface_admin_states = {} interface_admin_states = {}
for diff in req.diffs: for diff in req.diffs:
# 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency'] # 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
parts = diff.path.split('::') parts = diff.path.split('::')
# 由於 Harmonic 的樹狀圖結構與 CLI 結構高度一致
# 我們可以假設「最後一個元素」是參數名稱,「前面的所有元素」組合起來就是介面路徑
param_name = parts[-1] param_name = parts[-1]
interface_path = " ".join(parts[:-1]) # 例如: "cable ds-rf-port 1:9/0 down-channel 0" interface_path = " ".join(parts[:-1])
# 🌟 新增這行:全域清除路徑中間可能夾帶的虛擬資料夾索引 (例如 [0], [1])
# 這樣 "logging evt 1024 [1] active" 就會還原成 "logging evt 1024 active"
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
if param_name == "admin-state": if param_name == "admin-state":
# 如果使用者直接修改了 admin-state我們記錄下來作為最後的狀態
interface_admin_states[interface_path] = diff.new_val interface_admin_states[interface_path] = diff.new_val
else: else:
# 一般參數變更,加入群組 interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
interface_groups[interface_path].append((param_name, diff.new_val))
# 開始組裝最終的 CLI 腳本 # 開始組裝最終的 CLI 腳本
cli_lines = [] cli_lines = []
# 為了視覺清晰,我們可以加上註解 (您的執行 API 已經會自動過濾 ! 開頭的行)
cli_lines.append("! --- Auto-Generated Configuration Script ---") cli_lines.append("! --- Auto-Generated Configuration Script ---")
for interface, changes in interface_groups.items(): for interface, changes in interface_groups.items():
cli_lines.append(f"! Configuring: {interface}") cli_lines.append(f"! Configuring: {interface}")
# 1. 安全機制:先強制將介面 admin-state down # 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
cli_lines.append(f"{interface} admin-state down") supports_admin_state = interface in req.interfaces_with_admin_state
# 1. 安全機制:如果支援,才強制將介面 admin-state down
if supports_admin_state:
cli_lines.append(f"{interface} admin-state down")
# 2. 寫入所有被修改的參數 # 2. 寫入所有被修改的參數
for param, new_val in changes: for param, old_val, new_val in changes:
if new_val == "": if re.match(r"^\[\d+\]$", param):
# 處理清空數值的情況 (通常是加上 no) if new_val == "":
cli_lines.append(f"no {interface} {param}") if old_val:
cli_lines.append(f"no {interface} {old_val}")
else:
if old_val and old_val != new_val:
cli_lines.append(f"no {interface} {old_val}")
cli_lines.append(f"{interface} {new_val}")
else: else:
cli_lines.append(f"{interface} {param} {new_val}") if new_val == "":
cli_lines.append(f"no {interface} {param}")
else:
cli_lines.append(f"{interface} {param} {new_val}")
# 3. 恢復狀態:如果使用者有特別設定 admin-state 就用使用者的,否則預設恢復為 up # 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
final_state = interface_admin_states.get(interface, "up") if supports_admin_state:
cli_lines.append(f"{interface} admin-state {final_state}") final_state = interface_admin_states.get(interface, "up")
# 🌟 新增Harmonic 必須要有 commit 才會生效 cli_lines.append(f"{interface} admin-state {final_state}")
# Harmonic 必須要有 commit 才會生效
cli_lines.append("commit") cli_lines.append("commit")
cli_lines.append("!") # 空行分隔 cli_lines.append("!") # 空行分隔
@ -130,7 +142,6 @@ async def generate_cli(req: GenerateCliRequest):
if interface not in interface_groups: if interface not in interface_groups:
cli_lines.append(f"! Configuring state only: {interface}") cli_lines.append(f"! Configuring state only: {interface}")
cli_lines.append(f"{interface} admin-state {state}") cli_lines.append(f"{interface} admin-state {state}")
# 🌟 新增Harmonic 必須要有 commit 才會生效
cli_lines.append("commit") cli_lines.append("commit")
cli_lines.append("!") cli_lines.append("!")

114
routers/leaf_options.py Normal file
View File

@ -0,0 +1,114 @@
# --- routers/leaf_options.py ---
from fastapi import APIRouter, BackgroundTasks, HTTPException, Body
from pydantic import BaseModel
from typing import List
import json, os, asyncio, time, re
# 🌟 引入新的 asyncssh 版本爬蟲
from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE
router = APIRouter(prefix="/api/v1", tags=["Options"])
CACHE_FILE = "cmts_leaf_options_cache.json"
scraper_semaphore = asyncio.Semaphore(1)
class SyncOptionsRequest(BaseModel):
leaf_paths: List[str]
async def run_scraper_task_async(leaf_paths: List[str]):
if scraper_semaphore.locked():
print("⚠️ [Debug] 爬蟲正在執行中,本次請求被忽略")
return
async with scraper_semaphore:
try:
print(f"🚀 [Debug] 背景爬蟲開始啟動,準備抓取: {leaf_paths}")
# 🌟 雙重防護:確保傳給 CMTS 的是空白分隔,並在後端也徹底拔除 [0], [1] 等虛擬索引
cmts_query_paths = []
for p in leaf_paths:
clean_p = p.replace("::", " ")
clean_p = re.sub(r"\s*\[\d+\]", "", clean_p) # 拔除虛擬索引
cmts_query_paths.append(clean_p)
# 去除重複的路徑 (例如 alias [0] 和 alias [1] 淨化後都會變成 alias)
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
print(f"⏳ [Debug] 正在連線 CMTS 執行 sync_cmts_leaves_async...")
# 🌟 核心修改:直接 await 非同步爬蟲,不再需要 Netmiko 與 to_thread
# 從共用的 CMTS_DEVICE 字典中提取連線資訊
result = await sync_cmts_leaves_async(
host=CMTS_DEVICE.get("host"),
username=CMTS_DEVICE.get("username"),
password=CMTS_DEVICE.get("password"),
leaf_paths=cmts_query_paths
)
print(f"✅ [Debug] CMTS 抓取完成,結果: {result['status']}")
if result["status"] == "success":
cache_data = {}
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache_data = json.load(f)
current_timestamp = int(time.time())
# 存入快取
for cmts_path, data in result["data"].items():
data["updated_at"] = current_timestamp
cache_data[cmts_path] = data
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache_data, f, indent=4, ensure_ascii=False)
print("💾 [Debug] 快取檔案寫入成功!")
else:
error_msg = result.get('message', '沒有提供錯誤訊息')
print(f"❌ [Debug] 抓取失敗CMTS 拒絕了我們!原因: {error_msg}")
except Exception as e:
print(f"💥 [Debug] 背景爬蟲發生嚴重錯誤: {e}")
@router.get("/cmts-leaf-options")
async def get_leaf_options():
if not os.path.exists(CACHE_FILE): return {}
with open(CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
@router.post("/cmts-leaf-options/sync")
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
if not request.leaf_paths: raise HTTPException(status_code=400)
background_tasks.add_task(run_scraper_task_async, request.leaf_paths)
return {"status": "processing"}
# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE)
@router.post("/clear_cache")
async def clear_specific_cache(paths_to_clear: list = Body(...)):
cleared_count = 0
# 1. 檢查快取檔案是否存在
if not os.path.exists(CACHE_FILE):
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try:
# 2. 讀取現有的 JSON 快取資料
with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache_data = json.load(f)
# 3. 逐一比對並刪除指定的路徑
for path in paths_to_clear:
if path in cache_data:
del cache_data[path]
cleared_count += 1
# 4. 如果有刪除資料,將更新後的內容寫回 JSON 檔案
if cleared_count > 0:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4)
return {"status": "success", "cleared_count": cleared_count}
except Exception as e:
return {"status": "error", "message": f"清除快取時發生錯誤: {str(e)}"}

View File

@ -47,14 +47,35 @@ def parse_cli_to_tree(cli_text: str) -> dict:
if has_children: if has_children:
child_block, next_i = parse_block(i + 1, next_indent) child_block, next_i = parse_block(i + 1, next_indent)
block[content] = child_block
# 🌟 修改 1如果區塊名稱已存在加上帶有「空白」的虛擬索引 (例如: logging evt 1024 [1])
if content in block:
idx = 1
new_key = f"{content} [{idx}]"
while new_key in block:
idx += 1
new_key = f"{content} [{idx}]"
block[new_key] = child_block
else:
block[content] = child_block
i = next_i i = next_i
else: else:
parts = content.split(maxsplit=1) parts = content.split(maxsplit=1)
if len(parts) == 2: key = parts[0]
block[parts[0]] = parts[1] val = parts[1] if len(parts) == 2 else ""
# 🌟 修改 2如果單行指令的 Key 已存在,加上帶有「空白」的虛擬索引 (例如: alias [1])
if key in block:
idx = 1
new_key = f"{key} [{idx}]"
while new_key in block:
idx += 1
new_key = f"{key} [{idx}]"
block[new_key] = val
else: else:
block[parts[0]] = "" block[key] = val
i += 1 i += 1
return block, i return block, i
@ -63,48 +84,50 @@ def parse_cli_to_tree(cli_text: str) -> dict:
return tree return tree
def deep_split_tree(data): def deep_split_tree(data):
"""將平坦的 CLI 字典,依照空白字元拆分成深層巢狀結構 (具備型態衝突保護)""" """將平坦的 CLI 字典,依照空白字元拆分成深層巢狀結構 (具備終極型態衝突保護)"""
if not isinstance(data, dict): if not isinstance(data, dict):
return data return data
nested_tree = {} nested_tree = {}
for key, value in data.items(): for key, value in data.items():
# 1. 遞迴處理子節點
processed_value = deep_split_tree(value) processed_value = deep_split_tree(value)
# 2. 將當前的 key 依照空白拆分
parts = str(key).strip().split() parts = str(key).strip().split()
if not parts: if not parts:
continue continue
# 3. 依序建立深層結構
current = nested_tree current = nested_tree
for i, part in enumerate(parts): for i, part in enumerate(parts):
if i == len(parts) - 1: if i == len(parts) - 1:
# 抵達最後一個單字,準備塞入值
if part not in current: if part not in current:
current[part] = processed_value current[part] = processed_value
else: else:
# 💡 衝突保護:如果節點已存在,根據型態進行安全合併 # 💡 終極衝突保護:安全轉型,絕不遺失資料
if isinstance(current[part], dict) and isinstance(processed_value, dict): if isinstance(current[part], dict) and isinstance(processed_value, dict):
current[part].update(processed_value) current[part].update(processed_value)
elif isinstance(current[part], dict) and not isinstance(processed_value, dict): elif isinstance(current[part], dict) and not isinstance(processed_value, dict):
# 原本是資料夾,新來的是字串 -> 保留資料夾 # 原本是資料夾,新來的是字串 -> 塞入虛擬 key [0], [1]
pass idx = 0
while f"[{idx}]" in current[part]: idx += 1
current[part][f"[{idx}]"] = processed_value
elif not isinstance(current[part], dict) and isinstance(processed_value, dict): elif not isinstance(current[part], dict) and isinstance(processed_value, dict):
# 原本是字串,新來的是資料夾 -> 升級成資料夾 # 原本是字串,新來的是資料夾 -> 升級成資料夾,並保留原字串至 [0]
old_val = current[part]
current[part] = processed_value current[part] = processed_value
idx = 0
while f"[{idx}]" in current[part]: idx += 1
current[part][f"[{idx}]"] = old_val
else: else:
# 兩者都是字串,直接覆蓋 # 兩者都是字串 -> 升級成資料夾,包含兩個字串
current[part] = processed_value old_val = current[part]
current[part] = {"[0]": old_val, "[1]": processed_value}
else: else:
# 建立中間的階層
if part not in current: if part not in current:
current[part] = {} current[part] = {}
elif not isinstance(current[part], dict): elif not isinstance(current[part], dict):
# 💡 衝突保護:如果中間節點原本被當作字串,強制升級為字典(資料夾)以便容納子節點 # 中間節點原本是字串,必須升級為資料夾,並保留原字串
current[part] = {} old_val = current[part]
current[part] = {"[0]": old_val}
current = current[part] current = current[part]
return nested_tree return nested_tree

Binary file not shown.

View File

@ -11,6 +11,7 @@ let isConnected = false;
let currentEditPath = null; let currentEditPath = null;
let currentEditElementId = null; let currentEditElementId = null;
let currentEditIsSuccess = false; let currentEditIsSuccess = false;
let currentEditType = null; // 💡 新增:用來記錄是 'folder' 還是 'leaf'
window.onload = initTerminal; window.onload = initTerminal;
window.onresize = () => { if (fitAddon) fitAddon.fit(); }; window.onresize = () => { if (fitAddon) fitAddon.fit(); };
@ -50,7 +51,7 @@ function highlightTerminalOutput(text) {
} }
// ========================================== // ==========================================
// 💡 資料夾層級的編輯模式邏輯 (Node-Level Lock) // 💡 資料夾層級的編輯模式邏輯 (支援區塊級預先抓取)
// ========================================== // ==========================================
async function startEditFolder(path, elementId) { async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
@ -80,14 +81,183 @@ async function startEditFolder(path, elementId) {
const contentDiv = document.getElementById(`content-${elementId}`); const contentDiv = document.getElementById(`content-${elementId}`);
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
// 🌟 1. 先取得目前的快取資料
let optData = {};
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
optData = await optRes.json();
} catch (e) {
console.warn("無法取得選項快取", e);
}
const pathsToSync = [];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
// 🌟 2. 歷遍所有欄位,渲染 UI 並收集缺少的路徑
leafContainers.forEach(container => { leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const nodePath = container.getAttribute('data-path'); const rawPath = container.getAttribute('data-path');
container.innerHTML = ` // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
<input type="text" class="edit-input" data-path="${nodePath}" value="${origVal}" const cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">
`; let leafCache = optData[cacheKey];
// 判斷是否需要排程更新 (沒快取,或是快取過期)
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
pathsToSync.push(cacheKey);
}
// 渲染 UI (加入提示與下拉選單邏輯)
let inputHtml = "";
let hintAttr = "";
let hintIcon = "";
if (leafCache && leafCache.hint) {
const safeHint = leafCache.hint.replace(/"/g, '&quot;');
hintAttr = `title="${safeHint}"`;
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
}
if (leafCache && leafCache.options && leafCache.options.length > 0) {
let options = leafCache.options;
if (origVal && !options.includes(origVal)) {
options = [origVal, ...options];
}
inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
options.forEach(opt => {
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${opt}" ${isSelected}>${opt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// 退回純文字輸入
inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${origVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
}
container.innerHTML = inputHtml;
}); });
// 🌟 3. 觸發背景抓取:將缺少的路徑一次性送給後端
if (pathsToSync.length > 0) {
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`);
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: pathsToSync })
});
}
}
} catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
// 🔓 鎖定並開始編輯單一項目 (加入智慧等待機制)
async function startEditLeaf(path, elementId) {
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
try {
const response = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username })
});
const result = await response.json();
if (response.status === 409) return alert(`⚠️ ${result.detail}`);
if (result.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
// 💡 顯示載入中提示
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try {
let optRes = await fetch('/api/v1/cmts-leaf-options');
let optData = await optRes.json();
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
let leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
// 💡 如果沒快取,觸發背景抓取,並啟動「智慧等待」
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
// 1. 呼叫背景任務
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: [cacheKey] })
});
// 2. 每秒檢查一次,最多等 10 秒
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000)); // 暫停 1 秒
optRes = await fetch('/api/v1/cmts-leaf-options');
optData = await optRes.json();
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true;
break; // 找到了!跳出等待迴圈
}
}
if (!found) {
console.warn("⏳ 等待選項超時,退回純文字模式");
}
}
let inputHtml = "";
let hintAttr = "";
let hintIcon = "";
// 💡 處理 Hint 屬性與提示圖示
if (leafCache && leafCache.hint) {
// 替換雙引號避免 HTML 屬性破裂
const safeHint = leafCache.hint.replace(/"/g, '&quot;');
hintAttr = `title="${safeHint}"`;
// 加上一個小問號圖示,視覺上引導使用者懸停
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
}
// 🌟 渲染精緻的下拉選單
if (leafCache && leafCache.options && leafCache.options.length > 0) {
let options = leafCache.options;
if (origVal && !options.includes(origVal)) {
options = [origVal, ...options];
}
inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
options.forEach(opt => {
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${opt}" ${isSelected}>${opt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// ❌ 若為 list 且無選項,退回純文字輸入,但依然綁定提示
inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${origVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
}
container.innerHTML = inputHtml;
} catch (e) {
console.warn("選項載入失敗", e);
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${origVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
}
} }
} catch (error) { } catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
@ -122,12 +292,54 @@ async function cancelEditFolder(path, elementId) {
}); });
} }
// 🔓 取消編輯單一項目
async function cancelEditLeaf(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
}
try {
await fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
});
} catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>`;
}
// 💡 輔助函數:掃描畫面上所有支援 admin-state 的區塊路徑
function getInterfacesWithAdminState() {
const nodes = document.querySelectorAll('.leaf-container');
const interfaces = new Set();
nodes.forEach(node => {
const path = node.getAttribute('data-path');
// 如果這個節點是 admin-state
if (path && path.endsWith('::admin-state')) {
// 將路徑轉換為後端認得的介面格式 (拔除 ::admin-state、替換空白、拔除虛擬索引)
const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
interfaces.add(interfacePath);
}
});
return Array.from(interfaces);
}
// 🚀 Phase 3: 收集修改並呼叫後端轉譯 API // 🚀 Phase 3: 收集修改並呼叫後端轉譯 API
async function previewFolderCLI(path, elementId) { async function previewFolderCLI(path, elementId) {
// 💡 記錄當前正在編輯的節點資訊 // 💡 記錄當前正在編輯的節點資訊
currentEditPath = path; currentEditPath = path;
currentEditElementId = elementId; currentEditElementId = elementId;
currentEditIsSuccess = false; // 預設為尚未成功 currentEditIsSuccess = false; // 預設為尚未成功
currentEditType = 'folder'; // 💡 標記為資料夾編輯
const contentDiv = document.getElementById(`content-${elementId}`); const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) return; if (!contentDiv) return;
@ -160,7 +372,10 @@ async function previewFolderCLI(path, elementId) {
const response = await fetch('/api/v1/cmts-generate-cli', { const response = await fetch('/api/v1/cmts-generate-cli', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ diffs: diffs }) body: JSON.stringify({
diffs: diffs,
interfaces_with_admin_state: getInterfacesWithAdminState() // 💡 附上動態探測名單
})
}); });
const result = await response.json(); const result = await response.json();
@ -176,6 +391,47 @@ async function previewFolderCLI(path, elementId) {
} }
} }
// 🚀 預覽單一項目的 CLI
async function previewLeafCLI(path, elementId) {
currentEditPath = path;
currentEditElementId = elementId;
currentEditIsSuccess = false;
currentEditType = 'leaf'; // 💡 標記為單一項目編輯
const container = document.getElementById(`container-${elementId}`);
if (!container) return;
const input = container.querySelector('.edit-input');
if (!input) return;
const originalVal = container.getAttribute('data-original') || "";
const newVal = input.value.trim();
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
try {
const response = await fetch('/api/v1/cmts-generate-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diffs: diffs,
interfaces_with_admin_state: getInterfacesWithAdminState() // 💡 附上動態探測名單
})
});
const result = await response.json();
if (result.status === 'success') {
showCliPreviewModal(result.data);
} else {
alert("CLI 生成失敗: " + result.message);
}
} catch (error) {
alert("網路連線錯誤,無法生成 CLI");
}
}
// 🚀 Phase 3: 顯示側邊預覽區塊 (初始化為「預覽模式」) // 🚀 Phase 3: 顯示側邊預覽區塊 (初始化為「預覽模式」)
function showCliPreviewModal(cliScript) { function showCliPreviewModal(cliScript) {
const leftPane = document.getElementById('tree-container'); const leftPane = document.getElementById('tree-container');
@ -215,15 +471,20 @@ function hideSideCLI() {
document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none';
// 💡 判斷是否需要更新左側樹狀圖 // 💡 根據編輯類型,呼叫對應的套用函數
if (currentEditIsSuccess && currentEditElementId && currentEditPath) { if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
applyEditFolder(currentEditPath, currentEditElementId); if (currentEditType === 'folder') {
applyEditFolder(currentEditPath, currentEditElementId);
} else if (currentEditType === 'leaf') {
applyEditLeaf(currentEditPath, currentEditElementId);
}
} }
// 重置狀態,等待下一次編輯 // 重置狀態
currentEditPath = null; currentEditPath = null;
currentEditElementId = null; currentEditElementId = null;
currentEditIsSuccess = false; currentEditIsSuccess = false;
currentEditType = null;
} }
// 💡 新增:套用修改並解除鎖定 // 💡 新增:套用修改並解除鎖定
@ -246,6 +507,18 @@ async function applyEditFolder(path, elementId) {
await cancelEditFolder(path, elementId); await cancelEditFolder(path, elementId);
} }
// ✅ 成功寫入後套用單一項目的新值
async function applyEditLeaf(path, elementId) {
const container = document.getElementById(`container-${elementId}`);
if (container) {
const input = container.querySelector('.edit-input');
if (input) {
container.setAttribute('data-original', input.value.trim());
}
}
await cancelEditLeaf(path, elementId);
}
// 🚀 執行側邊區塊的指令 (切換為「執行模式」) // 🚀 執行側邊區塊的指令 (切換為「執行模式」)
function executeSideCLI() { function executeSideCLI() {
const finalScript = document.getElementById('side-cli-textarea').value; const finalScript = document.getElementById('side-cli-textarea').value;
@ -368,6 +641,211 @@ async function executeGeneratedCLI(script) {
} }
} }
// 🔌 外掛函數:為現有的 input 加上下拉選項 (不破壞原有結構)
async function enhanceInputWithOptions(path, elementId) {
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
// 🌟 建立乾淨的 cacheKey (轉換為空白格式,並全域拔除虛擬索引)
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
if (leafCache && leafCache.options && leafCache.options.length > 0 && (currentTime - leafCache.updated_at < CACHE_TTL)) {
// ✅ 找到選項:建立 <datalist> 並綁定到現有的 input 上
const container = document.getElementById(`container-${elementId}`);
const input = container.querySelector('.edit-input');
if (input) {
const listId = `dl-${elementId}`;
input.setAttribute('list', listId); // 讓 input 知道要用哪個清單
// 建立清單元素
let dataList = document.getElementById(listId);
if (!dataList) {
dataList = document.createElement('datalist');
dataList.id = listId;
container.appendChild(dataList);
}
// 填入選項
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
}
} else {
// ❌ 沒找到選項:觸發背景爬蟲,畫面維持原樣不變
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 🌟 傳送乾淨的 cacheKey 給後端爬蟲
body: JSON.stringify({ leaf_paths: [cacheKey] })
});
}
} catch (e) {
console.warn("選項外掛載入失敗,維持純文字輸入", e);
}
}
// 🚀 完美融合版:一鍵掃描缺失選項 (包含快取比對 + 防連點保護 + 僅掃描可見項目)
async function scanAllMissingOptions() {
const btn = document.getElementById('global-scan-btn');
const statusEl = document.getElementById('scan-status');
const treeContainer = document.getElementById('tree-container');
if (!treeContainer) return;
// 💡 1. 啟動防連點鎖定 (假設鎖定 60 秒,您可以自行調整)
const LOCK_DURATION_MS = 60000;
localStorage.setItem('scanLockUntil', Date.now() + LOCK_DURATION_MS);
// 立即將按鈕變灰並禁用
if (btn) {
btn.disabled = true;
btn.style.backgroundColor = "#bdc3c7";
btn.style.cursor = "not-allowed";
btn.innerText = "⏳ 掃描冷卻中...";
}
statusEl.innerHTML = `⏳ 正在比對快取資料...`;
statusEl.style.color = "#f39c12";
try {
// 💡 2. 取得目前的快取資料
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
// 💡 3. 找出畫面上「所有」的設定項目,並過濾出「當前可見」的項目
const allContainers = treeContainer.querySelectorAll('.leaf-container');
const pathsToSync = [];
allContainers.forEach(container => {
// 🛡️ 核心效能優化:檢查這個元素是否被包在「未展開」的資料夾裡面
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (isHiddenByFolder) return; // 如果被隱藏,就直接跳過,不加入掃描清單!
const path = container.getAttribute('data-path');
if (path) {
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
// 🌟 修正重點:
// 拿掉 options 的長度檢查。
// 只要「沒有這筆快取」或「快取已過期」,才需要重新抓取。
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
if (!pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
}
}
});
if (pathsToSync.length === 0) {
statusEl.textContent = "✅ 當前展開的欄位都已有最新選項,無需掃描!";
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 3000);
// 🌟 補上這段:立即解除按鈕鎖定狀態
if (btn) {
btn.disabled = false;
btn.style.backgroundColor = ""; // 恢復預設顏色 (或填入您原本的紫色 #8e44ad)
btn.style.cursor = "pointer";
btn.innerHTML = "🔍 掃描畫面缺失選項";
}
localStorage.removeItem('scanLockUntil'); // 提早解除防連點鎖定
return;
}
// 💡 4. 呼叫後端 API 進行批次抓取
statusEl.innerHTML = `⏳ 正在背景掃描 <b>${pathsToSync.length}</b> 個欄位的選項...`;
statusEl.style.color = "#e67e22";
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: pathsToSync })
});
if (response.ok) {
// 🌟 修改這裡:不要再用 setTimeout 解鎖,而是啟動智慧監控
statusEl.innerHTML = `✅ 任務已送出!正在監控背景執行進度...`;
statusEl.style.color = "#3498db";
// 傳入我們剛剛收集到的 pathsToSync 開始監控
checkScanProgress(pathsToSync);
} else {
throw new Error("API 回應錯誤");
}
} catch (error) {
console.error("掃描請求失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c";
}
}
// 🌟 最終修正版:智慧監控背景掃描進度
async function checkScanProgress(pendingPaths) {
const btn = document.getElementById('global-scan-btn');
const statusEl = document.getElementById('scan-status');
let attempts = 0;
const maxAttempts = 60; // 最多監控 5 分鐘 (每 5 秒查一次)
const interval = setInterval(async () => {
attempts++;
try {
// 1. 取得最新快取
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
// 2. 🌟 關鍵修正:只檢查快取中「是否存在該路徑的紀錄」
const remainingPaths = pendingPaths.filter(path => {
const leafCache = optData[path];
return !leafCache; // 只要沒有紀錄,就代表後端還沒處理完
});
const doneCount = pendingPaths.length - remainingPaths.length;
if (remainingPaths.length === 0) {
// ✅ 全部抓完了!真正解除鎖定
clearInterval(interval);
localStorage.removeItem('scanLockUntil');
if (btn) {
btn.disabled = false;
btn.style.backgroundColor = "#8e44ad";
btn.style.cursor = "pointer";
btn.innerText = "🔍 一鍵掃描缺失選項";
}
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
} else if (attempts >= maxAttempts) {
// ⚠️ 超時防呆
clearInterval(interval);
localStorage.removeItem('scanLockUntil');
if (typeof enableGlobalScanButton === 'function') enableGlobalScanButton();
statusEl.innerHTML = `⚠️ 掃描耗時較長 (${doneCount}/${pendingPaths.length}),已轉為純背景執行。`;
statusEl.style.color = "#f39c12";
} else {
// ⏳ 更新進度條
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${doneCount} / ${pendingPaths.length}</b> 完成...`;
}
} catch (e) {
console.warn("監控進度時發生網路錯誤,稍後重試...", e);
}
}, 5000); // 每 5 秒輪詢一次
}
// ========================================== // ==========================================
// 💡 編輯模式 UI 切換邏輯 (保留舊版單一節點編輯用) // 💡 編輯模式 UI 切換邏輯 (保留舊版單一節點編輯用)
// ========================================== // ==========================================
@ -996,50 +1474,65 @@ function buildTree(data, parentPath = "") {
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
html += ` html += `
<!-- 💡 加上 ontoggle 事件當展開/收合時自動切換 is-open class -->
<details id="details-${elementId}" ontoggle="this.querySelector('summary').classList.toggle('is-open', this.open)"> <details id="details-${elementId}" ontoggle="this.querySelector('summary').classList.toggle('is-open', this.open)">
<!-- 💡 套用 tree-node-header 樣式並加入 list-style: none 隱藏原生黑色小三角形 --> <!-- 💡 確保 summary flex 容器 -->
<summary class="tree-node-header" style="font-weight: bold; color: #2c3e50; margin-top: 5px; list-style: none;"> <summary class="tree-node-header" style="font-weight: bold; color: #2c3e50; margin-top: 5px; list-style: none; display: flex; align-items: center; padding-right: 10px;">
<!-- 💡 替換為純 CSS 的旋轉箭頭與動態資料夾圖示 -->
<span class="tree-chevron"></span> <span class="tree-chevron"></span>
<span class="tree-folder-icon"></span> <span class="tree-folder-icon"></span>
<span>${key}</span>
<!-- 確保資料夾名稱佔據剩餘空間讓後面的按鈕靠右或保持間距 --> <!-- 💡 關鍵 margin-left: auto 將按鈕區塊推到最右側 -->
<span style="flex-grow: 1;">${key}</span> <div style="margin-left: auto; display: flex; align-items: center;">
<!-- 資料夾專屬編輯按鈕 -->
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此區塊">
</span>
<!-- 資料夾專屬編輯按鈕 --> <!-- 儲存與取消按鈕 (預設隱藏) -->
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')" <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" <button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此區塊"> <button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;"> 取消</button>
</span>
</span> </div>
<!-- 儲存與取消按鈕 (預設隱藏) -->
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;"> 取消</button>
</span>
</summary> </summary>
<!-- 💡 包裝子內容方便後續用 JS 抓取裡面的葉節點 -->
<div id="content-${elementId}"> <div id="content-${elementId}">
${buildTree(value, currentPath)} ${buildTree(value, currentPath)}
</div> </div>
</details> </details>
`; `;
} else { } else {
// 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制) // 📄 渲染單一設定項目 (加入獨立編輯按鈕)
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
html += ` html += `
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;"> <!-- 💡 加上 tree-leaf-node 類別啟用 Hover 高光效果 -->
<div class="tree-leaf-node" style="padding: 4px 10px 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
<span style="margin-right: 5px;">📄</span> <b>${key}</b>: <span style="margin-right: 5px;">📄</span> <b>${key}</b>:
<span class="leaf-container" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;"> <span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${value === null ? '' : value}</span> <span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${value === null ? '' : value}</span>
</span> </span>
<div style="margin-left: auto; display: flex; align-items: center;">
<!-- 單一項目專屬編輯按鈕 -->
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
</span>
<!-- 儲存與取消按鈕 (預設隱藏) -->
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditLeaf('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;"> 取消</button>
</span>
</div>
</div> </div>
`; `;
} }
@ -1069,7 +1562,10 @@ async function fetchFullConfig() {
const result = await response.json(); const result = await response.json();
if (result.status === 'success') { if (result.status === 'success') {
if (treeContainer) treeContainer.innerHTML = buildTree(result.data); if (treeContainer) {
treeContainer.innerHTML = buildTree(result.data);
enableGlobalScanButton(); // 🌟 新增:樹狀圖成功渲染後,喚醒掃描按鈕
}
} else { } else {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`; if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
} }
@ -1247,3 +1743,115 @@ function closeModal(event) {
document.getElementById('outputModal').classList.remove('active'); document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
// ==========================================
// 💡 掃描按鈕狀態管理 (支援跨重新整理鎖定)
// ==========================================
// 1. 網頁載入時,檢查是否還有背景任務在跑
document.addEventListener("DOMContentLoaded", () => {
checkScanButtonState();
});
function checkScanButtonState() {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil');
const now = Date.now();
if (scanLockUntil && now < parseInt(scanLockUntil)) {
// 如果還在鎖定期間內,恢復鎖定狀態
lockScanButton(parseInt(scanLockUntil) - now);
}
}
// 2. 鎖定按鈕的 UI 變化
function lockScanButton(durationMs) {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
btn.disabled = true;
btn.style.backgroundColor = "#e67e22"; // 變成醒目的橘色
btn.style.cursor = "not-allowed";
btn.innerText = "⏳ 背景掃描執行中...";
// 設定計時器,時間到自動解鎖
setTimeout(() => {
// 只有當樹狀圖已經載入時,才恢復成可點擊的紫色
const treeContainer = document.getElementById('tree-container');
if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
enableGlobalScanButton();
} else {
// 樹狀圖沒載入,退回初始灰階狀態
btn.disabled = true;
btn.style.backgroundColor = "#bdc3c7";
btn.innerText = "⏳ 請先載入樹狀圖";
}
localStorage.removeItem('scanLockUntil');
}, durationMs);
}
// 3. 提供給「樹狀圖載入完成」時呼叫的解鎖函數
function enableGlobalScanButton() {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil');
// 如果目前沒有被鎖定,才把它變成可點擊的紫色
if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
btn.disabled = false;
btn.style.backgroundColor = "#8e44ad"; // 恢復紫色
btn.style.cursor = "pointer";
btn.innerText = "🔍 掃描畫面缺失選項";
}
}
// ==========================================
// 🌟 新增:清除畫面選項快取功能 (精準可見度判定版)
// ==========================================
async function clearVisibleCache() {
const elements = document.querySelectorAll('.leaf-container');
const pathsToClear = [];
elements.forEach(el => {
// 檢查這個元素是否被包在「未展開」的資料夾裡面
const isHiddenByFolder = el.closest('details:not([open])') !== null;
if (!isHiddenByFolder) {
const path = el.getAttribute('data-path');
if (path) {
const cacheKey = path.replace(/::/g, ' ');
if (!pathsToClear.includes(cacheKey)) {
pathsToClear.push(cacheKey);
}
}
}
});
if (pathsToClear.length === 0) {
alert("畫面上沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
return;
}
if (!confirm(`確定要清除畫面上 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「一鍵掃描」)`)) return;
try {
const response = await fetch('/api/v1/clear_cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pathsToClear)
});
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
const result = await response.json();
alert(`✅ 成功清除 ${result.cleared_count} 筆快取!\n現在請點擊旁邊的「一鍵掃描缺失選項」來重新擷取。`);
} catch (error) {
console.error("清除快取失敗:", error);
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
}
}

View File

@ -66,12 +66,22 @@ button:hover { background-color: #3498db; }
border-radius: 4px; border-radius: 4px;
} }
/* 單一設定項目的容器樣式與懸停高光 */
.tree-leaf-node {
transition: background-color 0.2s ease;
border-radius: 4px;
}
/* 💡 輕量化懸停效果:改用極淺的灰藍色 */ /* 💡 輕量化懸停效果:改用極淺的灰藍色 */
.tree-node-header:hover { .tree-node-header:hover {
background-color: #eef2f5; /* 非常柔和的背景色 */ background-color: #eef2f5; /* 非常柔和的背景色 */
/* 這裡不需要再改變文字顏色了,深色字在淺色背景上非常清晰 */ /* 這裡不需要再改變文字顏色了,深色字在淺色背景上非常清晰 */
} }
.tree-leaf-node:hover {
background-color: #eef2f5; /* 與目錄相同的淺灰藍色背景 */
}
/* 1. 旋轉小箭頭 (Chevron) */ /* 1. 旋轉小箭頭 (Chevron) */
.tree-chevron { .tree-chevron {
width: 16px; width: 16px;

View File

@ -1,9 +1,9 @@
{ {
"hidden_keys": [ "hidden_keys": [
"alias",
"ssh", "ssh",
"hostname", "hostname",
"logging", "logging",
"logging::[0]",
"logging::evt", "logging::evt",
"logging::evt::1024", "logging::evt::1024",
"logging::evt::1024::description", "logging::evt::1024::description",
@ -347,11 +347,36 @@
"logging::evt::2065::priority", "logging::evt::2065::priority",
"logging::evt::2065::active", "logging::evt::2065::active",
"logging::evt::2065::destination", "logging::evt::2065::destination",
"logging::[1]",
"logging::[2]",
"no", "no",
"ipdr", "ipdr",
"ipdr::[0]",
"ipdr::[1]",
"ipdr::[2]",
"ipdr::[3]",
"ipdr::[4]",
"snmp-server", "snmp-server",
"snmp-server::[0]",
"snmp-server::[1]",
"snmp-server::[2]",
"snmp-server::[3]",
"snmp-server::[4]",
"snmp-server::[5]",
"snmp-server::[6]",
"snmp-server::[7]",
"snmp-server::[8]",
"snmp-server::[9]",
"aaa", "aaa",
"aaa::[0]",
"aaa::[1]",
"aaa::[2]",
"aaa::[3]",
"aaa::[4]",
"aaa::[5]",
"radius-server", "radius-server",
"radius-server::[0]",
"radius-server::[1]",
"privilege", "privilege",
"privilege::0", "privilege::0",
"privilege::0::password", "privilege::0::password",
@ -360,8 +385,43 @@
"privilege::15", "privilege::15",
"privilege::15::password", "privilege::15::password",
"cli", "cli",
"cli::[0]",
"cli::[1]",
"packetcable", "packetcable",
"packetcable::[0]",
"packetcable::[1]",
"packetcable::[2]",
"packetcable::[3]",
"packetcable::[4]",
"packetcable::[5]",
"system", "system",
"system::[0]",
"system::[1]",
"system::[2]",
"system::[3]",
"system::[4]",
"system::[5]",
"system::[6]",
"system::[7]",
"system::[8]",
"system::[9]",
"system::[10]",
"system::[11]",
"system::[12]",
"system::[13]",
"system::[14]",
"system::[15]",
"system::[16]",
"system::[17]",
"system::[18]",
"system::[19]",
"system::[20]",
"system::[21]",
"system::[22]",
"system::[23]",
"system::[24]",
"system::[25]",
"system::[26]",
"system::proto-throttle", "system::proto-throttle",
"system::proto-throttle::total", "system::proto-throttle::total",
"system::proto-throttle::total::rate-pps", "system::proto-throttle::total::rate-pps",
@ -384,7 +444,10 @@
"system::proto-throttle::rip", "system::proto-throttle::rip",
"system::proto-throttle::rip::rate-pps", "system::proto-throttle::rip::rate-pps",
"system::proto-throttle::rip::max-burst-pkts", "system::proto-throttle::rip::max-burst-pkts",
"system::[27]",
"system::[28]",
"network", "network",
"clock" "network::[0]",
"network::[1]"
] ]
} }

26
test_scraper.py Normal file
View File

@ -0,0 +1,26 @@
from cmts_scraper import sync_cmts_leaves
# 💡 直接套用您在 shared.py 中驗證過的完美設定
CMTS_DEVICE = {
'device_type': 'cisco_ios',
'host': '10.14.110.4',
'username': 'admin',
'password': 'nsgadmin',
'port': 22,
'fast_cli': False,
'global_delay_factor': 2
}
# 測試用的葉節點路徑
TEST_LEAVES = [
"clock timezone",
"cable mac-domain 13:0/0.0 admin-state"
]
if __name__ == "__main__":
print("🚀 開始執行 CMTS 爬蟲測試...")
result = sync_cmts_leaves(CMTS_DEVICE, TEST_LEAVES)
print("\n📊 最終測試結果:")
import json
print(json.dumps(result, indent=4, ensure_ascii=False))