scitex 2.14.0__py3-none-any.whl → 2.15.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scitex/__init__.py +71 -17
- scitex/_env_loader.py +156 -0
- scitex/_mcp_resources/__init__.py +37 -0
- scitex/_mcp_resources/_cheatsheet.py +135 -0
- scitex/_mcp_resources/_figrecipe.py +138 -0
- scitex/_mcp_resources/_formats.py +102 -0
- scitex/_mcp_resources/_modules.py +337 -0
- scitex/_mcp_resources/_session.py +149 -0
- scitex/_mcp_tools/__init__.py +4 -0
- scitex/_mcp_tools/audio.py +66 -0
- scitex/_mcp_tools/diagram.py +11 -95
- scitex/_mcp_tools/introspect.py +210 -0
- scitex/_mcp_tools/plt.py +260 -305
- scitex/_mcp_tools/scholar.py +74 -0
- scitex/_mcp_tools/social.py +27 -0
- scitex/_mcp_tools/template.py +24 -0
- scitex/_mcp_tools/writer.py +17 -210
- scitex/ai/_gen_ai/_PARAMS.py +10 -7
- scitex/ai/classification/reporters/_SingleClassificationReporter.py +45 -1603
- scitex/ai/classification/reporters/_mixins/__init__.py +36 -0
- scitex/ai/classification/reporters/_mixins/_constants.py +67 -0
- scitex/ai/classification/reporters/_mixins/_cv_summary.py +387 -0
- scitex/ai/classification/reporters/_mixins/_feature_importance.py +119 -0
- scitex/ai/classification/reporters/_mixins/_metrics.py +275 -0
- scitex/ai/classification/reporters/_mixins/_plotting.py +179 -0
- scitex/ai/classification/reporters/_mixins/_reports.py +153 -0
- scitex/ai/classification/reporters/_mixins/_storage.py +160 -0
- scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit.py +30 -1550
- scitex/ai/classification/timeseries/_sliding_window_core.py +467 -0
- scitex/ai/classification/timeseries/_sliding_window_plotting.py +369 -0
- scitex/audio/README.md +40 -36
- scitex/audio/__init__.py +129 -61
- scitex/audio/_branding.py +185 -0
- scitex/audio/_mcp/__init__.py +32 -0
- scitex/audio/_mcp/handlers.py +59 -6
- scitex/audio/_mcp/speak_handlers.py +238 -0
- scitex/audio/_relay.py +225 -0
- scitex/audio/_tts.py +18 -10
- scitex/audio/engines/base.py +17 -10
- scitex/audio/engines/elevenlabs_engine.py +7 -2
- scitex/audio/mcp_server.py +228 -75
- scitex/canvas/README.md +1 -1
- scitex/canvas/editor/_dearpygui/__init__.py +25 -0
- scitex/canvas/editor/_dearpygui/_editor.py +147 -0
- scitex/canvas/editor/_dearpygui/_handlers.py +476 -0
- scitex/canvas/editor/_dearpygui/_panels/__init__.py +17 -0
- scitex/canvas/editor/_dearpygui/_panels/_control.py +119 -0
- scitex/canvas/editor/_dearpygui/_panels/_element_controls.py +190 -0
- scitex/canvas/editor/_dearpygui/_panels/_preview.py +43 -0
- scitex/canvas/editor/_dearpygui/_panels/_sections.py +390 -0
- scitex/canvas/editor/_dearpygui/_plotting.py +187 -0
- scitex/canvas/editor/_dearpygui/_rendering.py +504 -0
- scitex/canvas/editor/_dearpygui/_selection.py +295 -0
- scitex/canvas/editor/_dearpygui/_state.py +93 -0
- scitex/canvas/editor/_dearpygui/_utils.py +61 -0
- scitex/canvas/editor/flask_editor/_core/__init__.py +27 -0
- scitex/canvas/editor/flask_editor/_core/_bbox_extraction.py +200 -0
- scitex/canvas/editor/flask_editor/_core/_editor.py +173 -0
- scitex/canvas/editor/flask_editor/_core/_export_helpers.py +353 -0
- scitex/canvas/editor/flask_editor/_core/_routes_basic.py +190 -0
- scitex/canvas/editor/flask_editor/_core/_routes_export.py +332 -0
- scitex/canvas/editor/flask_editor/_core/_routes_panels.py +252 -0
- scitex/canvas/editor/flask_editor/_core/_routes_save.py +218 -0
- scitex/canvas/editor/flask_editor/_core.py +25 -1684
- scitex/canvas/editor/flask_editor/templates/__init__.py +32 -70
- scitex/cli/__init__.py +38 -43
- scitex/cli/audio.py +160 -41
- scitex/cli/capture.py +133 -20
- scitex/cli/introspect.py +488 -0
- scitex/cli/main.py +200 -109
- scitex/cli/mcp.py +60 -34
- scitex/cli/plt.py +414 -0
- scitex/cli/repro.py +15 -8
- scitex/cli/resource.py +15 -8
- scitex/cli/scholar/__init__.py +154 -8
- scitex/cli/scholar/_crossref_scitex.py +296 -0
- scitex/cli/scholar/_fetch.py +25 -3
- scitex/cli/social.py +355 -0
- scitex/cli/stats.py +136 -11
- scitex/cli/template.py +129 -12
- scitex/cli/tex.py +15 -8
- scitex/cli/writer.py +49 -299
- scitex/cloud/__init__.py +41 -2
- scitex/config/README.md +1 -1
- scitex/config/__init__.py +16 -2
- scitex/config/_env_registry.py +256 -0
- scitex/context/__init__.py +22 -0
- scitex/dev/__init__.py +20 -1
- scitex/diagram/__init__.py +42 -19
- scitex/diagram/mcp_server.py +13 -125
- scitex/gen/__init__.py +50 -14
- scitex/gen/_list_packages.py +4 -4
- scitex/introspect/__init__.py +82 -0
- scitex/introspect/_call_graph.py +303 -0
- scitex/introspect/_class_hierarchy.py +163 -0
- scitex/introspect/_core.py +41 -0
- scitex/introspect/_docstring.py +131 -0
- scitex/introspect/_examples.py +113 -0
- scitex/introspect/_imports.py +271 -0
- scitex/{gen/_inspect_module.py → introspect/_list_api.py} +48 -56
- scitex/introspect/_mcp/__init__.py +41 -0
- scitex/introspect/_mcp/handlers.py +233 -0
- scitex/introspect/_members.py +155 -0
- scitex/introspect/_resolve.py +89 -0
- scitex/introspect/_signature.py +131 -0
- scitex/introspect/_source.py +80 -0
- scitex/introspect/_type_hints.py +172 -0
- scitex/io/_save.py +1 -2
- scitex/io/bundle/README.md +1 -1
- scitex/logging/_formatters.py +19 -9
- scitex/mcp_server.py +98 -5
- scitex/os/__init__.py +4 -0
- scitex/{gen → os}/_check_host.py +4 -5
- scitex/plt/__init__.py +245 -550
- scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +5 -10
- scitex/plt/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
- scitex/plt/gallery/README.md +1 -1
- scitex/plt/utils/_hitmap/__init__.py +82 -0
- scitex/plt/utils/_hitmap/_artist_extraction.py +343 -0
- scitex/plt/utils/_hitmap/_color_application.py +346 -0
- scitex/plt/utils/_hitmap/_color_conversion.py +121 -0
- scitex/plt/utils/_hitmap/_constants.py +40 -0
- scitex/plt/utils/_hitmap/_hitmap_core.py +334 -0
- scitex/plt/utils/_hitmap/_path_extraction.py +357 -0
- scitex/plt/utils/_hitmap/_query.py +113 -0
- scitex/plt/utils/_hitmap.py +46 -1616
- scitex/plt/utils/_metadata/__init__.py +80 -0
- scitex/plt/utils/_metadata/_artists/__init__.py +25 -0
- scitex/plt/utils/_metadata/_artists/_base.py +195 -0
- scitex/plt/utils/_metadata/_artists/_collections.py +356 -0
- scitex/plt/utils/_metadata/_artists/_extract.py +57 -0
- scitex/plt/utils/_metadata/_artists/_images.py +80 -0
- scitex/plt/utils/_metadata/_artists/_lines.py +261 -0
- scitex/plt/utils/_metadata/_artists/_patches.py +247 -0
- scitex/plt/utils/_metadata/_artists/_text.py +106 -0
- scitex/plt/utils/_metadata/_csv.py +416 -0
- scitex/plt/utils/_metadata/_detect.py +225 -0
- scitex/plt/utils/_metadata/_legend.py +127 -0
- scitex/plt/utils/_metadata/_rounding.py +117 -0
- scitex/plt/utils/_metadata/_verification.py +202 -0
- scitex/schema/README.md +1 -1
- scitex/scholar/__init__.py +8 -0
- scitex/scholar/_mcp/crossref_handlers.py +265 -0
- scitex/scholar/core/Scholar.py +63 -1700
- scitex/scholar/core/_mixins/__init__.py +36 -0
- scitex/scholar/core/_mixins/_enrichers.py +270 -0
- scitex/scholar/core/_mixins/_library_handlers.py +100 -0
- scitex/scholar/core/_mixins/_loaders.py +103 -0
- scitex/scholar/core/_mixins/_pdf_download.py +375 -0
- scitex/scholar/core/_mixins/_pipeline.py +312 -0
- scitex/scholar/core/_mixins/_project_handlers.py +125 -0
- scitex/scholar/core/_mixins/_savers.py +69 -0
- scitex/scholar/core/_mixins/_search.py +103 -0
- scitex/scholar/core/_mixins/_services.py +88 -0
- scitex/scholar/core/_mixins/_url_finding.py +105 -0
- scitex/scholar/crossref_scitex.py +367 -0
- scitex/scholar/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
- scitex/scholar/examples/00_run_all.sh +120 -0
- scitex/scholar/jobs/_executors.py +27 -3
- scitex/scholar/pdf_download/ScholarPDFDownloader.py +38 -416
- scitex/scholar/pdf_download/_cli.py +154 -0
- scitex/scholar/pdf_download/strategies/__init__.py +11 -8
- scitex/scholar/pdf_download/strategies/manual_download_fallback.py +80 -3
- scitex/scholar/pipelines/ScholarPipelineBibTeX.py +73 -121
- scitex/scholar/pipelines/ScholarPipelineParallel.py +80 -138
- scitex/scholar/pipelines/ScholarPipelineSingle.py +43 -63
- scitex/scholar/pipelines/_single_steps.py +71 -36
- scitex/scholar/storage/_LibraryManager.py +97 -1695
- scitex/scholar/storage/_mixins/__init__.py +30 -0
- scitex/scholar/storage/_mixins/_bibtex_handlers.py +128 -0
- scitex/scholar/storage/_mixins/_library_operations.py +218 -0
- scitex/scholar/storage/_mixins/_metadata_conversion.py +226 -0
- scitex/scholar/storage/_mixins/_paper_saving.py +456 -0
- scitex/scholar/storage/_mixins/_resolution.py +376 -0
- scitex/scholar/storage/_mixins/_storage_helpers.py +121 -0
- scitex/scholar/storage/_mixins/_symlink_handlers.py +226 -0
- scitex/security/README.md +3 -3
- scitex/session/README.md +1 -1
- scitex/session/__init__.py +26 -7
- scitex/session/_decorator.py +1 -1
- scitex/sh/README.md +1 -1
- scitex/sh/__init__.py +7 -4
- scitex/social/__init__.py +155 -0
- scitex/social/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
- scitex/stats/_mcp/_handlers/__init__.py +31 -0
- scitex/stats/_mcp/_handlers/_corrections.py +113 -0
- scitex/stats/_mcp/_handlers/_descriptive.py +78 -0
- scitex/stats/_mcp/_handlers/_effect_size.py +106 -0
- scitex/stats/_mcp/_handlers/_format.py +94 -0
- scitex/stats/_mcp/_handlers/_normality.py +110 -0
- scitex/stats/_mcp/_handlers/_posthoc.py +224 -0
- scitex/stats/_mcp/_handlers/_power.py +247 -0
- scitex/stats/_mcp/_handlers/_recommend.py +102 -0
- scitex/stats/_mcp/_handlers/_run_test.py +279 -0
- scitex/stats/_mcp/_handlers/_stars.py +48 -0
- scitex/stats/_mcp/handlers.py +19 -1171
- scitex/stats/auto/_stat_style.py +175 -0
- scitex/stats/auto/_style_definitions.py +411 -0
- scitex/stats/auto/_styles.py +22 -620
- scitex/stats/descriptive/__init__.py +11 -8
- scitex/stats/descriptive/_ci.py +39 -0
- scitex/stats/power/_power.py +15 -4
- scitex/str/__init__.py +2 -1
- scitex/str/_title_case.py +63 -0
- scitex/template/README.md +1 -1
- scitex/template/__init__.py +25 -10
- scitex/template/_code_templates.py +147 -0
- scitex/template/_mcp/handlers.py +81 -0
- scitex/template/_mcp/tool_schemas.py +55 -0
- scitex/template/_templates/__init__.py +51 -0
- scitex/template/_templates/audio.py +233 -0
- scitex/template/_templates/canvas.py +312 -0
- scitex/template/_templates/capture.py +268 -0
- scitex/template/_templates/config.py +43 -0
- scitex/template/_templates/diagram.py +294 -0
- scitex/template/_templates/io.py +107 -0
- scitex/template/_templates/module.py +53 -0
- scitex/template/_templates/plt.py +202 -0
- scitex/template/_templates/scholar.py +267 -0
- scitex/template/_templates/session.py +130 -0
- scitex/template/_templates/session_minimal.py +43 -0
- scitex/template/_templates/session_plot.py +67 -0
- scitex/template/_templates/session_stats.py +77 -0
- scitex/template/_templates/stats.py +323 -0
- scitex/template/_templates/writer.py +296 -0
- scitex/template/clone_writer_directory.py +5 -5
- scitex/ui/_backends/_email.py +10 -2
- scitex/ui/_backends/_webhook.py +5 -1
- scitex/web/_search_pubmed.py +10 -6
- scitex/writer/README.md +1 -1
- scitex/writer/__init__.py +43 -34
- scitex/writer/_mcp/handlers.py +11 -744
- scitex/writer/_mcp/tool_schemas.py +5 -335
- scitex-2.15.3.dist-info/METADATA +667 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/RECORD +241 -120
- scitex/canvas/editor/flask_editor/templates/_scripts.py +0 -4933
- scitex/canvas/editor/flask_editor/templates/_styles.py +0 -1658
- scitex/diagram/_compile.py +0 -312
- scitex/diagram/_diagram.py +0 -355
- scitex/diagram/_mcp/__init__.py +0 -4
- scitex/diagram/_mcp/handlers.py +0 -400
- scitex/diagram/_mcp/tool_schemas.py +0 -157
- scitex/diagram/_presets.py +0 -173
- scitex/diagram/_schema.py +0 -182
- scitex/diagram/_split.py +0 -278
- scitex/gen/_ci.py +0 -12
- scitex/gen/_title_case.py +0 -89
- scitex/plt/_mcp/__init__.py +0 -4
- scitex/plt/_mcp/_handlers_annotation.py +0 -102
- scitex/plt/_mcp/_handlers_figure.py +0 -195
- scitex/plt/_mcp/_handlers_plot.py +0 -252
- scitex/plt/_mcp/_handlers_style.py +0 -219
- scitex/plt/_mcp/handlers.py +0 -74
- scitex/plt/_mcp/tool_schemas.py +0 -497
- scitex/plt/mcp_server.py +0 -231
- scitex/scholar/examples/SUGGESTIONS.md +0 -865
- scitex/scholar/examples/dev.py +0 -38
- scitex-2.14.0.dist-info/METADATA +0 -1238
- /scitex/{gen → context}/_detect_environment.py +0 -0
- /scitex/{gen → context}/_get_notebook_path.py +0 -0
- /scitex/{gen/_shell.py → sh/_shell_legacy.py} +0 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/WHEEL +0 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/entry_points.txt +0 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
2
|
# Timestamp: "2025-12-13 (ywatanabe)"
|
|
4
3
|
# File: _wrappers.py - Seaborn plot wrappers
|
|
5
4
|
|
|
@@ -113,20 +112,16 @@ class SeabornWrappersMixin:
|
|
|
113
112
|
"sns_boxplot", data=data, x=x, y=y, track=track, id=id, **kwargs
|
|
114
113
|
)
|
|
115
114
|
|
|
116
|
-
# Post-processing: Style boxplot
|
|
115
|
+
# Post-processing: Style boxplot elements (0.2mm black lines)
|
|
117
116
|
from scitex.plt.utils import mm_to_pt
|
|
118
117
|
|
|
119
118
|
lw_pt = mm_to_pt(0.2)
|
|
120
|
-
|
|
121
119
|
for line in self._axis_mpl.get_lines():
|
|
122
120
|
line.set_linewidth(lw_pt)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
x_span = abs(xdata[1] - xdata[0])
|
|
128
|
-
if x_span < 0.4:
|
|
129
|
-
line.set_color("black")
|
|
121
|
+
line.set_color("black")
|
|
122
|
+
for patch in self._axis_mpl.patches:
|
|
123
|
+
patch.set_linewidth(lw_pt)
|
|
124
|
+
patch.set_edgecolor("black")
|
|
130
125
|
|
|
131
126
|
if strip:
|
|
132
127
|
strip_kwargs = kwargs.copy()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# External Package Branding Guide
|
|
2
|
+
|
|
3
|
+
When scitex wraps external packages (like `figrecipe` for `scitex.plt` or `crossref-local` for `scitex.scholar`), those packages should support configurable branding so documentation and error messages show the scitex namespace.
|
|
4
|
+
|
|
5
|
+
## When to Use Branding
|
|
6
|
+
|
|
7
|
+
- **Use branding**: External packages that scitex wraps (figrecipe, crossref-local, etc.)
|
|
8
|
+
- **Don't use branding**: Internal scitex modules (scitex.audio, scitex.stats, etc.) - just hardcode `SCITEX_*` prefix
|
|
9
|
+
|
|
10
|
+
## Pattern Overview
|
|
11
|
+
|
|
12
|
+
The external package provides a `_branding.py` module that:
|
|
13
|
+
1. Reads brand name from environment variable
|
|
14
|
+
2. Derives environment variable prefix from brand name
|
|
15
|
+
3. Provides helper functions for rebranding text/docstrings
|
|
16
|
+
|
|
17
|
+
## Implementation Template
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
# external_package/_branding.py
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
# Environment variables for branding
|
|
27
|
+
# Parent package sets these before importing
|
|
28
|
+
BRAND_NAME = os.environ.get("{PACKAGE}_BRAND", "{package}")
|
|
29
|
+
BRAND_ALIAS = os.environ.get("{PACKAGE}_ALIAS", "{alias}")
|
|
30
|
+
|
|
31
|
+
# Original values for replacement
|
|
32
|
+
_ORIGINAL_NAME = "{package}"
|
|
33
|
+
_ORIGINAL_ALIAS = "{alias}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _brand_to_env_prefix(brand: str) -> str:
|
|
37
|
+
"""Convert brand name to environment variable prefix.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
"figrecipe" -> "FIGRECIPE"
|
|
41
|
+
"scitex.plt" -> "SCITEX_PLT"
|
|
42
|
+
"crossref-local" -> "CROSSREF_LOCAL"
|
|
43
|
+
"""
|
|
44
|
+
return brand.upper().replace(".", "_").replace("-", "_")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Environment variable prefix based on brand
|
|
48
|
+
ENV_PREFIX = _brand_to_env_prefix(BRAND_NAME)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_env(key: str, default: Optional[str] = None) -> Optional[str]:
|
|
52
|
+
"""Get environment variable with brand-aware prefix.
|
|
53
|
+
|
|
54
|
+
Checks {ENV_PREFIX}_{key} first, then falls back to original prefix.
|
|
55
|
+
"""
|
|
56
|
+
value = os.environ.get(f"{ENV_PREFIX}_{key}")
|
|
57
|
+
if value is not None:
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
# Fall back to original prefix if different
|
|
61
|
+
original_prefix = _brand_to_env_prefix(_ORIGINAL_NAME)
|
|
62
|
+
if ENV_PREFIX != original_prefix:
|
|
63
|
+
value = os.environ.get(f"{original_prefix}_{key}")
|
|
64
|
+
if value is not None:
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def rebrand_text(text: Optional[str]) -> Optional[str]:
|
|
71
|
+
"""Apply branding to a text string (docstrings, error messages)."""
|
|
72
|
+
if text is None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
if BRAND_NAME == _ORIGINAL_NAME and BRAND_ALIAS == _ORIGINAL_ALIAS:
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
result = text
|
|
79
|
+
|
|
80
|
+
# Replace import statements
|
|
81
|
+
result = re.sub(
|
|
82
|
+
rf"import\s+{_ORIGINAL_NAME}\s+as\s+{_ORIGINAL_ALIAS}",
|
|
83
|
+
f"import {BRAND_NAME} as {BRAND_ALIAS}",
|
|
84
|
+
result,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Replace "from package" statements
|
|
88
|
+
result = re.sub(
|
|
89
|
+
rf"from\s+{_ORIGINAL_NAME}(\s+import|\s*\.)",
|
|
90
|
+
lambda m: f"from {BRAND_NAME}{m.group(1)}",
|
|
91
|
+
result,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_mcp_server_name() -> str:
|
|
98
|
+
"""Get the MCP server name based on branding."""
|
|
99
|
+
return BRAND_NAME.replace(".", "-")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Usage in Parent Package (scitex)
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# scitex/plt/__init__.py
|
|
106
|
+
import os
|
|
107
|
+
|
|
108
|
+
# Set branding BEFORE importing the external package
|
|
109
|
+
os.environ["FIGRECIPE_BRAND"] = "scitex.plt"
|
|
110
|
+
os.environ["FIGRECIPE_ALIAS"] = "plt"
|
|
111
|
+
|
|
112
|
+
# Now import - docstrings will show scitex.plt instead of figrecipe
|
|
113
|
+
from figrecipe import *
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Port Scheme
|
|
117
|
+
|
|
118
|
+
SciTeX uses port scheme 3129X (TEX → te-ku-su → 2-9-3 in Japanese):
|
|
119
|
+
|
|
120
|
+
| Port | Service |
|
|
121
|
+
|-------|------------------|
|
|
122
|
+
| 31290 | scitex-cloud |
|
|
123
|
+
| 31291 | crossref-local |
|
|
124
|
+
| 31292 | openalex |
|
|
125
|
+
| 31293 | scitex-audio |
|
|
126
|
+
|
|
127
|
+
## Environment Variable Pattern
|
|
128
|
+
|
|
129
|
+
External packages should use `{ENV_PREFIX}_{SETTING}`:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
SCITEX_PLT_MODE=local
|
|
133
|
+
CROSSREF_LOCAL_API_URL=http://localhost:8333
|
|
134
|
+
SCITEX_AUDIO_RELAY_URL=http://localhost:31293
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Example: crossref-local
|
|
138
|
+
|
|
139
|
+
See GitHub Issue: https://github.com/ywatanabe1989/crossref-local/issues/11
|
|
140
|
+
|
|
141
|
+
The crossref-local package should implement:
|
|
142
|
+
- `CROSSREF_LOCAL_BRAND` / `CROSSREF_LOCAL_ALIAS` env vars
|
|
143
|
+
- Dynamic `ENV_PREFIX` derived from brand name
|
|
144
|
+
- When used via scitex.scholar, shows `scitex.scholar` in docs
|
|
145
|
+
|
|
146
|
+
## References
|
|
147
|
+
|
|
148
|
+
- figrecipe/_branding.py - Reference implementation
|
|
149
|
+
- scitex/audio/_branding.py - Simple internal module (no rebranding needed)
|
scitex/plt/gallery/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- ---
|
|
2
2
|
!-- Timestamp: 2025-12-08 23:59:46
|
|
3
3
|
!-- Author: ywatanabe
|
|
4
|
-
!-- File: /home/ywatanabe/proj/scitex-
|
|
4
|
+
!-- File: /home/ywatanabe/proj/scitex-python/src/scitex/plt/gallery/README.md
|
|
5
5
|
!-- --- -->
|
|
6
6
|
|
|
7
7
|
# SciTeX Plot Gallery
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Timestamp: "2026-01-24 (ywatanabe)"
|
|
3
|
+
# File: /home/ywatanabe/proj/scitex-python/src/scitex/plt/utils/_hitmap/__init__.py
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Hit map generation utilities for interactive element selection.
|
|
7
|
+
|
|
8
|
+
This package provides functions to generate hit maps for matplotlib figures,
|
|
9
|
+
enabling pixel-perfect element selection in web editors and interactive tools.
|
|
10
|
+
|
|
11
|
+
Supported methods:
|
|
12
|
+
1. ID Colors: Single render with unique colors per element (~89ms)
|
|
13
|
+
2. Export Path Data: Extract geometry for client-side hit testing (~192ms)
|
|
14
|
+
|
|
15
|
+
Reserved colors:
|
|
16
|
+
- Black (#000000, ID=0): Background/no element
|
|
17
|
+
- Dark gray (#010101, ID=65793): Non-selectable axes elements (spines, labels, ticks)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._artist_extraction import (
|
|
21
|
+
detect_logical_groups,
|
|
22
|
+
get_all_artists,
|
|
23
|
+
get_all_artists_with_groups,
|
|
24
|
+
)
|
|
25
|
+
from ._color_application import (
|
|
26
|
+
apply_hitmap_colors,
|
|
27
|
+
apply_id_color,
|
|
28
|
+
prepare_hitmap_figure,
|
|
29
|
+
restore_figure_props,
|
|
30
|
+
restore_original_colors,
|
|
31
|
+
)
|
|
32
|
+
from ._color_conversion import id_to_rgb, rgb_to_id, rgb_to_id_lookup
|
|
33
|
+
from ._constants import HITMAP_AXES_COLOR, HITMAP_BACKGROUND_COLOR, to_native
|
|
34
|
+
from ._hitmap_core import generate_hitmap_id_colors, generate_hitmap_with_bbox_tight
|
|
35
|
+
from ._path_extraction import extract_path_data, extract_selectable_regions
|
|
36
|
+
from ._query import query_hitmap_neighborhood, save_hitmap_png
|
|
37
|
+
|
|
38
|
+
# Backward compatibility aliases
|
|
39
|
+
_to_native = to_native
|
|
40
|
+
_id_to_rgb = id_to_rgb
|
|
41
|
+
_rgb_to_id = rgb_to_id
|
|
42
|
+
_rgb_to_id_lookup = rgb_to_id_lookup
|
|
43
|
+
_apply_id_color = apply_id_color
|
|
44
|
+
_prepare_hitmap_figure = prepare_hitmap_figure
|
|
45
|
+
_restore_figure_props = restore_figure_props
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Constants
|
|
49
|
+
"HITMAP_BACKGROUND_COLOR",
|
|
50
|
+
"HITMAP_AXES_COLOR",
|
|
51
|
+
# Artist extraction
|
|
52
|
+
"get_all_artists",
|
|
53
|
+
"get_all_artists_with_groups",
|
|
54
|
+
"detect_logical_groups",
|
|
55
|
+
# Color conversion
|
|
56
|
+
"id_to_rgb",
|
|
57
|
+
"rgb_to_id",
|
|
58
|
+
"rgb_to_id_lookup",
|
|
59
|
+
# Core hitmap generation
|
|
60
|
+
"generate_hitmap_id_colors",
|
|
61
|
+
"generate_hitmap_with_bbox_tight",
|
|
62
|
+
# Path extraction
|
|
63
|
+
"extract_path_data",
|
|
64
|
+
"extract_selectable_regions",
|
|
65
|
+
# Query and save
|
|
66
|
+
"query_hitmap_neighborhood",
|
|
67
|
+
"save_hitmap_png",
|
|
68
|
+
# Color application
|
|
69
|
+
"apply_hitmap_colors",
|
|
70
|
+
"restore_original_colors",
|
|
71
|
+
# Backward compatibility
|
|
72
|
+
"_to_native",
|
|
73
|
+
"_id_to_rgb",
|
|
74
|
+
"_rgb_to_id",
|
|
75
|
+
"_rgb_to_id_lookup",
|
|
76
|
+
"_apply_id_color",
|
|
77
|
+
"_prepare_hitmap_figure",
|
|
78
|
+
"_restore_figure_props",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# EOF
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Timestamp: "2026-01-24 (ywatanabe)"
|
|
3
|
+
# File: /home/ywatanabe/proj/scitex-python/src/scitex/plt/utils/_hitmap/_artist_extraction.py
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Artist extraction functions for hitmap generation.
|
|
7
|
+
|
|
8
|
+
This module provides functions to extract selectable artists from matplotlib
|
|
9
|
+
figures and detect logical groups (histogram, bar series, etc.).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"get_all_artists",
|
|
16
|
+
"get_all_artists_with_groups",
|
|
17
|
+
"detect_logical_groups",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_all_artists(fig, include_text: bool = False) -> List[Tuple[Any, int, str]]:
|
|
22
|
+
"""
|
|
23
|
+
Extract all selectable artists from a figure.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
fig : matplotlib.figure.Figure
|
|
28
|
+
The figure to extract artists from.
|
|
29
|
+
include_text : bool
|
|
30
|
+
Whether to include text elements.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
list of tuple
|
|
35
|
+
List of (artist, axes_index, artist_type) tuples.
|
|
36
|
+
"""
|
|
37
|
+
artists = []
|
|
38
|
+
|
|
39
|
+
for ax_idx, ax in enumerate(fig.axes):
|
|
40
|
+
# Lines (Line2D)
|
|
41
|
+
for line in ax.get_lines():
|
|
42
|
+
label = line.get_label()
|
|
43
|
+
if not label.startswith("_"): # Skip internal lines
|
|
44
|
+
artists.append((line, ax_idx, "line"))
|
|
45
|
+
|
|
46
|
+
# Scatter plots (PathCollection)
|
|
47
|
+
for coll in ax.collections:
|
|
48
|
+
coll_type = type(coll).__name__
|
|
49
|
+
if "PathCollection" in coll_type:
|
|
50
|
+
artists.append((coll, ax_idx, "scatter"))
|
|
51
|
+
elif "PolyCollection" in coll_type or "FillBetween" in coll_type:
|
|
52
|
+
artists.append((coll, ax_idx, "fill"))
|
|
53
|
+
elif "QuadMesh" in coll_type:
|
|
54
|
+
artists.append((coll, ax_idx, "mesh"))
|
|
55
|
+
|
|
56
|
+
# Bars (Rectangle patches in containers)
|
|
57
|
+
for container in ax.containers:
|
|
58
|
+
if hasattr(container, "patches") and container.patches:
|
|
59
|
+
artists.append((container, ax_idx, "bar"))
|
|
60
|
+
|
|
61
|
+
# Individual patches (rectangles, circles, etc.)
|
|
62
|
+
for patch in ax.patches:
|
|
63
|
+
patch_type = type(patch).__name__
|
|
64
|
+
if patch_type == "Rectangle":
|
|
65
|
+
artists.append((patch, ax_idx, "rectangle"))
|
|
66
|
+
elif patch_type in ("Circle", "Ellipse"):
|
|
67
|
+
artists.append((patch, ax_idx, "circle"))
|
|
68
|
+
elif patch_type == "Polygon":
|
|
69
|
+
artists.append((patch, ax_idx, "polygon"))
|
|
70
|
+
|
|
71
|
+
# Images
|
|
72
|
+
for img in ax.images:
|
|
73
|
+
artists.append((img, ax_idx, "image"))
|
|
74
|
+
|
|
75
|
+
# Text (optional)
|
|
76
|
+
if include_text:
|
|
77
|
+
for text in ax.texts:
|
|
78
|
+
if text.get_text():
|
|
79
|
+
artists.append((text, ax_idx, "text"))
|
|
80
|
+
|
|
81
|
+
return artists
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect_logical_groups(fig) -> Dict[str, Dict[str, Any]]:
|
|
85
|
+
"""
|
|
86
|
+
Detect logical groups in a matplotlib figure.
|
|
87
|
+
|
|
88
|
+
Logical groups represent high-level plot elements that may consist of
|
|
89
|
+
multiple physical matplotlib artists. For example:
|
|
90
|
+
- Histogram: Many Rectangle patches grouped as one "histogram"
|
|
91
|
+
- Bar series: BarContainer with multiple bars
|
|
92
|
+
- Box plot: Box, whiskers, caps, median, fliers as one "boxplot"
|
|
93
|
+
- Error bars: Line + error caps as one "errorbar"
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
fig : matplotlib.figure.Figure
|
|
98
|
+
The figure to analyze.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
dict
|
|
103
|
+
Dictionary mapping group_id to group info.
|
|
104
|
+
"""
|
|
105
|
+
groups = {}
|
|
106
|
+
group_counter = {}
|
|
107
|
+
|
|
108
|
+
def get_group_id(group_type: str, ax_idx: int) -> str:
|
|
109
|
+
"""Generate unique group ID."""
|
|
110
|
+
key = f"{group_type}_{ax_idx}"
|
|
111
|
+
if key not in group_counter:
|
|
112
|
+
group_counter[key] = 0
|
|
113
|
+
idx = group_counter[key]
|
|
114
|
+
group_counter[key] += 1
|
|
115
|
+
return f"{group_type}_{ax_idx}_{idx}"
|
|
116
|
+
|
|
117
|
+
for ax_idx, ax in enumerate(fig.axes):
|
|
118
|
+
# Detect BarContainers (covers bar charts and histograms)
|
|
119
|
+
bar_containers = [
|
|
120
|
+
c for c in ax.containers if "BarContainer" in type(c).__name__
|
|
121
|
+
]
|
|
122
|
+
n_bar_containers = len(bar_containers)
|
|
123
|
+
|
|
124
|
+
for container in ax.containers:
|
|
125
|
+
container_type = type(container).__name__
|
|
126
|
+
|
|
127
|
+
if "BarContainer" in container_type:
|
|
128
|
+
patches = (
|
|
129
|
+
list(container.patches) if hasattr(container, "patches") else []
|
|
130
|
+
)
|
|
131
|
+
if not patches:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Check if bars are adjacent (histogram) or spaced (bar chart)
|
|
135
|
+
is_histogram = False
|
|
136
|
+
if len(patches) > 1:
|
|
137
|
+
widths = [p.get_width() for p in patches]
|
|
138
|
+
x_positions = [p.get_x() for p in patches]
|
|
139
|
+
if len(x_positions) > 1:
|
|
140
|
+
gaps = [
|
|
141
|
+
x_positions[i + 1] - (x_positions[i] + widths[i])
|
|
142
|
+
for i in range(len(x_positions) - 1)
|
|
143
|
+
]
|
|
144
|
+
avg_width = sum(widths) / len(widths)
|
|
145
|
+
is_histogram = all(abs(g) < avg_width * 0.1 for g in gaps)
|
|
146
|
+
|
|
147
|
+
if is_histogram:
|
|
148
|
+
group_type = "histogram"
|
|
149
|
+
group_id = get_group_id(group_type, ax_idx)
|
|
150
|
+
label = ""
|
|
151
|
+
if hasattr(container, "get_label"):
|
|
152
|
+
label = container.get_label()
|
|
153
|
+
if not label or label.startswith("_"):
|
|
154
|
+
label = f"{group_type}_{len([g for g in groups if group_type in g])}"
|
|
155
|
+
|
|
156
|
+
groups[group_id] = {
|
|
157
|
+
"type": group_type,
|
|
158
|
+
"label": label,
|
|
159
|
+
"axes_index": ax_idx,
|
|
160
|
+
"artists": patches,
|
|
161
|
+
"artist_types": ["rectangle"] * len(patches),
|
|
162
|
+
"role": "logical",
|
|
163
|
+
"member_count": len(patches),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
elif n_bar_containers > 1:
|
|
167
|
+
group_type = "bar_series"
|
|
168
|
+
group_id = get_group_id(group_type, ax_idx)
|
|
169
|
+
label = ""
|
|
170
|
+
if hasattr(container, "get_label"):
|
|
171
|
+
label = container.get_label()
|
|
172
|
+
if not label or label.startswith("_"):
|
|
173
|
+
label = f"{group_type}_{len([g for g in groups if group_type in g])}"
|
|
174
|
+
|
|
175
|
+
groups[group_id] = {
|
|
176
|
+
"type": group_type,
|
|
177
|
+
"label": label,
|
|
178
|
+
"axes_index": ax_idx,
|
|
179
|
+
"artists": patches,
|
|
180
|
+
"artist_types": ["rectangle"] * len(patches),
|
|
181
|
+
"role": "logical",
|
|
182
|
+
"member_count": len(patches),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
elif "ErrorbarContainer" in container_type:
|
|
186
|
+
group_id = get_group_id("errorbar", ax_idx)
|
|
187
|
+
artists = []
|
|
188
|
+
artist_types = []
|
|
189
|
+
|
|
190
|
+
if hasattr(container, "lines"):
|
|
191
|
+
data_line, caplines, barlinecols = container.lines
|
|
192
|
+
if data_line:
|
|
193
|
+
artists.append(data_line)
|
|
194
|
+
artist_types.append("line")
|
|
195
|
+
artists.extend(caplines)
|
|
196
|
+
artist_types.extend(["line"] * len(caplines))
|
|
197
|
+
artists.extend(barlinecols)
|
|
198
|
+
artist_types.extend(["line_collection"] * len(barlinecols))
|
|
199
|
+
|
|
200
|
+
label = container.get_label() if hasattr(container, "get_label") else ""
|
|
201
|
+
if not label or label.startswith("_"):
|
|
202
|
+
label = f"errorbar_{len([g for g in groups if 'errorbar' in g])}"
|
|
203
|
+
|
|
204
|
+
groups[group_id] = {
|
|
205
|
+
"type": "errorbar",
|
|
206
|
+
"label": label,
|
|
207
|
+
"axes_index": ax_idx,
|
|
208
|
+
"artists": artists,
|
|
209
|
+
"artist_types": artist_types,
|
|
210
|
+
"role": "logical",
|
|
211
|
+
"member_count": len(artists),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Detect pie charts (Wedge patches)
|
|
215
|
+
wedges = [p for p in ax.patches if type(p).__name__ == "Wedge"]
|
|
216
|
+
if wedges:
|
|
217
|
+
group_id = get_group_id("pie", ax_idx)
|
|
218
|
+
groups[group_id] = {
|
|
219
|
+
"type": "pie",
|
|
220
|
+
"label": "Pie Chart",
|
|
221
|
+
"axes_index": ax_idx,
|
|
222
|
+
"artists": wedges,
|
|
223
|
+
"artist_types": ["wedge"] * len(wedges),
|
|
224
|
+
"role": "logical",
|
|
225
|
+
"member_count": len(wedges),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Detect contour sets
|
|
229
|
+
poly_collections = [
|
|
230
|
+
c
|
|
231
|
+
for c in ax.collections
|
|
232
|
+
if "PolyCollection" in type(c).__name__
|
|
233
|
+
and hasattr(c, "get_array")
|
|
234
|
+
and c.get_array() is not None
|
|
235
|
+
]
|
|
236
|
+
if len(poly_collections) > 2:
|
|
237
|
+
group_id = get_group_id("contour", ax_idx)
|
|
238
|
+
groups[group_id] = {
|
|
239
|
+
"type": "contour",
|
|
240
|
+
"label": "Contour Plot",
|
|
241
|
+
"axes_index": ax_idx,
|
|
242
|
+
"artists": poly_collections,
|
|
243
|
+
"artist_types": ["poly_collection"] * len(poly_collections),
|
|
244
|
+
"role": "logical",
|
|
245
|
+
"member_count": len(poly_collections),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return groups
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_all_artists_with_groups(
|
|
252
|
+
fig, include_text: bool = False
|
|
253
|
+
) -> Tuple[List[Tuple[Any, int, str, Optional[str]]], Dict[str, Dict[str, Any]]]:
|
|
254
|
+
"""
|
|
255
|
+
Extract all selectable artists from a figure with logical group information.
|
|
256
|
+
|
|
257
|
+
Parameters
|
|
258
|
+
----------
|
|
259
|
+
fig : matplotlib.figure.Figure
|
|
260
|
+
The figure to extract artists from.
|
|
261
|
+
include_text : bool
|
|
262
|
+
Whether to include text elements.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
tuple
|
|
267
|
+
(artists_list, groups_dict) where:
|
|
268
|
+
- artists_list: List of (artist, axes_index, artist_type, group_id) tuples
|
|
269
|
+
- groups_dict: Dictionary of logical groups
|
|
270
|
+
"""
|
|
271
|
+
groups = detect_logical_groups(fig)
|
|
272
|
+
|
|
273
|
+
artist_to_group = {}
|
|
274
|
+
for group_id, group_info in groups.items():
|
|
275
|
+
for artist in group_info["artists"]:
|
|
276
|
+
artist_to_group[id(artist)] = group_id
|
|
277
|
+
|
|
278
|
+
artists_with_groups = []
|
|
279
|
+
|
|
280
|
+
for ax_idx, ax in enumerate(fig.axes):
|
|
281
|
+
# Lines
|
|
282
|
+
for line in ax.get_lines():
|
|
283
|
+
label = line.get_label()
|
|
284
|
+
if not label.startswith("_"):
|
|
285
|
+
group_id = artist_to_group.get(id(line))
|
|
286
|
+
artists_with_groups.append((line, ax_idx, "line", group_id))
|
|
287
|
+
|
|
288
|
+
# Collections
|
|
289
|
+
for coll in ax.collections:
|
|
290
|
+
coll_type = type(coll).__name__
|
|
291
|
+
group_id = artist_to_group.get(id(coll))
|
|
292
|
+
if "PathCollection" in coll_type:
|
|
293
|
+
artists_with_groups.append((coll, ax_idx, "scatter", group_id))
|
|
294
|
+
elif "PolyCollection" in coll_type or "FillBetween" in coll_type:
|
|
295
|
+
artists_with_groups.append((coll, ax_idx, "fill", group_id))
|
|
296
|
+
elif "QuadMesh" in coll_type:
|
|
297
|
+
artists_with_groups.append((coll, ax_idx, "mesh", group_id))
|
|
298
|
+
|
|
299
|
+
# Bars
|
|
300
|
+
processed_patches = set()
|
|
301
|
+
for container in ax.containers:
|
|
302
|
+
if hasattr(container, "patches") and container.patches:
|
|
303
|
+
group_id = artist_to_group.get(id(container.patches[0]))
|
|
304
|
+
if group_id:
|
|
305
|
+
artists_with_groups.append((container, ax_idx, "bar", group_id))
|
|
306
|
+
for patch in container.patches:
|
|
307
|
+
processed_patches.add(id(patch))
|
|
308
|
+
else:
|
|
309
|
+
for patch in container.patches:
|
|
310
|
+
artists_with_groups.append((patch, ax_idx, "rectangle", None))
|
|
311
|
+
processed_patches.add(id(patch))
|
|
312
|
+
|
|
313
|
+
# Patches
|
|
314
|
+
for patch in ax.patches:
|
|
315
|
+
if id(patch) in processed_patches:
|
|
316
|
+
continue
|
|
317
|
+
patch_type = type(patch).__name__
|
|
318
|
+
group_id = artist_to_group.get(id(patch))
|
|
319
|
+
if patch_type == "Rectangle":
|
|
320
|
+
artists_with_groups.append((patch, ax_idx, "rectangle", group_id))
|
|
321
|
+
elif patch_type in ("Circle", "Ellipse"):
|
|
322
|
+
artists_with_groups.append((patch, ax_idx, "circle", group_id))
|
|
323
|
+
elif patch_type == "Polygon":
|
|
324
|
+
artists_with_groups.append((patch, ax_idx, "polygon", group_id))
|
|
325
|
+
elif patch_type == "Wedge":
|
|
326
|
+
artists_with_groups.append((patch, ax_idx, "wedge", group_id))
|
|
327
|
+
|
|
328
|
+
# Images
|
|
329
|
+
for img in ax.images:
|
|
330
|
+
group_id = artist_to_group.get(id(img))
|
|
331
|
+
artists_with_groups.append((img, ax_idx, "image", group_id))
|
|
332
|
+
|
|
333
|
+
# Text
|
|
334
|
+
if include_text:
|
|
335
|
+
for text in ax.texts:
|
|
336
|
+
if text.get_text():
|
|
337
|
+
group_id = artist_to_group.get(id(text))
|
|
338
|
+
artists_with_groups.append((text, ax_idx, "text", group_id))
|
|
339
|
+
|
|
340
|
+
return artists_with_groups, groups
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# EOF
|