6767 "Home-page" : "https://github.com/aaltoimaginglanguage/conpy" ,
6868 "Summary" : "Functions and classes for performing connectivity analysis on MEG data." , # noqa: E501
6969 },
70+ # no wheels
71+ "eelbrain" : {
72+ "Home-page" : "https://eelbrain.readthedocs.io" ,
73+ "Summary" : "MEG/EEG analysis tools" ,
74+ },
7075}
7176
7277REQUIRE_INSTALLED = os .getenv ("MNE_REQUIRE_RELATED_SOFTWARE_INSTALLED" , "false" ).lower ()
7984 "matplotlib-base" : "matplotlib" ,
8085}
8186
82- _memory = joblib .Memory (location = pathlib .Path (__file__ ).parent / ".joblib" , verbose = 0 )
87+ # 4. Each package is associated to one or more category. The assignment is done in
88+ # related_software.txt
89+ cat_names = {
90+ "io" : "Data I/O and interoperability" ,
91+ "organization" : "Data organization and workflows" ,
92+ "preproc" : "Preprocessing and artifact correction" ,
93+ "oscillations" : "Oscillations and time-frequency analysis" ,
94+ "connectivity" : "Connectivity and source analysis" ,
95+ "stats" : "Statistics and machine learning" ,
96+ "microstates" : "Microstates and neural states" ,
97+ "modalities" : "Other physiological signals and modalities" ,
98+ "visu" : "Visualization and real-time analysis" ,
99+ "Other" : "Other" ,
100+ }
101+
102+ cwd = pathlib .Path (__file__ ).parent
103+ _memory = joblib .Memory (location = cwd / ".joblib" , verbose = 0 )
83104
84105
85106@_memory .cache (cache_validation_callback = joblib .expires_after (days = 7 ))
@@ -104,6 +125,40 @@ def _get_installer_packages():
104125 return packages
105126
106127
128+ def _get_mapping (packages ):
129+ txt = cwd / "related_software.txt"
130+ txt_nodeps = cwd / "related_software_nodeps.txt"
131+ mapping = dict ()
132+ for line in (txt .read_text () + "\n " + txt_nodeps .read_text ()).split ("\n " ):
133+ line = line .strip ()
134+ if not line or line .startswith ("##" ):
135+ continue
136+ line = line .lstrip ("# " )
137+ pkg = line .split ("#" )[0 ].strip ()
138+ # just keep anything after "categories: " to end of line
139+ if "categories: " in line :
140+ categories = line .split ("categories:" )[- 1 ].strip ()
141+ else :
142+ categories = "Other"
143+ # split the comma-separated list of categories into a tuple of strings
144+ mapping [pkg ] = tuple ([x .strip () for x in categories .split ("," )])
145+ # unpack the tuples to make a single sequence of (unique) categories
146+ categories = sorted (set ([cat for cats in mapping .values () for cat in cats ]))
147+ # put "other" last
148+ if "Other" in categories :
149+ categories .remove ("Other" )
150+ categories .append ("Other" )
151+ # now, invert the mapping to be category: list of packages
152+ rev_mapping = dict ()
153+ for cat in categories :
154+ rev_mapping [cat ] = tuple ([pkg for pkg , cats in mapping .items () if cat in cats ])
155+ # extra packages: not in the two text files
156+ extras = tuple (set (packages ) - set (mapping ))
157+ if len (other := tuple (sorted (rev_mapping .get ("Other" , ()) + extras ))):
158+ rev_mapping ["Other" ] = other
159+ return rev_mapping
160+
161+
107162@functools .lru_cache
108163def _get_packages () -> dict [str , str ]:
109164 try :
@@ -127,10 +182,11 @@ def _get_packages() -> dict[str, str]:
127182 if name not in packages :
128183 packages .append (name )
129184 # Simple alphabetical order
130- packages = sorted (packages , key = lambda x : x .lower ())
131185 packages = [RENAMES .get (package , package ) for package in packages ]
186+ packages = sorted (packages , key = lambda x : x .lower ())
132187 out = dict ()
133188 reasons = []
189+ assert "fsleyes" in packages
134190 for package in status_iterator (
135191 packages , f"Adding { len (packages )} related software packages: "
136192 ):
@@ -141,13 +197,15 @@ def _get_packages() -> dict[str, str]:
141197 else :
142198 md = importlib .metadata .metadata (package )
143199 except importlib .metadata .PackageNotFoundError :
200+ assert "fsleyes" != package
144201 reasons .append (f"{ package } : not found, needs to be installed" )
145202 continue # raise a complete error later
146203 else :
147204 # Every project should really have this
148205 do_continue = False
149206 for key in ("Summary" ,):
150207 if key not in md :
208+ assert "fsleyes" != package
151209 reasons .extend (f"{ package } : missing { repr (key )} " )
152210 do_continue = True
153211 if do_continue :
@@ -185,31 +243,50 @@ def _get_packages() -> dict[str, str]:
185243 f"Could not find suitable metadata for related software:\n { reason_str } "
186244 )
187245
188- return out
246+ # read the .txt files and build the category mapping
247+ cat_to_pkgs_mapping = _get_mapping (out )
248+ return out , cat_to_pkgs_mapping
189249
190250
191251class RelatedSoftwareDirective (Directive ):
192252 """Create a directive that inserts a bullet list of related software."""
193253
194254 def run (self ):
195255 """Run the directive."""
196- my_list = nodes .bullet_list (bullet = "*" )
197- for package , data in _get_packages ().items ():
198- item = nodes .list_item ()
199- if "description" not in data :
200- para = nodes .paragraph (text = f"{ package } " )
201- else :
202- para = nodes .paragraph (text = f": { data ['description' ]} " )
203- refnode = nodes .reference (
204- "url" ,
205- package ,
206- internal = False ,
207- refuri = data ["url" ],
208- )
209- para .insert (0 , refnode )
210- item += para
211- my_list .append (item )
212- return [my_list ]
256+ my_section = list ()
257+ pkg_data , cat_to_pkgs = _get_packages ()
258+ # iterate over category, packages
259+ for category , packages in cat_to_pkgs .items ():
260+ # Make each category a proper (sub)section so that it gets a real
261+ # heading, an anchor, and an entry in the page TOC. This mimics what
262+ # docutils itself does in RSTState.new_subsection.
263+ section = nodes .section ()
264+ # Use real category name instead of short names
265+ title = nodes .title (text = cat_names [category ])
266+ section ["names" ].append (nodes .fully_normalize_name (title .astext ()))
267+ self .state .document .note_implicit_target (section , section )
268+ section += title
269+ my_section .append (section )
270+ this_list = nodes .bullet_list (bullet = "*" )
271+
272+ for package in packages :
273+ data = pkg_data .get (package .lower (), {})
274+ item = nodes .list_item ()
275+ if "description" not in data :
276+ para = nodes .paragraph (text = f"{ package } " )
277+ else :
278+ para = nodes .paragraph (text = f": { data ['description' ]} " )
279+ refnode = nodes .reference (
280+ "url" ,
281+ package ,
282+ internal = False ,
283+ refuri = data ["url" ],
284+ )
285+ para .insert (0 , refnode )
286+ item += para
287+ this_list .append (item )
288+ section += this_list
289+ return my_section
213290
214291
215292def setup (app ): # noqa: D103
@@ -228,7 +305,9 @@ def setup(app): # noqa: D103
228305 # running `python doc/sphinxext/related_software.py` for testing
229306 # require metadata for any installed packages (for debugging)
230307 REQUIRE_METADATA = True
231- items = list (RelatedSoftwareDirective .run (None )[0 ].children )
232- print (f"Got { len (items )} related software packages:" )
233- for item in items :
234- print (f"- { item .astext ()} " )
308+ pkg_data , cat_to_pkgs = _get_packages ()
309+ print (f"Got { len (pkg_data )} related software packages:" )
310+ for category , packages in cat_to_pkgs .items ():
311+ print (f"{ cat_names [category ]} :" )
312+ for package in packages :
313+ print (f"- { package } " )
0 commit comments