use uv to run test, more fixes
This commit is contained in:
27
PLAN.md
27
PLAN.md
@@ -357,7 +357,7 @@ All introspection tools below call `session->ensureConnected(timeoutMs)` first;
|
|||||||
|
|
||||||
### Implementation status (updated — see "Known issues / Blocked" section for details on the sub-model population blocker)
|
### Implementation status (updated — see "Known issues / Blocked" section for details on the sub-model population blocker)
|
||||||
|
|
||||||
**Working end-to-end (verified via stdio MCP smoke test against a live probe):**
|
**Working end-to-end (verified via full test suite — 26/26 tests passing, 2026-07-07):**
|
||||||
- ✅ `connectProbe(host?, port?)` / `connectProbeDefault()` / `disconnectProbe()` / `probeStatus()` — lazy connect + auto-reconnect state machine.
|
- ✅ `connectProbe(host?, port?)` / `connectProbeDefault()` / `disconnectProbe()` / `probeStatus()` — lazy connect + auto-reconnect state machine.
|
||||||
- ✅ `listQuickWindows()` / `selectQuickWindow(index)` — window navigation.
|
- ✅ `listQuickWindows()` / `selectQuickWindow(index)` — window navigation.
|
||||||
- ✅ `listScenegraphNodes()` — returns real QSGNode tree with signal-driven `primeAndWait()`.
|
- ✅ `listScenegraphNodes()` — returns real QSGNode tree with signal-driven `primeAndWait()`.
|
||||||
@@ -369,13 +369,25 @@ All introspection tools below call `session->ensureConnected(timeoutMs)` first;
|
|||||||
- ✅ `getMaterialShaders(address)` — reads `shaderModel`. Returns "no shaders" for nodes without materials.
|
- ✅ `getMaterialShaders(address)` — reads `shaderModel`. Returns "no shaders" for nodes without materials.
|
||||||
- ✅ `getShaderSource(row)` — async path via `MaterialExtensionInterface` proxy. Works end-to-end when shaderModel has data.
|
- ✅ `getShaderSource(row)` — async path via `MaterialExtensionInterface` proxy. Works end-to-end when shaderModel has data.
|
||||||
- ✅ `getMaterialProperties(address)` — reads `materialPropertyModel`. Returns "no material properties" for nodes without materials.
|
- ✅ `getMaterialProperties(address)` — reads `materialPropertyModel`. Returns "no material properties" for nodes without materials.
|
||||||
|
- ✅ `selectQuickItem(address)` — selects a QML item via `SelectionModelClient` for `QuickItemModel.selection`. Returns item property data.
|
||||||
|
- ✅ `getItemProperties(address)` — reads `AggregatedPropertyModel` for a selected QML item. Returns 4-column model (name/value/type/class) with nested property groups.
|
||||||
|
- ✅ **Connection tests** (5/5): tools listed, disconnected status, connect+disconnect, reconnect, reconnect after disconnect.
|
||||||
|
- ✅ **Navigation tests** (7/7): list windows, select window, list items, list scenegraph nodes, scenegraph has geometry nodes.
|
||||||
|
- ✅ **Item property tests** (6/6): tools listed, select item, get properties, has position, has visual properties, has property groups.
|
||||||
|
- ✅ **SceneGraph tests** (8/8): select node, get vertices, get adjacency, get shaders, get material properties, set render mode, set slow mode.
|
||||||
|
|
||||||
**Not yet implemented:**
|
**Not yet implemented:**
|
||||||
- ❌ `grabTexture(objectId)` — `TextureExtension` async image grab. Not started.
|
- ❌ `grabTexture(objectId)` — `TextureExtension` async image grab. Not started.
|
||||||
|
|
||||||
### Known issues / Blocked
|
### Known issues / Blocked
|
||||||
|
|
||||||
**RESOLVED — Sub-models now populate correctly after SG node selection (2026-07-07).**
|
**All known blockers resolved as of 2026-07-07.**
|
||||||
|
|
||||||
|
**RESOLVED — Item property model lazy-fetch causes `Loading...` data (2026-07-07).**
|
||||||
|
|
||||||
|
`RemoteModel::data()` returns `"Loading..."` on the first call and queues an async fetch. The data only arrives after the event loop processes the server reply. The fix adds a polling loop in `getItemProperties()` that repeatedly calls `data()` and runs the event loop until non-`"Loading..."` data arrives (5s timeout). Similarly, grouped properties (background, contentItem, etc.) require explicit `rowCount()` triggering + event loop processing + `hasChildren()` check because `RemoteModel::rowCount(parent)` returns 0 until the child count response arrives asynchronously.
|
||||||
|
|
||||||
|
The `primeAndWait()` approach was abandoned for the property model because it triggers a GammaRay `RemoteModel` use-after-free crash (`typeinfo name for QSharedMemory` RTTI corruption). The polling approach avoids this by using only single-cell `data()` calls and short event loop iterations instead of deep recursive tree traversal.
|
||||||
|
|
||||||
The fix was NOT a code change but a sequencing fix in `ensureNodeSelected()`:
|
The fix was NOT a code change but a sequencing fix in `ensureNodeSelected()`:
|
||||||
|
|
||||||
@@ -401,6 +413,7 @@ The fix was NOT a code change but a sequencing fix in `ensureNodeSelected()`:
|
|||||||
All items from the original blocker resolved. Remaining minor issues:
|
All items from the original blocker resolved. Remaining minor issues:
|
||||||
- `listScenegraphNodes` first-call fill stability — first call may show "Loading..." for deep nodes (RemoteModel lazy-fetch behavior); second call returns the full tree. The `primeAndWait()` loop handles this transparently, but single-call would be nicer.
|
- `listScenegraphNodes` first-call fill stability — first call may show "Loading..." for deep nodes (RemoteModel lazy-fetch behavior); second call returns the full tree. The `primeAndWait()` loop handles this transparently, but single-call would be nicer.
|
||||||
- `walkItemChildren` `flagsToJson` uses hardcoded flag bit values (1,2,4,8,16,32,64) — fragile if GammaRay's `ItemFlags` enum changes. Should read from `common/quickitemmodelroles.h` if it were installed.
|
- `walkItemChildren` `flagsToJson` uses hardcoded flag bit values (1,2,4,8,16,32,64) — fragile if GammaRay's `ItemFlags` enum changes. Should read from `common/quickitemmodelroles.h` if it were installed.
|
||||||
|
- Full test suite takes ~8 minutes due to probe start/stop overhead and 500-1500ms settle delays per tool call. Could be optimized with module-scoped bridge fixtures and shared probe process.
|
||||||
|
|
||||||
## Step 7: Launch and run
|
## Step 7: Launch and run
|
||||||
|
|
||||||
@@ -539,9 +552,9 @@ cmake --build build
|
|||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
1. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry).
|
1. **Optimize test suite runtime** — share bridge across tests or reduce settle delays to bring ~8min down to ~2-3min.
|
||||||
2. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior.
|
2. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry).
|
||||||
3. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
|
3. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior.
|
||||||
4. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
|
4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
|
||||||
5. **Pin qtmcp `GIT_TAG`** to a specific commit/tag for reproducibility (currently tracks `main`).
|
5. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
|
||||||
6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
|
6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
|||||||
--injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen
|
--injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen
|
||||||
|
|
||||||
# Start the bridge (stdio MCP server)
|
# Start the bridge (stdio MCP server)
|
||||||
./run.sh
|
bridge/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge starts in lazy-connect mode. Call `connectProbe("127.0.0.1", 11732)` from your MCP client once the probe is up.
|
The bridge starts in lazy-connect mode. Call `connectProbe("127.0.0.1", 11732)` from your MCP client once the probe is up.
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ include(FetchContent)
|
|||||||
FetchContent_Declare(
|
FetchContent_Declare(
|
||||||
qtmcp
|
qtmcp
|
||||||
GIT_REPOSITORY https://github.com/signal-slot/qtmcp.git
|
GIT_REPOSITORY https://github.com/signal-slot/qtmcp.git
|
||||||
GIT_TAG main # TODO: pin to a released tag/commit for reproducibility
|
GIT_TAG 2706345c44e282aba15cf020608af2283e9fb5f9
|
||||||
GIT_SHALLOW TRUE
|
GIT_SHALLOW TRUE
|
||||||
|
GIT_REMOTE_UPDATE_STRATEGY CHECKOUT
|
||||||
)
|
)
|
||||||
# Qt-prefixed vars (NOT BUILD_EXAMPLES) — verified to suppress example/test build.
|
# Qt-prefixed vars (NOT BUILD_EXAMPLES) — verified to suppress example/test build.
|
||||||
set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||||
@@ -44,6 +45,8 @@ add_executable(qml-sg-mcp-bridge
|
|||||||
src/scenegraph_tools.h
|
src/scenegraph_tools.h
|
||||||
src/material_interface.cpp
|
src/material_interface.cpp
|
||||||
src/material_interface.h
|
src/material_interface.h
|
||||||
|
src/quickinspector_proxy.cpp
|
||||||
|
src/quickinspector_proxy.h
|
||||||
src/quickinspector_types.h
|
src/quickinspector_types.h
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
|
|
||||||
#include "gammaray_session.h"
|
#include "gammaray_session.h"
|
||||||
|
|
||||||
|
#include "quickinspector_proxy.h"
|
||||||
|
|
||||||
#include <client/clientconnectionmanager.h>
|
#include <client/clientconnectionmanager.h>
|
||||||
#include <common/objectbroker.h>
|
#include <common/objectbroker.h>
|
||||||
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
#include <QItemSelectionModel>
|
#include <QItemSelectionModel>
|
||||||
|
#include <QMargins>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
|
||||||
// Defined in material_interface.cpp — registers the MaterialExtensionInterface
|
// Defined in material_interface.cpp — registers the MaterialExtensionInterface
|
||||||
@@ -29,6 +32,13 @@ GammaRaySession::GammaRaySession(QObject *parent)
|
|||||||
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::ready, [this]() {
|
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::ready, [this]() {
|
||||||
m_lastError.clear();
|
m_lastError.clear();
|
||||||
setState(State::Ready);
|
setState(State::Ready);
|
||||||
|
|
||||||
|
// Register a minimal QuickInspectorInterface proxy now that
|
||||||
|
// Endpoint::instance() is available. The probe emits features() during
|
||||||
|
// selectWindow — without a client-side object with the right IID,
|
||||||
|
// GammaRay's Endpoint cannot dispatch the signal.
|
||||||
|
GammaRay::registerQuickInspectorProxy();
|
||||||
|
|
||||||
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
|
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
|
||||||
// lazily creates a RemoteModel which then syncs asynchronously from the
|
// lazily creates a RemoteModel which then syncs asynchronously from the
|
||||||
// probe (~1-2s for the first rows). Requesting them now at ready() means
|
// probe (~1-2s for the first rows). Requesting them now at ready() means
|
||||||
@@ -66,6 +76,13 @@ GammaRaySession::~GammaRaySession() = default;
|
|||||||
void GammaRaySession::initOnce()
|
void GammaRaySession::initOnce()
|
||||||
{
|
{
|
||||||
GammaRay::ClientConnectionManager::init();
|
GammaRay::ClientConnectionManager::init();
|
||||||
|
|
||||||
|
// Register Qt types that GammaRay's QDataStream may serialize/deserialize.
|
||||||
|
// Without these, QVariant::load() fails with "unknown user type" and the
|
||||||
|
// GammaRay::Message stream enters an error state, crashing the bridge.
|
||||||
|
qRegisterMetaType<QMargins>("QMargins");
|
||||||
|
qRegisterMetaType<QMarginsF>("QMarginsF");
|
||||||
|
|
||||||
// Register our MaterialExtensionInterface proxy factory. The real factory
|
// Register our MaterialExtensionInterface proxy factory. The real factory
|
||||||
// is in QuickInspectorUiFactory::initUi() (a GUI plugin we don't load).
|
// is in QuickInspectorUiFactory::initUi() (a GUI plugin we don't load).
|
||||||
// Without this, ObjectBroker::object<MaterialExtensionInterface *>() would
|
// Without this, ObjectBroker::object<MaterialExtensionInterface *>() would
|
||||||
@@ -85,11 +102,23 @@ void GammaRaySession::connectToHost(const QUrl &url)
|
|||||||
void GammaRaySession::disconnectFromHost()
|
void GammaRaySession::disconnectFromHost()
|
||||||
{
|
{
|
||||||
m_reconnectScheduled = false;
|
m_reconnectScheduled = false;
|
||||||
// Clear the URL so auto-reconnect won't fire after an explicit disconnect.
|
|
||||||
// (lastUrl() returns empty afterwards — connectProbe() is required to
|
|
||||||
// re-establish, which matches user intent for "I want this bridge off".)
|
|
||||||
m_serverUrl = QUrl();
|
m_serverUrl = QUrl();
|
||||||
m_conMan->disconnectFromHost();
|
m_conMan->disconnectFromHost();
|
||||||
|
|
||||||
|
// Wait for the async disconnect to complete. QTcpSocket::disconnectFromHost()
|
||||||
|
// is asynchronous — Endpoint::connectionClosed() (which clears m_socket)
|
||||||
|
// fires on a later event loop iteration. Without this wait, the next
|
||||||
|
// connectToHost() in the same process would trigger
|
||||||
|
// socketConnected() → Endpoint::setDevice()
|
||||||
|
// which asserts Q_ASSERT(!m_socket) because the old socket is still set.
|
||||||
|
{
|
||||||
|
QEventLoop loop;
|
||||||
|
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::disconnected,
|
||||||
|
&loop, &QEventLoop::quit);
|
||||||
|
QTimer::singleShot(5000, &loop, &QEventLoop::quit);
|
||||||
|
loop.exec();
|
||||||
|
}
|
||||||
|
|
||||||
setState(State::Disconnected);
|
setState(State::Disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +127,7 @@ bool GammaRaySession::ensureConnected(int timeoutMs)
|
|||||||
if (m_state == State::Ready)
|
if (m_state == State::Ready)
|
||||||
return true;
|
return true;
|
||||||
if (!m_serverUrl.isValid())
|
if (!m_serverUrl.isValid())
|
||||||
return false; // caller must invoke connectProbe() first
|
return false;
|
||||||
if (m_state == State::Disconnected || m_state == State::Failed)
|
if (m_state == State::Disconnected || m_state == State::Failed)
|
||||||
connectToHost(m_serverUrl); // kick a fresh attempt
|
connectToHost(m_serverUrl); // kick a fresh attempt
|
||||||
|
|
||||||
|
|||||||
43
bridge/src/quickinspector_proxy.cpp
Normal file
43
bridge/src/quickinspector_proxy.cpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
quickinspector_proxy.cpp — Minimal QuickInspectorInterface client proxy.
|
||||||
|
|
||||||
|
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "quickinspector_proxy.h"
|
||||||
|
|
||||||
|
#include <common/objectbroker.h>
|
||||||
|
|
||||||
|
#include <QMetaType>
|
||||||
|
|
||||||
|
namespace GammaRay {
|
||||||
|
|
||||||
|
QuickInspectorProxy::QuickInspectorProxy(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
// Register the metatype string for QFlags<QuickInspectorInterface::Feature>
|
||||||
|
// as a typedef for int. The features() signal packs this type into QVariant,
|
||||||
|
// and QVariant::load() fails with "unknown user type" without registration.
|
||||||
|
if (QMetaType::fromName("QFlags<GammaRay::QuickInspectorInterface::Feature>").id() == QMetaType::UnknownType) {
|
||||||
|
QMetaType::registerNormalizedTypedef(
|
||||||
|
"QFlags<GammaRay::QuickInspectorInterface::Feature>",
|
||||||
|
QMetaType::fromType<int>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerQuickInspectorProxy()
|
||||||
|
{
|
||||||
|
// Guard: only register once (Endpoint::registerObject asserts on duplicate).
|
||||||
|
static bool registered = false;
|
||||||
|
if (registered)
|
||||||
|
return;
|
||||||
|
registered = true;
|
||||||
|
|
||||||
|
auto *proxy = new QuickInspectorProxy();
|
||||||
|
ObjectBroker::registerObject(
|
||||||
|
QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"),
|
||||||
|
proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace GammaRay
|
||||||
38
bridge/src/quickinspector_proxy.h
Normal file
38
bridge/src/quickinspector_proxy.h
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
quickinspector_proxy.h — Minimal QuickInspectorInterface client proxy.
|
||||||
|
|
||||||
|
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
The probe emits QuickInspectorInterface signals (features, slowModeChanged,
|
||||||
|
etc.) without a matching slot registered via ObjectBroker. This proxy
|
||||||
|
registers with the IID as object name so GammaRay's Endpoint can dispatch
|
||||||
|
signals. No-op stub slots prevent "cannot call method features on unknown
|
||||||
|
object" errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef QUICKINSPECTOR_PROXY_H
|
||||||
|
#define QUICKINSPECTOR_PROXY_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
namespace GammaRay {
|
||||||
|
|
||||||
|
class QuickInspectorProxy : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_CLASSINFO("IID", "com.kdab.GammaRay.QuickInspectorInterface/1.0")
|
||||||
|
public:
|
||||||
|
explicit QuickInspectorProxy(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void features(int) {}
|
||||||
|
void serverSideDecorationChanged(bool) {}
|
||||||
|
void slowModeChanged(bool) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
void registerQuickInspectorProxy();
|
||||||
|
|
||||||
|
} // namespace GammaRay
|
||||||
|
|
||||||
|
#endif // QUICKINSPECTOR_PROXY_H
|
||||||
@@ -780,8 +780,17 @@ QString SceneGraphTools::ensureItemSelected(const QString &address) const
|
|||||||
|
|
||||||
m_selectedItemAddress = address;
|
m_selectedItemAddress = address;
|
||||||
|
|
||||||
// Prime the property model to fetch its row count and cell data.
|
// Wait for the property model to populate. The RemoteModel's lazy-fetch
|
||||||
if (propModel) { propModel->rowCount(); primeAndWait(propModel, 4, 300, 500, 1500); }
|
// mechanism requests data from the probe on the first rowCount() call.
|
||||||
|
// After the wait, the data should have arrived.
|
||||||
|
if (propModel) {
|
||||||
|
propModel->rowCount();
|
||||||
|
// Give the model time to settle. Use a longer wait since the
|
||||||
|
// AggregatedPropertyModel has nested groups that need multiple
|
||||||
|
// fetch rounds. primeAndWait() is avoided here because it triggers
|
||||||
|
// a GammaRay RemoteModel use-after-free in primeTree().
|
||||||
|
settle(loop, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -795,26 +804,28 @@ QString SceneGraphTools::selectQuickItem(const QString &address) const
|
|||||||
|
|
||||||
// Report what we know about the selected item.
|
// Report what we know about the selected item.
|
||||||
auto *itemModel = m_session->model(QString::fromUtf8(kQuickItemModel));
|
auto *itemModel = m_session->model(QString::fromUtf8(kQuickItemModel));
|
||||||
const QModelIndex idx = findNodeByAddress(itemModel, address);
|
QJsonObject out;
|
||||||
QString type;
|
if (!itemModel) {
|
||||||
int flags = 0;
|
out.insert(QStringLiteral("error"), QStringLiteral("QuickItemModel gone"));
|
||||||
int propertyCount = 0;
|
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||||
if (idx.isValid()) {
|
|
||||||
const QModelIndex typeIdx = itemModel->index(idx.row(), 1, idx.parent());
|
|
||||||
type = itemModel->data(typeIdx, Qt::DisplayRole).toString();
|
|
||||||
flags = itemModel->data(idx, kItemFlagsRole).toInt();
|
|
||||||
}
|
}
|
||||||
|
const QModelIndex idx = findNodeByAddress(itemModel, address);
|
||||||
|
if (!idx.isValid()) {
|
||||||
|
out.insert(QStringLiteral("error"), QStringLiteral("address %1 not found after selection").arg(address));
|
||||||
|
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||||
|
}
|
||||||
|
const QModelIndex typeIdx = itemModel->index(idx.row(), 1, idx.parent());
|
||||||
|
const QString type = itemModel->data(typeIdx, Qt::DisplayRole).toString();
|
||||||
|
const int flags = itemModel->data(idx, kItemFlagsRole).toInt();
|
||||||
|
int propertyCount = 0;
|
||||||
auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kItemBaseName)));
|
auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kItemBaseName)));
|
||||||
if (propModel)
|
if (propModel)
|
||||||
propertyCount = propModel->rowCount();
|
propertyCount = propModel->rowCount();
|
||||||
|
|
||||||
QJsonObject out = {
|
out.insert(QStringLiteral("selected"), address);
|
||||||
{ QStringLiteral("address"), address },
|
out.insert(QStringLiteral("type"), type);
|
||||||
{ QStringLiteral("type"), type },
|
out.insert(QStringLiteral("flags"), flags);
|
||||||
{ QStringLiteral("flags"), flagsToJson(flags) },
|
out.insert(QStringLiteral("propertyCount"), propertyCount);
|
||||||
{ QStringLiteral("propertyCount"), propertyCount },
|
|
||||||
};
|
|
||||||
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +850,30 @@ QString SceneGraphTools::getItemProperties(const QString &address) const
|
|||||||
QStringLiteral("no properties available (did you select a valid QML item?)")))
|
QStringLiteral("no properties available (did you select a valid QML item?)")))
|
||||||
.toJson(QJsonDocument::Compact));
|
.toJson(QJsonDocument::Compact));
|
||||||
|
|
||||||
|
// The RemoteModel uses lazy-fetch: data() triggers a fetch and returns
|
||||||
|
// "Loading..." until the fetch response arrives on the event loop.
|
||||||
|
// Poll until column 0 data arrives for at least one row.
|
||||||
|
{
|
||||||
|
QEventLoop loop;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||||
|
bool gotData = false;
|
||||||
|
while (std::chrono::steady_clock::now() < deadline && !gotData) {
|
||||||
|
for (int r = 0; r < rows; ++r) {
|
||||||
|
const QString val = m->data(m->index(r, 0), Qt::DisplayRole).toString();
|
||||||
|
if (!val.isEmpty() && val != QStringLiteral("Loading...")) {
|
||||||
|
gotData = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gotData) {
|
||||||
|
QTimer::singleShot(200, &loop, &QEventLoop::quit);
|
||||||
|
loop.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read rowCount after polling (more rows may have arrived)
|
||||||
|
const int actualRows = m->rowCount();
|
||||||
const int cols = m->columnCount();
|
const int cols = m->columnCount();
|
||||||
|
|
||||||
QJsonObject out;
|
QJsonObject out;
|
||||||
@@ -846,34 +881,76 @@ QString SceneGraphTools::getItemProperties(const QString &address) const
|
|||||||
for (int c = 0; c < cols; ++c)
|
for (int c = 0; c < cols; ++c)
|
||||||
headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString());
|
headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString());
|
||||||
|
|
||||||
|
// Pre-fetch children for grouped properties. RemoteModel uses lazy-fetch:
|
||||||
|
// rowCount(parent) triggers an async request and returns 0 until the reply
|
||||||
|
// arrives on the event loop. After polling, hasChildren() works correctly.
|
||||||
|
for (int r = 0; r < actualRows; ++r) {
|
||||||
|
m->rowCount(m->index(r, 0));
|
||||||
|
}
|
||||||
|
// Poll to let row count responses arrive
|
||||||
|
if (actualRows > 0) {
|
||||||
|
QEventLoop l2;
|
||||||
|
QTimer::singleShot(500, &l2, &QEventLoop::quit);
|
||||||
|
l2.exec();
|
||||||
|
}
|
||||||
|
// Second pass: ensure child data is fetched for rows that have children
|
||||||
|
for (int r = 0; r < actualRows; ++r) {
|
||||||
|
const QModelIndex idx0 = m->index(r, 0);
|
||||||
|
if (m->hasChildren(idx0) && m->canFetchMore(idx0))
|
||||||
|
m->fetchMore(idx0);
|
||||||
|
}
|
||||||
|
if (actualRows > 0) {
|
||||||
|
QEventLoop l2b;
|
||||||
|
QTimer::singleShot(500, &l2b, &QEventLoop::quit);
|
||||||
|
l2b.exec();
|
||||||
|
}
|
||||||
|
|
||||||
// Top-level properties (simple Q_PROPERTY values like x, y, width, height...)
|
// Top-level properties (simple Q_PROPERTY values like x, y, width, height...)
|
||||||
QJsonObject simpleProps;
|
QJsonObject simpleProps;
|
||||||
// Nested property groups (anchors, transform, etc.) will have children.
|
// Nested property groups (anchors, transform, etc.) will have children.
|
||||||
QJsonObject groups;
|
QJsonObject groups;
|
||||||
for (int r = 0; r < rows; ++r) {
|
for (int r = 0; r < actualRows; ++r) {
|
||||||
const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString();
|
const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString();
|
||||||
const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString();
|
const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString();
|
||||||
const QString ptype = cols > 2 ? m->data(m->index(r, 2), Qt::DisplayRole).toString() : QString();
|
const QString ptype = cols > 2 ? m->data(m->index(r, 2), Qt::DisplayRole).toString() : QString();
|
||||||
|
|
||||||
// Check if this property has children (nested sub-properties).
|
|
||||||
QJsonObject prop;
|
QJsonObject prop;
|
||||||
prop.insert(QStringLiteral("value"), val);
|
prop.insert(QStringLiteral("value"), val);
|
||||||
prop.insert(QStringLiteral("type"), ptype);
|
prop.insert(QStringLiteral("type"), ptype);
|
||||||
|
|
||||||
if (m->hasChildren(m->index(r, 0))) {
|
const QModelIndex idx0 = m->index(r, 0);
|
||||||
|
if (m->hasChildren(idx0)) {
|
||||||
// For grouped properties (anchors, transform, etc.), collect children.
|
// For grouped properties (anchors, transform, etc.), collect children.
|
||||||
QJsonObject children;
|
// Children also use lazy-fetch, so poll if needed.
|
||||||
for (int cr = 0; cr < m->rowCount(m->index(r, 0)); ++cr) {
|
const int childRows = m->rowCount(idx0);
|
||||||
const QString cname = m->data(m->index(cr, 0, m->index(r, 0)), Qt::DisplayRole).toString();
|
if (childRows > 0) {
|
||||||
const QString cval = m->data(m->index(cr, 1, m->index(r, 0)), Qt::DisplayRole).toString();
|
// Trigger child data fetches first
|
||||||
const QString ctype = cols > 2 ? m->data(m->index(cr, 2, m->index(r, 0)), Qt::DisplayRole).toString() : QString();
|
for (int cr = 0; cr < childRows; ++cr) {
|
||||||
QJsonObject child;
|
m->data(m->index(cr, 0, idx0), Qt::DisplayRole);
|
||||||
child.insert(QStringLiteral("value"), cval);
|
m->data(m->index(cr, 1, idx0), Qt::DisplayRole);
|
||||||
child.insert(QStringLiteral("type"), ctype);
|
}
|
||||||
children.insert(cname, child);
|
// Poll to let child data arrive
|
||||||
|
{
|
||||||
|
QEventLoop l3;
|
||||||
|
QTimer::singleShot(500, &l3, &QEventLoop::quit);
|
||||||
|
l3.exec();
|
||||||
|
}
|
||||||
|
// Read actual child data
|
||||||
|
QJsonObject children;
|
||||||
|
for (int cr = 0; cr < childRows; ++cr) {
|
||||||
|
const QString cname = m->data(m->index(cr, 0, idx0), Qt::DisplayRole).toString();
|
||||||
|
const QString cval = m->data(m->index(cr, 1, idx0), Qt::DisplayRole).toString();
|
||||||
|
const QString ctype = cols > 2 ? m->data(m->index(cr, 2, idx0), Qt::DisplayRole).toString() : QString();
|
||||||
|
if (cname.isEmpty() || cname == QStringLiteral("Loading..."))
|
||||||
|
continue;
|
||||||
|
QJsonObject child;
|
||||||
|
child.insert(QStringLiteral("value"), cval);
|
||||||
|
child.insert(QStringLiteral("type"), ctype);
|
||||||
|
children.insert(cname, child);
|
||||||
|
}
|
||||||
|
if (!children.isEmpty())
|
||||||
|
prop.insert(QStringLiteral("children"), children);
|
||||||
}
|
}
|
||||||
if (!children.isEmpty())
|
|
||||||
prop.insert(QStringLiteral("children"), children);
|
|
||||||
groups.insert(name, prop);
|
groups.insert(name, prop);
|
||||||
} else {
|
} else {
|
||||||
simpleProps.insert(name, prop);
|
simpleProps.insert(name, prop);
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "gammaray-mcp-tests"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["pytest>=8.0"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
|
|||||||
@@ -1,31 +1,15 @@
|
|||||||
# Test Suite
|
# Test Suite
|
||||||
|
|
||||||
Integration tests for the gammaray-mcp bridge, written in Python using `pytest`. Tests communicate with the bridge over stdio JSON-RPC (the same protocol MCP clients use), exercising every tool end-to-end against a real GammaRay probe.
|
Integration tests for the gammaray-mcp bridge, written in Python using `pytest`.
|
||||||
|
Tests communicate with the bridge over stdio JSON-RPC, exercising every MCP tool
|
||||||
|
end-to-end against a real GammaRay probe.
|
||||||
|
|
||||||
## Prerequisites
|
## Quick start
|
||||||
|
|
||||||
- The bridge must be built: `cmake --build bridge/build`
|
|
||||||
- `pytest` (install via `pip3 install pytest` or `pip install pytest` in a venv)
|
|
||||||
- For probe-dependent tests: a QML test app at `~/Sources/testqml/relayout.qml` (configurable via `TEST_QML` env)
|
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the project root:
|
./run_tests.sh --no-probe # unit tests only (no probe needed)
|
||||||
./tests/run_tests.sh --no-probe # unit tests only (no probe needed)
|
./run_tests.sh -v # full suite, verbose
|
||||||
./tests/run_tests.sh -v # full suite, verbose
|
./run_tests.sh -k "item" # item-related tests only
|
||||||
./tests/run_tests.sh -k "item" # run only item-related tests
|
|
||||||
./tests/run_tests.sh --no-probe -x # stop on first failure, no probe
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly with pytest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd tests
|
|
||||||
LD_LIBRARY_PATH=../install-prefix/lib \
|
|
||||||
QT_QPA_PLATFORM=offscreen \
|
|
||||||
QT_PLUGIN_PATH=../bridge/build/lib/x86_64-linux-gnu/qt6/plugins \
|
|
||||||
pytest -v
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test files
|
## Test files
|
||||||
@@ -37,19 +21,51 @@ pytest -v
|
|||||||
| `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes |
|
| `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes |
|
||||||
| `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes |
|
| `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes |
|
||||||
|
|
||||||
|
## Test QML application
|
||||||
|
|
||||||
|
`testapp/main.qml` is a simple Qt Quick Controls application that exercises all
|
||||||
|
the tools: buttons, switches, sliders, a list view with delegate items, and
|
||||||
|
visibility/opacity controls.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**`run_tests.sh` is the single configuration point.** All paths default to
|
||||||
|
project-local locations. When GammaRay is installed elsewhere (e.g. from a
|
||||||
|
system package), override these by editing the variables at the top of
|
||||||
|
`run_tests.sh`:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GAMMARAY_BIN` | `install-prefix/bin/gammaray` | Path to the probe binary |
|
||||||
|
| `GAMMARAY_LIB_DIR` | `install-prefix/lib` | Runtime library directory for GammaRay |
|
||||||
|
| `QML_RUNNER` | `/usr/lib/qt6/bin/qml` | QML runtime executable |
|
||||||
|
| `TEST_QML` | `testapp/main.qml` | Test QML application |
|
||||||
|
| `BRIDGE_EXE` | `bridge/build/qml-sg-mcp-bridge` | Bridge executable |
|
||||||
|
| `BRIDGE_RUN_SCRIPT` | `bridge/run.sh` | Bridge wrapper script |
|
||||||
|
|
||||||
|
You can also override any of these via environment variables when calling
|
||||||
|
`run_tests.sh` or `pytest` directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GAMMARAY_BIN=/usr/bin/gammaray GAMMARAY_LIB_DIR=/usr/lib/x86_64-linux-gnu \
|
||||||
|
./run_tests.sh --no-probe
|
||||||
|
```
|
||||||
|
|
||||||
|
## pytest options
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `--no-probe` | `false` | Skip all probe-dependent tests |
|
||||||
|
| `--probe-timeout` | 15s | Seconds to wait for probe to become ready |
|
||||||
|
|
||||||
## Markers
|
## Markers
|
||||||
|
|
||||||
- `@pytest.mark.probe` — test requires a running GammaRay probe. Automatically skipped with `--no-probe`.
|
- `@pytest.mark.probe` — test requires a running GammaRay probe.
|
||||||
|
Automatically skipped with `--no-probe`.
|
||||||
|
|
||||||
## Fixtures
|
## Fixtures
|
||||||
|
|
||||||
- `bridge` — fresh `McpClient` instance per test (initialized, not connected)
|
- `bridge` — fresh `McpClient` instance per test (initialized, not connected)
|
||||||
- `connected_bridge` — bridge connected to a probe (via `connectProbeDefault`)
|
- `connected_bridge` — bridge connected to a probe (via `connectProbeDefault`)
|
||||||
- `probe_process` — session-scoped, starts a probe via `systemd-run` (fallback: direct spawn) and waits for `ready()`
|
- `probe_process` — session-scoped, starts a probe via `systemd-run` (fallback:
|
||||||
|
direct spawn) and waits for `ready()`
|
||||||
## Options
|
|
||||||
|
|
||||||
| Flag | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `--no-probe` | `false` | Skip all probe-dependent tests |
|
|
||||||
| `--probe-timeout` | 15s | Seconds to wait for probe to become ready |
|
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
"""pytest fixtures for gammaray-mcp bridge tests."""
|
"""pytest fixtures for gammaray-mcp bridge tests.
|
||||||
|
|
||||||
|
All paths are configurable via environment variables. run_tests.sh is the
|
||||||
|
canonical configuration point — it sets sensible defaults and exports them
|
||||||
|
before calling pytest. If you prefer to run pytest directly, set these:
|
||||||
|
|
||||||
|
GAMMARAY_BIN — path to the gammaray probe binary
|
||||||
|
GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries
|
||||||
|
QML_RUNNER — path to the QML runtime executable
|
||||||
|
TEST_QML — path to the test QML application
|
||||||
|
BRIDGE_EXE — path to the bridge executable
|
||||||
|
BRIDGE_RUN_SCRIPT — path to bridge/run.sh
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -10,11 +22,33 @@ import pytest
|
|||||||
from mcp_client import McpClient
|
from mcp_client import McpClient
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
BRIDGE_EXE = PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"
|
|
||||||
RUN_SCRIPT = PROJECT_ROOT / "bridge" / "run.sh"
|
# All paths read from env vars, with sensible project-local defaults.
|
||||||
PROBE_BIN = PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"
|
# Users only need to override these when GammaRay is installed elsewhere.
|
||||||
TEST_QML = Path(os.environ.get("TEST_QML", str(Path.home() / "Sources" / "testqml" / "relayout.qml")))
|
GAMMARAY_BIN = Path(os.environ.get(
|
||||||
QML_RUNNER = "/usr/lib/qt6/bin/qml"
|
"GAMMARAY_BIN",
|
||||||
|
str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"),
|
||||||
|
))
|
||||||
|
GAMMARAY_LIB_DIR = os.environ.get(
|
||||||
|
"GAMMARAY_LIB_DIR",
|
||||||
|
str(PROJECT_ROOT / "install-prefix" / "lib"),
|
||||||
|
)
|
||||||
|
QML_RUNNER = Path(os.environ.get(
|
||||||
|
"QML_RUNNER",
|
||||||
|
"/usr/lib/qt6/bin/qml",
|
||||||
|
))
|
||||||
|
TEST_QML = Path(os.environ.get(
|
||||||
|
"TEST_QML",
|
||||||
|
str(PROJECT_ROOT / "tests" / "testapp" / "main.qml"),
|
||||||
|
))
|
||||||
|
BRIDGE_EXE = Path(os.environ.get(
|
||||||
|
"BRIDGE_EXE",
|
||||||
|
str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"),
|
||||||
|
))
|
||||||
|
BRIDGE_RUN_SCRIPT = Path(os.environ.get(
|
||||||
|
"BRIDGE_RUN_SCRIPT",
|
||||||
|
str(PROJECT_ROOT / "bridge" / "run.sh"),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
@@ -42,7 +76,6 @@ def pytest_collection_modifyitems(config, items):
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def bridge_path() -> Path:
|
def bridge_path() -> Path:
|
||||||
"""Path to the bridge executable."""
|
|
||||||
if not BRIDGE_EXE.exists():
|
if not BRIDGE_EXE.exists():
|
||||||
pytest.skip(f"Bridge not built at {BRIDGE_EXE}")
|
pytest.skip(f"Bridge not built at {BRIDGE_EXE}")
|
||||||
return BRIDGE_EXE
|
return BRIDGE_EXE
|
||||||
@@ -50,20 +83,19 @@ def bridge_path() -> Path:
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def run_script_path() -> Path:
|
def run_script_path() -> Path:
|
||||||
"""Path to the run.sh wrapper."""
|
if not BRIDGE_RUN_SCRIPT.exists():
|
||||||
if not RUN_SCRIPT.exists():
|
pytest.skip(f"run.sh not found at {BRIDGE_RUN_SCRIPT}")
|
||||||
pytest.skip(f"run.sh not found at {RUN_SCRIPT}")
|
return BRIDGE_RUN_SCRIPT
|
||||||
return RUN_SCRIPT
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="module")
|
||||||
def probe_process(request) -> subprocess.Popen | None:
|
def probe_process(request) -> subprocess.Popen | None:
|
||||||
"""Start a GammaRay probe process (session-scoped, one per test run)."""
|
"""Start a GammaRay probe process (session-scoped, one per test run)."""
|
||||||
if request.config.getoption("--no-probe"):
|
if request.config.getoption("--no-probe"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not PROBE_BIN.exists():
|
if not GAMMARAY_BIN.exists():
|
||||||
pytest.skip(f"GammaRay probe binary not found at {PROBE_BIN}")
|
pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}")
|
||||||
if not TEST_QML.exists():
|
if not TEST_QML.exists():
|
||||||
pytest.skip(f"Test QML file not found at {TEST_QML}")
|
pytest.skip(f"Test QML file not found at {TEST_QML}")
|
||||||
if not QML_RUNNER.exists():
|
if not QML_RUNNER.exists():
|
||||||
@@ -71,7 +103,7 @@ def probe_process(request) -> subprocess.Popen | None:
|
|||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||||
env["LD_LIBRARY_PATH"] = str(PROJECT_ROOT / "install-prefix" / "lib")
|
env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
|
||||||
|
|
||||||
proc = None
|
proc = None
|
||||||
used_systemd = False
|
used_systemd = False
|
||||||
@@ -94,20 +126,21 @@ def probe_process(request) -> subprocess.Popen | None:
|
|||||||
"--working-directory", str(TEST_QML.parent),
|
"--working-directory", str(TEST_QML.parent),
|
||||||
"-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}",
|
"-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}",
|
||||||
"-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}",
|
"-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}",
|
||||||
str(PROBE_BIN),
|
str(GAMMARAY_BIN),
|
||||||
"--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
"--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||||
"--injector", "preload",
|
"--injector", "preload",
|
||||||
QML_RUNNER, str(TEST_QML),
|
str(QML_RUNNER), str(TEST_QML),
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
used_systemd = True
|
used_systemd = True
|
||||||
|
proc = subprocess.Popen(["true"]) # sentinel: probe is external
|
||||||
else:
|
else:
|
||||||
# Fallback: direct spawn
|
# Fallback: direct spawn
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[str(PROBE_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||||
"--injector", "preload", QML_RUNNER, str(TEST_QML)],
|
"--injector", "preload", str(QML_RUNNER), str(TEST_QML)],
|
||||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,15 +189,35 @@ def bridge(run_script_path) -> McpClient:
|
|||||||
c = McpClient(bridge_path=run_script_path)
|
c = McpClient(bridge_path=run_script_path)
|
||||||
c.initialize()
|
c.initialize()
|
||||||
yield c
|
yield c
|
||||||
|
# Gracefully disconnect from the probe before killing the bridge.
|
||||||
|
# GammaRay's QTcpSocket::disconnectFromHost() is asynchronous — the
|
||||||
|
# Endpoint::disconnected signal (which clears m_socket) fires on a later
|
||||||
|
# event loop iteration. If we kill the bridge (SIGTERM) while m_socket is
|
||||||
|
# still set, the probe does not immediately detect the disconnection, and
|
||||||
|
# the next test's bridge cannot connect. Sending disconnectProbe() first
|
||||||
|
# triggers a clean GammaRay protocol-level shutdown that waits for the
|
||||||
|
# async disconnect to complete, releasing the probe for the next test.
|
||||||
|
try:
|
||||||
|
c.call_tool("disconnectProbe", timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass # bridge may have already crashed; best-effort
|
||||||
c.close()
|
c.close()
|
||||||
|
# Allow the probe to detect the disconnection before the next test
|
||||||
|
# connects.
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def connected_bridge(bridge, probe_process) -> McpClient:
|
def connected_bridge(bridge, probe_process) -> McpClient:
|
||||||
"""Bridge connected to a running probe."""
|
"""Bridge connected to a running probe (retries on transient failures)."""
|
||||||
if probe_process is None:
|
if probe_process is None:
|
||||||
pytest.skip("No probe available")
|
pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)")
|
||||||
result = bridge.call_tool("connectProbeDefault")
|
deadline = time.monotonic() + 30
|
||||||
assert isinstance(result, dict), f"connectProbeDefault failed: {result}"
|
last_error = None
|
||||||
assert result.get("connected"), f"Not connected: {result}"
|
while time.monotonic() < deadline:
|
||||||
return bridge
|
result = bridge.call_tool("connectProbeDefault")
|
||||||
|
if isinstance(result, dict) and result.get("connected"):
|
||||||
|
return bridge
|
||||||
|
last_error = result
|
||||||
|
time.sleep(3)
|
||||||
|
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")
|
||||||
@@ -30,11 +30,15 @@ class McpClient:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self._proc.kill()
|
self._proc.kill()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def returncode(self):
|
||||||
|
return self._proc.poll()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stderr(self):
|
def stderr(self):
|
||||||
return self._proc.stderr
|
return self._proc.stderr
|
||||||
|
|
||||||
def send_request(self, method: str, params: dict | None = None) -> dict:
|
def send_request(self, method: str, params: dict | None = None, timeout: float = 60.0) -> dict:
|
||||||
self._next_id += 1
|
self._next_id += 1
|
||||||
req = {
|
req = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -45,14 +49,17 @@ class McpClient:
|
|||||||
line = json.dumps(req)
|
line = json.dumps(req)
|
||||||
self._proc.stdin.write(line + "\n")
|
self._proc.stdin.write(line + "\n")
|
||||||
self._proc.stdin.flush()
|
self._proc.stdin.flush()
|
||||||
return self._read_response(self._next_id)
|
return self._read_response(self._next_id, timeout=timeout)
|
||||||
|
|
||||||
def _read_response(self, expected_id: int, timeout: float = 60.0) -> dict:
|
def _read_response(self, expected_id: int, timeout: float = 60.0) -> dict:
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
line = self._proc.stdout.readline()
|
line = self._proc.stdout.readline()
|
||||||
if not line:
|
if not line:
|
||||||
raise RuntimeError("Bridge process terminated")
|
stderr = self._proc.stderr.read()
|
||||||
|
rc = self._proc.poll()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Bridge process terminated (rc={rc}, stderr: {stderr[-500:] if stderr else 'none'})")
|
||||||
resp = json.loads(line)
|
resp = json.loads(line)
|
||||||
rid = resp.get("id")
|
rid = resp.get("id")
|
||||||
if rid == expected_id:
|
if rid == expected_id:
|
||||||
@@ -74,8 +81,8 @@ class McpClient:
|
|||||||
self._initialized = True
|
self._initialized = True
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
def call_tool(self, name: str, arguments: dict | None = None) -> dict:
|
def call_tool(self, name: str, arguments: dict | None = None, timeout: float = 60.0) -> dict:
|
||||||
resp = self.send_request("tools/call", {"name": name, "arguments": arguments or {}})
|
resp = self.send_request("tools/call", {"name": name, "arguments": arguments or {}}, timeout=timeout)
|
||||||
content = resp.get("result", {}).get("content", [])
|
content = resp.get("result", {}).get("content", [])
|
||||||
if content and content[0].get("type") == "text":
|
if content and content[0].get("type") == "text":
|
||||||
text = content[0]["text"]
|
text = content[0]["text"]
|
||||||
|
|||||||
@@ -1,34 +1,57 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Run the gammaray-mcp test suite.
|
# Convenience script to run the gammaray-mcp test suite.
|
||||||
|
#
|
||||||
|
# This is the SINGLE configuration point for paths. If you install GammaRay
|
||||||
|
# from a system package instead of the local install-prefix, change the
|
||||||
|
# variables below. Everything else (conftest.py, the QML test app, etc.)
|
||||||
|
# reads from these environment variables.
|
||||||
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./run_tests.sh # probe-required tests (default)
|
# ./run_tests.sh # probe-required tests (default)
|
||||||
# ./run_tests.sh --no-probe # only unit tests (no probe needed)
|
# ./run_tests.sh --no-probe # only unit tests (no probe needed)
|
||||||
# ./run_tests.sh -v # verbose
|
# ./run_tests.sh -v # verbose
|
||||||
|
# ./run_tests.sh -k "item" # run only item-related tests
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configurable paths — adjust these if GammaRay is installed elsewhere
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: "${GAMMARAY_BIN:=$PROJECT_ROOT/install-prefix/bin/gammaray}"
|
||||||
|
: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}"
|
||||||
|
: "${QML_RUNNER:=/usr/lib/qt6/bin/qml}"
|
||||||
|
: "${TEST_QML:=$SCRIPT_DIR/testapp/main.qml}"
|
||||||
|
: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge}"
|
||||||
|
: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
|
||||||
|
|
||||||
|
# Export so conftest.py can read them
|
||||||
|
export GAMMARAY_BIN
|
||||||
|
export GAMMARAY_LIB_DIR
|
||||||
|
export QML_RUNNER
|
||||||
|
export TEST_QML
|
||||||
|
export BRIDGE_EXE
|
||||||
|
export BRIDGE_RUN_SCRIPT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure the bridge is built
|
# Ensure the bridge is built
|
||||||
if [ ! -f "$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge" ]; then
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ ! -f "$BRIDGE_EXE" ]; then
|
||||||
echo "Building bridge first..."
|
echo "Building bridge first..."
|
||||||
cmake -S "$PROJECT_ROOT/bridge" -B "$PROJECT_ROOT/bridge/build" -G Ninja \
|
cmake -S "$PROJECT_ROOT/bridge" -B "$PROJECT_ROOT/bridge/build" -G Ninja \
|
||||||
-DCMAKE_PREFIX_PATH="$PROJECT_ROOT/install-prefix"
|
-DCMAKE_PREFIX_PATH="$PROJECT_ROOT/install-prefix"
|
||||||
cmake --build "$PROJECT_ROOT/bridge/build"
|
cmake --build "$PROJECT_ROOT/bridge/build"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use the same env as run.sh
|
# ---------------------------------------------------------------------------
|
||||||
export LD_LIBRARY_PATH="$PROJECT_ROOT/install-prefix/lib"
|
# Runtime environment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
export LD_LIBRARY_PATH="$GAMMARAY_LIB_DIR"
|
||||||
export QT_QPA_PLATFORM=offscreen
|
export QT_QPA_PLATFORM=offscreen
|
||||||
export QT_PLUGIN_PATH="$PROJECT_ROOT/bridge/build/lib/x86_64-linux-gnu/qt6/plugins"
|
export QT_PLUGIN_PATH="$PROJECT_ROOT/bridge/build/lib/x86_64-linux-gnu/qt6/plugins"
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Install test dependencies if needed
|
exec uv run --frozen --directory "$PROJECT_ROOT" python "$SCRIPT_DIR/run_with_uv.py" "$@"
|
||||||
python3 -c "import pytest" 2>/dev/null || {
|
|
||||||
echo "pytest not found. Installing..."
|
|
||||||
pip3 install pytest --user
|
|
||||||
}
|
|
||||||
|
|
||||||
exec python3 -m pytest "$@" .
|
|
||||||
8
tests/run_with_uv.py
Normal file
8
tests/run_with_uv.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Entry point for uv run: adds tests dir to path and runs pytest."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.resolve()))
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.exit(pytest.main(sys.argv[1:]))
|
||||||
@@ -19,26 +19,43 @@ class TestConnection:
|
|||||||
assert status.get("state") in ("disconnected", "connecting")
|
assert status.get("state") in ("disconnected", "connecting")
|
||||||
assert status.get("ready") is False
|
assert status.get("ready") is False
|
||||||
|
|
||||||
def test_connect_and_disconnect(self, connected_bridge):
|
def test_connect_and_disconnect(self, bridge, probe_process):
|
||||||
# connectProbeDefault is already called by connected_bridge fixture
|
if probe_process is None:
|
||||||
status = connected_bridge.call_tool("probeStatus")
|
pytest.skip("No probe available")
|
||||||
|
# Connect
|
||||||
|
result = bridge.call_tool("connectProbeDefault")
|
||||||
|
assert isinstance(result, dict), f"connectProbeDefault failed: {result}"
|
||||||
|
assert result.get("connected"), f"Not connected: {result}"
|
||||||
|
# Verify status
|
||||||
|
status = bridge.call_tool("probeStatus")
|
||||||
assert isinstance(status, dict)
|
assert isinstance(status, dict)
|
||||||
assert status.get("state") == "ready"
|
assert status.get("state") == "ready"
|
||||||
assert status.get("ready") is True
|
assert status.get("ready") is True
|
||||||
assert "tcp://" in status.get("url", "")
|
assert "tcp://" in status.get("url", "")
|
||||||
|
|
||||||
# Now disconnect
|
# Now disconnect
|
||||||
result = connected_bridge.call_tool("disconnectProbe")
|
result = bridge.call_tool("disconnectProbe")
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert result.get("state") == "disconnected"
|
assert result.get("state") == "disconnected"
|
||||||
|
|
||||||
def test_reconnect(self, connected_bridge):
|
def test_reconnect(self, bridge, probe_process):
|
||||||
|
if probe_process is None:
|
||||||
|
pytest.skip("No probe available")
|
||||||
|
bridge.call_tool("connectProbeDefault")
|
||||||
# Disconnect
|
# Disconnect
|
||||||
connected_bridge.call_tool("disconnectProbe")
|
bridge.call_tool("disconnectProbe")
|
||||||
|
|
||||||
# Reconnect with explicit args
|
# Reconnect with explicit args
|
||||||
result = connected_bridge.call_tool("connectProbe",
|
result = bridge.call_tool("connectProbe",
|
||||||
{"host": "127.0.0.1", "port": 11732})
|
{"host": "127.0.0.1", "port": 11732})
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result.get("connected") is True
|
||||||
|
assert result.get("state") == "ready"
|
||||||
|
|
||||||
|
def test_connect_default_after_disconnect(self, bridge, probe_process):
|
||||||
|
if probe_process is None:
|
||||||
|
pytest.skip("No probe available")
|
||||||
|
bridge.call_tool("connectProbeDefault")
|
||||||
|
bridge.call_tool("disconnectProbe")
|
||||||
|
result = bridge.call_tool("connectProbeDefault")
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert result.get("connected") is True
|
assert result.get("connected") is True
|
||||||
assert result.get("state") == "ready"
|
assert result.get("state") == "ready"
|
||||||
|
|||||||
93
tests/testapp/main.qml
Normal file
93
tests/testapp/main.qml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
visible: true
|
||||||
|
width: 600
|
||||||
|
height: 500
|
||||||
|
title: "GammaRay MCP Test App"
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
// Row of buttons at the top
|
||||||
|
RowLayout {
|
||||||
|
spacing: 6
|
||||||
|
Button { text: "Add Item"; onClicked: listModel.append({ name: "Item " + (listModel.count + 1) }) }
|
||||||
|
Button { text: "Remove Item"; onClicked: { if (listModel.count > 0) listModel.remove(listModel.count - 1) } }
|
||||||
|
Button { text: "Force Layout"; onClicked: listView.forceLayout() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to toggle visibility
|
||||||
|
RowLayout {
|
||||||
|
spacing: 6
|
||||||
|
Label { text: "Show ListView:" }
|
||||||
|
Switch {
|
||||||
|
id: visibilitySwitch
|
||||||
|
checked: true
|
||||||
|
onCheckedChanged: listView.visible = checked
|
||||||
|
}
|
||||||
|
Label { text: "Opacity:" }
|
||||||
|
Slider {
|
||||||
|
id: opacitySlider
|
||||||
|
from: 0.0; to: 1.0; value: 1.0
|
||||||
|
onValueChanged: listView.opacity = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListView with delegate items
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "#f0f0f0"
|
||||||
|
radius: 4
|
||||||
|
border.color: "#ccc"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: listView
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 4
|
||||||
|
model: ListModel {
|
||||||
|
id: listModel
|
||||||
|
ListElement { name: "Alpha" }
|
||||||
|
ListElement { name: "Beta" }
|
||||||
|
ListElement { name: "Gamma" }
|
||||||
|
ListElement { name: "Delta" }
|
||||||
|
}
|
||||||
|
delegate: Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 32
|
||||||
|
color: index % 2 === 0 ? "#ffffff" : "#f5f5f5"
|
||||||
|
border.color: "#ddd"
|
||||||
|
border.width: 1
|
||||||
|
radius: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: model.name
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom status bar
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 24
|
||||||
|
color: "#e0e0e0"
|
||||||
|
radius: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Items: " + listModel.count + " | Visible: " + listView.visible
|
||||||
|
font.pixelSize: 11
|
||||||
|
color: "#666"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
uv.lock
generated
Normal file
152
uv.lock
generated
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exceptiongroup"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gammaray-mcp-tests"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "pytest", specifier = ">=8.0" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user