Repository: esi-neuroscience/acme (language: python)

Issue:
Logfile modifcations
Reworked logfile logisitics 
ACME doesn't allow creation of a new client then crashes
Sometimes I start a cluster but then the time runs out for the slurm cue and the workers are killed. If I then try and start a new cluster e.g.:
`acme.esi_cluster_setup(partition="8GBXS", n_workers=10, n_workers_startup=2, timeout=10, interactive_wait=1)`
I get a message like this:

```
Syncopy <ACME: esi_cluster_setup> Found existing parallel computing client <Client: 'tcp://10.100.32.17:40905' processes=0 threads=0, memory=0 B>. Not starting new cluster.
```

However then I try to use pmap with this client and it crashes:

```
RuntimeError: <ACMEdaemon> no active workers found in distributed computing cluster <Client: 'tcp://10.100.32.17:40905' processes=0 threads=0, memory=0 B> Consider running 
	import dask.distributed as dd; dd.get_client().restart()
If this fails to make workers come online, please use
	import acme; acme.cluster_cleanup()
to shut down any defunct distributed computing clients
```

Could the 0 active workers be detected and the empty client automatically cleaned up and replaced with the new one or similar? It's not a big deal though, I can easily work around it.



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index bafae81..f737c5d 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,3 +1,10 @@
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
+
 [run]
 branch = True
 source = acme
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..f298cad
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,52 @@
+---
+name: Report a Bug
+about: Ran into a problem? Please let us know!
+title: ''
+labels: 'bug'
+assignees: ''
+---
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
+Thank you for taking the time to report an issue! We know that writing
+detailed error reports takes effort, and we appreciate you taking the time.
+
+**Describe the problem**
+Please provide a short description of the problem.
+
+**Steps To Reproduce**
+If possible, please provide a short stub of the function you are wrapping
+with `ParallelMap`, e.g.,
+
+```python
+def myfunc(x, y, z):
+    ...
+    return something
+```
+
+Please re-run the erroneous `ParallelMap` call with full verbosity and
+ACME's output recorded in a logfile, i.e.,
+
+```python
+with ParallelMap(myfunc,...,verbose=True, logfile=True) as pmap:
+    pmap.compute()
+```
+
+Please attach the generated logfile to this issue.
+
+**System Profile:**
+Please tell us a little bit about your computational working environment:
+
+Are you using ACME locally or on HPC cluster hardware?
+If your code is running on a cluster:
+- which scheduling manager are you using (SLURM, PBS, etc.)?
+- what architecture/OS version is your code running on (x86, ARM, etc.)?
+  On Linux, please simply paste the output of the command `uname -a` here:
+
+**Additional Information**
+Anything else you feel might be important.
+
+**Thank you again for your time!**
diff --git a/.github/ISSUE_TEMPLATE/new_feature.md b/.github/ISSUE_TEMPLATE/new_feature.md
new file mode 100644
index 0000000..81ea7fe
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/new_feature.md
@@ -0,0 +1,22 @@
+---
+name: Suggest A New Feature
+about: What is currently missing in ACME?
+title: ''
+labels: 'enhancement'
+assignees: ''
+---
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
+**Please provide a short description of the feature you'd like to see in ACME**
+Feel free to elaborate as much as you want. If possible it would be great
+if you could sketch your idea(s) using a minimal (pseudo) code sample.
+
+**Additional context**
+What environment are you currently working in? Is your feature related to
+a specific usage scenario? The more details the better :)
+
+**Thank you for your time!**
diff --git a/.github/ISSUE_TEMPLATE/questions.md b/.github/ISSUE_TEMPLATE/questions.md
new file mode 100644
index 0000000..0d276f2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/questions.md
@@ -0,0 +1,20 @@
+---
+name: Questions/Help/Support/Comments
+about: You have a question or need help running ACME? Get in touch!
+title: ''
+labels: 'question'
+assignees: ''
+---
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
+Please state your question/remark as detailed as possible, ideally by
+providing some (pseudo) code (if possible). Also, please check out the
+[AMCE User Guide](https://esi-acme.readthedocs.io/en/latest/userguide.html)
+and [Advanced Documentation](https://esi-acme.readthedocs.io/en/latest/advanced_usage.html)
+for additional information.
+
+**We look forward to hearing from you!**
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..24fe52e
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,25 @@
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
+Summary
+-------
+-
+
+
+Author Guidelines
+-----------------
+
+- [ ] Is the change set **< 400 lines**?
+- [ ] Is the code running locally **and** on HPC infrastructure?
+- [ ] Is the code running on all supported platforms (macOS, Linux and Windows)?
+
+Reviewer Checklist
+------------------
+
+- [ ] Are testing routines present?
+- [ ] Do objects in the global package namespace perform proper parsing of their input?
+- [ ] Are all docstrings complete and accurate?
+- [ ] Are proposed changes tracked in `CHANGELOG.md`?
diff --git a/.gitignore b/.gitignore
index ce01a8b..2e161ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,10 @@
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
+
 # Compiled Object files
 *.slo
 *.lo
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8c8515b..8933e7b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,3 +1,10 @@
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
+
 stages:
     - tox
     - slurm
diff --git a/.readthedocs.yml b/.readthedocs.yml
index b1b5ee9..4efaf37 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -1,6 +1,12 @@
-# .readthedocs.yml
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
 # Read the Docs configuration file
 # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+#
 
 # Required
 version: 2
diff --git a/.reuse/dep5 b/.reuse/dep5
new file mode 100644
index 0000000..815ef80
--- /dev/null
+++ b/.reuse/dep5
@@ -0,0 +1,10 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: esi-acme
+Upstream-Contact: Stefan Fürtinger <stefan.fuertinger@esi-frankfurt.de>
+Source: https://esi-acme.readthedocs.io/
+
+# Sample paragraph, commented out:
+#
+# Files: src/*
+# Copyright: $YEAR $NAME <$CONTACT>
+# License: ...
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e05248b..40e6c1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,46 @@
+ <!--
+ Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+ in Cooperation with Max Planck Society
+ SPDX-License-Identifier: CC-BY-NC-SA-1.0
+ -->
+
 # Changelog of ACME
 All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 
+## [2023.4] - 2023-04-14
+Re-designed ACME's logs and command line output.
+
+### NEW
+- Created templates for filing issues and opening Pull Requests for ACME
+  on GitHub.
+- Enabled private security reporting in ACME's GitHub repository and
+  added a security policy for ACME (in compliance with the OpenSSF Best
+  Practices Badge)
+
+### CHANGED
+- Overhauled ACME's logging facilities: many print messages have been
+  marked `"DEBUG"` to make ACME's default output less "noisy". To this
+  effect the Python `logging` module is now used more extensively than
+  before. The canonical name of ACME's logger is simply "ACME".
+- By default, ACME now creates a log-file alongside any auto-generated
+  output files to keep a record of file creation and attribution.
+- Reworked ACME's SyNCoPy interface: a dedicated module `spy_interface.py`
+  is now managing ACME's I/O direction if ACME is called by SyNCoPy. This
+  allows for (much) cleaner exception handling in ACME's cluster helpers
+  (`esi_cluster_setup`, `cluster_cleanup` etc.) which ultimately permits
+  a more streamlined extension of ACME to more HPC infrastructure.
+- Redesigned ACME's online documentation: increased font-size to enhance
+  readability, included a contribution guide and reworked the overall page
+  navigation + visual layout.
+
+### REMOVED
+### DEPRECATED
+### FIXED
+- Prevented ACME from accidentally using a dysfunctional client (e.g., a
+  SLURM client with workers whose jobs have been externally cancelled).
+  Thanks to @KatharineShapcott, cf #47
+
 ## [2022.12] - 2022-12-15
 Bugfix release.
 
diff --git a/CITATION.cff b/CITATION.cff
index 94993f2..1ebcd21 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -1,3 +1,10 @@
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
+
 authors:
 - affiliation: "Ernst Str\xFCngmann Institute for Neuroscience in Cooperation with\
     \ Max Planck Society"
@@ -12,7 +19,7 @@ authors:
 - affiliation: "Ernst Str\xFCngmann Institute for Neuroscience in Cooperation with\
     \ Max Planck Society"
   family-names: Schmiedt
-  given-names: Joscha
+  given-names: Joscha Tapani
   orcid: https://orcid.org/0000-0001-6233-1866
 cff-version: 1.1.0
 date-released: '2022-12-15'
diff --git a/LICENSE b/LICENSE
index 4fd3356..c014f05 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 BSD 3-Clause License
 
-Copyright (c) 2020, Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+Copyright (c) 2020-2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt
new file mode 100644
index 0000000..9593a37
--- /dev/null
+++ b/LICENSES/BSD-3-Clause.txt
@@ -0,0 +1,11 @@
+Copyright (c) 2020-2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/LICENSES/CC-BY-NC-ND-1.0.txt b/LICENSES/CC-BY-NC-ND-1.0.txt
new file mode 100644
index 0000000..91bde04
--- /dev/null
+++ b/LICENSES/CC-BY-NC-ND-1.0.txt
@@ -0,0 +1,75 @@
+Creative Commons Attribution-NoDerivs-NonCommercial 1.0
+
+  CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DRAFT LICENSE DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM ITS USE.
+
+License
+
+THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE IS PROHIBITED.
+
+BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS.
+
+1. Definitions
+
+     a. "Collective Work" means a work, such as a periodical issue, anthology or encyclopedia, in which the Work in its entirety in unmodified form, along with a number of other contributions, constituting separate and independent works in themselves, are assembled into a collective whole. A work that constitutes a Collective Work will not be considered a Derivative Work (as defined below) for the purposes of this License.
+
+     b. "Derivative Work" means a work based upon the Work or upon the Work and other pre-existing works, such as a translation, musical arrangement, dramatization, fictionalization, motion picture version, sound recording, art reproduction, abridgment, condensation, or any other form in which the Work may be recast, transformed, or adapted, except that a work that constitutes a Collective Work will not be considered a Derivative Work for the purpose of this License.
+
+     c. "Licensor" means the individual or entity that offers the Work under the terms of this License.
+
+     d. "Original Author" means the individual or entity who created the Work.
+
+     e. "Work" means the copyrightable work of authorship offered under the terms of this License.
+
+     f. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation.
+
+2. Fair Use Rights. Nothing in this license is intended to reduce, limit, or restrict any rights arising from fair use, first sale or other limitations on the exclusive rights of the copyright owner under copyright law or other applicable laws.
+
+3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below:
+
+     a. to reproduce the Work, to incorporate the Work into one or more Collective Works, and to reproduce the Work as incorporated in the Collective Works;
+
+     b. to distribute copies or phonorecords of, display publicly, perform publicly, and perform publicly by means of a digital audio transmission the Work including as incorporated in Collective Works;
+
+The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. All rights not expressly granted by Licensor are hereby reserved.
+
+4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions:
+
+     a. You may distribute, publicly display, publicly perform, or publicly digitally perform the Work only under the terms of this License, and You must include a copy of, or the Uniform Resource Identifier for, this License with every copy or phonorecord of the Work You distribute, publicly display, publicly perform, or publicly digitally perform. You may not offer or impose any terms on the Work that alter or restrict the terms of this License or the recipients' exercise of the rights granted hereunder. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties. You may not distribute, publicly display, publicly perform, or publicly digitally perform the Work with any technological measures that control access or use of the Work in a manner inconsistent with the terms of this License Agreement. The above applies to the Work as incorporated in a Collective Work, but this does not require the Collective Work apart from the Work itself to be made subject to the terms of this License. If You create a Collective Work, upon notice from any Licensor You must, to the extent practicable, remove from the Collective Work any reference to such Licensor or the Original Author, as requested.
+
+     b. You may not exercise any of the rights granted to You in Section 3 above in any manner that is primarily intended for or directed toward commercial advantage or private monetary compensation. The exchange of the Work for other copyrighted works by means of digital file-sharing or otherwise shall not be considered to be intended for or directed toward commercial advantage or private monetary compensation, provided there is no payment of any monetary compensation in connection with the exchange of copyrighted works.
+
+     c. If you distribute, publicly display, publicly perform, or publicly digitally perform the Work or any Collective Works, You must keep intact all copyright notices for the Work and give the Original Author credit reasonable to the medium or means You are utilizing by conveying the name (or pseudonym if applicable) of the Original Author if supplied; the title of the Work if supplied. Such credit may be implemented in any reasonable manner; provided, however, that in the case of a Collective Work, at a minimum such credit will appear where any other comparable authorship credit appears and in a manner at least as prominent as such other comparable authorship credit.
+
+5. Representations, Warranties and Disclaimer
+
+     a. By offering the Work for public release under this License, Licensor represents and warrants that, to the best of Licensor's knowledge after reasonable inquiry:
+
+          i. Licensor has secured all rights in the Work necessary to grant the license rights hereunder and to permit the lawful exercise of the rights granted hereunder without You having any obligation to pay any royalties, compulsory license fees, residuals or any other payments;
+
+          ii. The Work does not infringe the copyright, trademark, publicity rights, common law rights or any other right of any third party or constitute defamation, invasion of privacy or other tortious injury to any third party.
+
+     b. EXCEPT AS EXPRESSLY STATED IN THIS LICENSE OR OTHERWISE AGREED IN WRITING OR REQUIRED BY APPLICABLE LAW, THE WORK IS LICENSED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES REGARDING THE CONTENTS OR ACCURACY OF THE WORK.
+
+6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, AND EXCEPT FOR DAMAGES ARISING FROM LIABILITY TO A THIRD PARTY RESULTING FROM BREACH OF THE WARRANTIES IN SECTION 5, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+7. Termination
+
+     a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Collective Works from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License.
+
+     b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above.
+
+8. Miscellaneous
+
+     a. Each time You distribute or publicly digitally perform the Work or a Collective Work, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License.
+
+     b. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable.
+
+     c. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent.
+
+     d. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. This License may not be modified without the mutual written agreement of the Licensor and You.
+
+Creative Commons is not a party to this License, and makes no warranty whatsoever in connection with the Work. Creative Commons will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, if Creative Commons has expressly identified itself as the Licensor hereunder, it shall have all rights and obligations of Licensor.
+
+Except for the limited purpose of indicating to the public that the Work is licensed under the CCPL, neither party will use the trademark "Creative Commons" or any related trademark or logo of Creative Commons without the prior written consent of Creative Commons. Any permitted use will be in compliance with Creative Commons' then-current trademark usage guidelines, as may be published on its website or otherwise made available upon request from time to time.
+
+Creative Commons may be contacted at http://creativecommons.org/.
diff --git a/LICENSES/CC-BY-NC-SA-1.0.txt b/LICENSES/CC-BY-NC-SA-1.0.txt
new file mode 100644
index 0000000..0722e12
--- /dev/null
+++ b/LICENSES/CC-BY-NC-SA-1.0.txt
@@ -0,0 +1,83 @@
+Creative Commons Attribution-NonCommercial-ShareAlike 1.0
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DRAFT LICENSE DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM ITS USE.
+
+License
+
+THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE IS PROHIBITED.
+
+BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS.
+
+1. Definitions
+
+     a. "Collective Work" means a work, such as a periodical issue, anthology or encyclopedia, in which the Work in its entirety in unmodified form, along with a number of other contributions, constituting separate and independent works in themselves, are assembled into a collective whole. A work that constitutes a Collective Work will not be considered a Derivative Work (as defined below) for the purposes of this License.
+
+     b. "Derivative Work" means a work based upon the Work or upon the Work and other pre-existing works, such as a translation, musical arrangement, dramatization, fictionalization, motion picture version, sound recording, art reproduction, abridgment, condensation, or any other form in which the Work may be recast, transformed, or adapted, except that a work that constitutes a Collective Work will not be considered a Derivative Work for the purpose of this License.
+
+     c. "Licensor" means the individual or entity that offers the Work under the terms of this License.
+
+     d. "Original Author" means the individual or entity who created the Work.
+
+     e. "Work" means the copyrightable work of authorship offered under the terms of this License.
+
+     f. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation.
+
+2. Fair Use Rights. Nothing in this license is intended to reduce, limit, or restrict any rights arising from fair use, first sale or other limitations on the exclusive rights of the copyright owner under copyright law or other applicable laws.
+
+3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below:
+
+     a. to reproduce the Work, to incorporate the Work into one or more Collective Works, and to reproduce the Work as incorporated in the Collective Works;
+
+     b. to create and reproduce Derivative Works;
+
+     c. to distribute copies or phonorecords of, display publicly, perform publicly, and perform publicly by means of a digital audio transmission the Work including as incorporated in Collective Works;
+
+     d. to distribute copies or phonorecords of, display publicly, perform publicly, and perform publicly by means of a digital audio transmission Derivative Works;
+
+The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. All rights not expressly granted by Licensor are hereby reserved.
+
+4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions:
+
+     a. You may distribute, publicly display, publicly perform, or publicly digitally perform the Work only under the terms of this License, and You must include a copy of, or the Uniform Resource Identifier for, this License with every copy or phonorecord of the Work You distribute, publicly display, publicly perform, or publicly digitally perform. You may not offer or impose any terms on the Work that alter or restrict the terms of this License or the recipients' exercise of the rights granted hereunder. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties. You may not distribute, publicly display, publicly perform, or publicly digitally perform the Work with any technological measures that control access or use of the Work in a manner inconsistent with the terms of this License Agreement. The above applies to the Work as incorporated in a Collective Work, but this does not require the Collective Work apart from the Work itself to be made subject to the terms of this License. If You create a Collective Work, upon notice from any Licensor You must, to the extent practicable, remove from the Collective Work any reference to such Licensor or the Original Author, as requested. If You create a Derivative Work, upon notice from any Licensor You must, to the extent practicable, remove from the Derivative Work any reference to such Licensor or the Original Author, as requested.
+
+     b. You may distribute, publicly display, publicly perform, or publicly digitally perform a Derivative Work only under the terms of this License, and You must include a copy of, or the Uniform Resource Identifier for, this License with every copy or phonorecord of each Derivative Work You distribute, publicly display, publicly perform, or publicly digitally perform. You may not offer or impose any terms on the Derivative Works that alter or restrict the terms of this License or the recipients' exercise of the rights granted hereunder, and You must keep intact all notices that refer to this License and to the disclaimer of warranties. You may not distribute, publicly display, publicly perform, or publicly digitally perform the Derivative Work with any technological measures that control access or use of the Work in a manner inconsistent with the terms of this License Agreement. The above applies to the Derivative Work as incorporated in a Collective Work, but this does not require the Collective Work apart from the Derivative Work itself to be made subject to the terms of this License.
+
+     c. You may not exercise any of the rights granted to You in Section 3 above in any manner that is primarily intended for or directed toward commercial advantage or private monetary compensation. The exchange of the Work for other copyrighted works by means of digital file-sharing or otherwise shall not be considered to be intended for or directed toward commercial advantage or private monetary compensation, provided there is no payment of any monetary compensation in connection with the exchange of copyrighted works.
+
+     d. If you distribute, publicly display, publicly perform, or publicly digitally perform the Work or any Derivative Works or Collective Works, You must keep intact all copyright notices for the Work and give the Original Author credit reasonable to the medium or means You are utilizing by conveying the name (or pseudonym if applicable) of the Original Author if supplied; the title of the Work if supplied; in the case of a Derivative Work, a credit identifying the use of the Work in the Derivative Work (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). Such credit may be implemented in any reasonable manner; provided, however, that in the case of a Derivative Work or Collective Work, at a minimum such credit will appear where any other comparable authorship credit appears and in a manner at least as prominent as such other comparable authorship credit.
+
+5. Representations, Warranties and Disclaimer
+
+     a. By offering the Work for public release under this License, Licensor represents and warrants that, to the best of Licensor's knowledge after reasonable inquiry:
+
+          i. Licensor has secured all rights in the Work necessary to grant the license rights hereunder and to permit the lawful exercise of the rights granted hereunder without You having any obligation to pay any royalties, compulsory license fees, residuals or any other payments;
+
+          ii. The Work does not infringe the copyright, trademark, publicity rights, common law rights or any other right of any third party or constitute defamation, invasion of privacy or other tortious injury to any third party.
+
+     b. EXCEPT AS EXPRESSLY STATED IN THIS LICENSE OR OTHERWISE AGREED IN WRITING OR REQUIRED BY APPLICABLE LAW, THE WORK IS LICENSED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES REGARDING THE CONTENTS OR ACCURACY OF THE WORK.
+
+6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, AND EXCEPT FOR DAMAGES ARISING FROM LIABILITY TO A THIRD PARTY RESULTING FROM BREACH OF THE WARRANTIES IN SECTION 5, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+7. Termination
+
+     a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Derivative Works or Collective Works from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License.
+
+     b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above.
+
+8. Miscellaneous
+
+     a. Each time You distribute or publicly digitally perform the Work or a Collective Work, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License.
+
+     b. Each time You distribute or publicly digitally perform a Derivative Work, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License.
+
+     c. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable.
+
+     d. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent.
+
+     e. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. This License may not be modified without the mutual written agreement of the Licensor and You.
+
+Creative Commons is not a party to this License, and makes no warranty whatsoever in connection with the Work. Creative Commons will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, if Creative Commons has expressly identified itself as the Licensor hereunder, it shall have all rights and obligations of Licensor.
+
+Except for the limited purpose of indicating to the public that the Work is licensed under the CCPL, neither party will use the trademark "Creative Commons" or any related trademark or logo of Creative Commons without the prior written consent of Creative Commons. Any permitted use will be in compliance with Creative Commons' then-current trademark usage guidelines, as may be published on its website or otherwise made available upon request from time to time.
+
+Creative Commons may be contacted at http://creativecommons.org/.
diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/LICENSES/CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
diff --git a/MANIFEST.in b/MANIFEST.in
index 57e4e5b..596e269 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,10 @@
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
+
 exclude codecov.yml
 exclude .gitignore
 exclude .gitlab-ci.yml
diff --git a/README.md b/README.md
index e9a05cb..9b1681f 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,9 @@
+<!--
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+-->
+
 ![ACME_logo](https://raw.githubusercontent.com/esi-neuroscience/acme/master/doc/source/_static/acme_logo.png)
 
 # ACME: Asynchronous Computing Made ESI
@@ -6,6 +12,7 @@
 [![pypi](https://badge.fury.io/py/esi-acme.svg)](https://badge.fury.io/py/esi-acme)
 [![license](https://img.shields.io/github/license/esi-neuroscience/acme)](https://github.com/esi-neuroscience/acme/blob/main/LICENSE)
 [![Open in Visual Studio Code](https://img.shields.io/badge/-Open_in_VS_Code-007ACC?logo=visual%20studio%20code&logoColor=ffffff)](https://github.dev/esi-neuroscience/acme)
+[![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/7144/badge)](https://bestpractices.coreinfrastructure.org/projects/7144)
 
 main: [![tests](https://github.com/esi-neuroscience/acme/actions/workflows/tests_workflow.yml/badge.svg?branch=main)](https://github.com/esi-neuroscience/acme/actions/workflows/tests_workflow.yml)
 [![codecov](https://codecov.io/gh/esi-neuroscience/acme/branch/main/graph/badge.svg?token=LCB2RPBQJG)](https://codecov.io/gh/esi-neuroscience/acme)
@@ -67,6 +74,8 @@ with ParallelMap(f, [2, 4, 6, 8], 4) as pmap:
   pmap.compute()
 ```
 
+See also our [Quickstart Guide](https://esi-acme.readthedocs.io/en/latest/quickstart.html).
+
 ### Intermediate Examples
 
 Set number of function calls via `n_inputs`
@@ -84,6 +93,9 @@ with pmap as p:
   p.compute()
 ```
 
+More details in
+[Override Automatic Input Argument Distribution](https://esi-acme.readthedocs.io/en/latest/userguide.html#override-automatic-input-argument-distribution)
+
 ### Advanced Use
 
 Allocate custom `client` object and recycle it for several computations
@@ -116,6 +128,8 @@ with pmap as p:
     p.compute()
 ```
 
+For more information see [Reuse Worker Clients](https://esi-acme.readthedocs.io/en/latest/userguide.html#reuse-worker-clients)
+
 ## Handling results
 
 ### Load results from files
@@ -124,6 +138,9 @@ By default, results are saved to disk in HDF5 format and can be accessed using
 the `results_container` attribute of `ParallelMap`:
 
 ```python
+def f(x, y, z=3):
+  return (x + y) * z
+
 with ParallelMap(f, [2, 4, 6, 8], 4) as pmap:
   filenames = pmap.compute()
 ```
@@ -133,20 +150,56 @@ Example loading code:
 ```python
 import h5py
 import numpy as np
-out = np.zeros((4))
+out = np.zeros((4,))
 
 with h5py.File(pmap.results_container, "r") as h5f:
   for k, key in enumerate(h5f.keys()):
-    out[k] = h5f[key]["result_0"]
+    out[k] = h5f[key]["result_0"][()]
+```
+
+See also [Where Are My Results?](https://esi-acme.readthedocs.io/en/latest/userguide.html#where-are-my-results)
+
+### Collect results in single HDF5 dataset
+
+If possible, results can be slotted into a single HDF5 dataset:
+
+```python
+def f(x, y, z=3):
+  return (x + y) * z
+
+with ParallelMap(f, [2, 4, 6, 8], 4, result_shape=(None,)) as pmap:
+  pmap.compute()
+```
+
+Example loading code:
+
+```python
+import h5py
+
+with h5py.File(pmap.results_container, "r") as h5f:
+  out = h5f["result_0"][()] # returns a NumPy array of shape (4,)
 ```
 
+More examples can be found in
+[Collect Results in Single Dataset](https://esi-acme.readthedocs.io/en/latest/userguide.html#collect-results-in-single-dataset)
+
 ### Collect results in local memory
 
 This is possible but not recommended.
 
 ```python
+def f(x, y, z=3):
+  return (x + y) * z
+
 with ParallelMap(f, [2, 4, 6, 8], 4, write_worker_results=False) as pmap:
-  results = pmap.compute() # returns a list of outputs
+  result = pmap.compute() # returns a 4-element list
+```
+
+Alternatively, create an in-memory NumPy array
+
+```python
+with ParallelMap(f, [2, 4, 6, 8], 4, write_worker_results=False, result_shape=(None,)) as pmap:
+  result = pmap.compute() # returns a NumPy array of shape (4,)
 ```
 
 ## Debugging
@@ -155,11 +208,15 @@ Use the `debug` keyword to perform all function calls in the local thread of
 the active Python interpreter
 
 ```python
+def f(x, y, z=3):
+  return (x + y) * z
+
 with ParallelMap(f, [2, 4, 6, 8], 4, z=None) as pmap:
     results = pmap.compute(debug=True)
 ```
 
 This way tools like `pdb` or ``%debug`` IPython magics can be used.
+More information can be found in the `FAQ`[https://esi-acme.readthedocs.io/en/latest/troubleshooting_faq.html]
 
 ## Documentation and Contact
 
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..9a9ec31
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,8 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+Please use GitHub's
+[private vulnerability reporting tool](https://github.com/esi-neuroscience/acme/security/advisories)
+to tell us about any security issues you found in ACME. We will get back
+to you in less than 14 days.
diff --git a/acme.yml b/acme.yml
index 43a1711..949af78 100644
--- a/acme.yml
+++ b/acme.yml
@@ -1,12 +1,12 @@
-# This file was auto-generated by setup.py on 15/12/2022 at 11:52:07. 
-# Do not edit, all of your changes will be overwritten. 
+# This file was auto-generated by setup.py on 15/12/2022 at 11:52:07.
+# Do not edit, all of your changes will be overwritten.
 channels:
 - defaults
 - conda-forge
 dependencies:
-- click < 8.1
 - colorama
-- dask < 2022.11
+- dask = 2023.3.1
+- dask-jobqueue >= 0.8, < 0.9
 - h5py < 4
 - numpy >= 1.0, < 2.0
 - tqdm >= 4.31
@@ -15,7 +15,9 @@ dependencies:
 - scipy >= 1.5, < 2.0
 - numpydoc
 - pytest-cov
+- sphinx < 6
 - sphinx_bootstrap_theme
+- sphinx-tabs
 - tox
 - pyyaml
 - setuptools
@@ -25,6 +27,5 @@ dependencies:
 - python <3.11,>=3.7
 - pip
 - pip:
-  - dask-jobqueue >= 0.8, < 0.9
   - sphinx_automodapi
 name: acme
diff --git a/acme/__init__.py b/acme/__init__.py
index c66507d..afac333 100644
--- a/acme/__init__.py
+++ b/acme/__init__.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Main package initializer
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import subprocess
@@ -37,12 +41,18 @@
     "`workers_per_job` are DEPRECATED. Please use `n_workers`, `mem_per_worker`, " +\
     "`n_workers_startup` and `processes_per_worker`, respectively."
 
+# Remove dask-jobqueue's FutureWarnings about tmpfile (which we don't use)
+warnings.simplefilter(action='ignore', category=FutureWarning)
+
 # Import local modules
-from . import frontend, backend, shared, dask_helpers
 from .frontend import *
 from .backend import *
 from .shared import *
 from .dask_helpers import *
+from .logger import prepare_log
+
+# Set up module-wide logging
+prepare_log(logname="ACME")
 
 # Override default exception handler (take care of Jupyter's Exception handling)
 from .shared import ctrlc_catcher
diff --git a/acme/backend.py b/acme/backend.py
index 744ecee..7282b07 100644
--- a/acme/backend.py
+++ b/acme/backend.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Computational scaffolding for user-interface
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import time
@@ -30,15 +34,18 @@
 # Local imports
 from . import __path__
 from .dask_helpers import (esi_cluster_setup, local_cluster_setup,
-                           slurm_cluster_setup, cluster_cleanup)
-from .shared import user_yesno, is_esi_node
-from . import shared as acs
+                           slurm_cluster_setup, cluster_cleanup, count_online_workers)
+from .shared import user_yesno, is_esi_node, is_slurm_node
+from .logger import prepare_log
 isSpyModule = False
 if "syncopy" in sys.modules:
     isSpyModule = True
 
 __all__ = ["ACMEdaemon"]
 
+# Fetch logger
+log = logging.getLogger("ACME")
+
 
 # Main manager for parallel execution of user-defined functions
 class ACMEdaemon(object):
@@ -46,10 +53,10 @@ class ACMEdaemon(object):
     # Restrict valid class attributes
     __slots__ = "func", "argv", "kwargv", "n_calls", "n_workers", "acme_func", \
         "task_ids", "out_dir", "collect_results", "results_container", "result_shape", \
-        "result_dtype", "stacking_dim", "client", "stop_client", "has_slurm", "log"
+        "result_dtype", "stacking_dim", "client", "stop_client", "has_slurm"
 
     # Prepend every stdout/stderr message with the name of this class
-    msgName = "<ACMEdaemon>"
+    objName = "<ACMEdaemon>"
 
     # format string for tqdm progress bars
     tqdmFormat = "{desc}: {percentage:3.0f}% |{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]"
@@ -59,11 +66,7 @@ class ACMEdaemon(object):
 
     def __init__(
         self,
-        pmap=None,
-        func=None,
-        argv=None,
-        kwargv=None,
-        n_calls=None,
+        pmap,
         n_workers="auto",
         write_worker_results=True,
         output_dir=None,
@@ -78,34 +81,18 @@ def __init__(
         setup_interactive=True,
         stop_client="auto",
         verbose=None,
-        logfile=None,
-        **kwargs):
+        logfile=None):
         """
         Manager class for performing concurrent user function calls
 
         Parameters
         ----------
-        pmap : :class:~`acme.ParallelMap` context manager or None
-            If `pmap` is not `None`, `:class:~`acme.ACMEDaemon` assumes
-            that that the provided :class:~`acme.ParallelMap` instance has already
+        pmap : :class:~`acme.ParallelMap` context manager
+            By default, `:class:~`acme.ACMEDaemon` assumes  that that
+            the provided :class:~`acme.ParallelMap` instance has already
             been properly set up to process `func` (all input arguments parsed and
             properly formatted). All other input arguments of `:class:~`acme.ACMEDaemon`
             are extracted from the provided :class:~`acme.ParallelMap` instance.
-            If `pmap` is `None`, `:class:~`acme.ACMEDaemon` runs in "stand-alone"
-            mode: all remaining arguments have to be manually supplied (in the
-            correct format)
-        func : callable
-            User-defined function to be executed concurrently. See :class:~`acme.ParallelMap`
-            for details.
-        argv : list of lists
-            Positional arguments of `func`: all elements of have to be list-like
-            with lengths `n_calls` or 1
-        kwargv : list of dicts
-            Keyword arguments of `func`: all values of have to be list-like with
-            lengths `n_calls` or 1
-        n_calls : int
-            Number of concurrent calls of `func` to perform. If `pmap` is not `None`,
-            then ``n_calls = pmap.n_inputs``
         n_workers : int or "auto"
             Number of SLURM workers (=jobs) to spawn. See :class:~`acme.ParallelMap`
             for details.
@@ -149,8 +136,9 @@ def __init__(
             If `None` (default), general run-time information as well as warnings
             and errors are shown. See :class:~`acme.ParallelMap` for details.
         logfile : None or bool or str
-            If `None` (default) or `False`, all run-time information as well as errors and
-            warnings are printed to the command line only. See :class:~`acme.ParallelMap`
+            If `None` (default) or `True`, and `write_worker_results` is
+            `True`, all run-time information as well as errors and
+            warnings are tracked in a log-file. See :class:~`acme.ParallelMap`
             for details.
 
         Returns
@@ -168,37 +156,55 @@ def __init__(
         """
 
         # The only error checking happening in `__init__`
-        if pmap is not None:
-            if pmap.__class__.__name__ != "ParallelMap":
-                msg = "{} `pmap` has to be a `ParallelMap` instance, not {}"
-                raise TypeError(msg.format(self.msgName, str(pmap)))
-
-        # Input pre-processed by a `ParallelMap` object takes precedence over keyword args
-        self.initialize(getattr(pmap, "func", func),
-                        getattr(pmap, "argv", argv),
-                        getattr(pmap, "kwargv", kwargv),
-                        getattr(pmap, "n_inputs", n_calls))
-
-        # If `log` is `None`, `prepare_log` has not been called yet
-        if getattr(pmap, "log", None) is None:
-            self.log = acs.prepare_log(func, caller=self.msgName, logfile=logfile,
-                                       verbose=verbose)
-        else:
-            self.log = pmap.log
+        try:
+            pClassName = pmap.__class__.__name__
+        except:
+            pClassName = "notParallelMap"
+        if pClassName != "ParallelMap":
+            msg = "%s `pmap` has to be a `ParallelMap` instance, not %s"
+            raise TypeError(msg%(self.objName, str(type(pmap))))
+
+        # Allocate first batch of slots
+        self.func = pmap.func
+        self.argv = pmap.argv
+        self.kwargv = pmap.kwargv
+        self.n_calls = pmap.n_inputs
+        self.n_workers = None
+        self.acme_func = None
+        self.out_dir = None
+        self.collect_results = None
+        self.results_container = None
+        self.result_shape = None
+        self.result_dtype = None
+        self.stacking_dim = None
+        self.client = None
+        self.stop_client = None
+
+        # Define list of taskIDs for distribution across workers
+        self.task_ids = list(range(self.n_calls))
+        log.debug("Allocated `taskID` list: %s", str(self.task_ids))
+
+        # Finally, determine if the code is executed on a SLURM-enabled node
+        self.has_slurm = is_slurm_node()
+        log.debug("Set `has_slurm = %s`", str(self.has_slurm))
 
         # Set up output handler
-        self.prepare_output(write_worker_results,
-                            output_dir,
-                            result_shape,
-                            result_dtype,
-                            single_file,
-                            write_pickle)
+        self.pre_process(verbose,
+                         logfile,
+                         write_worker_results,
+                         output_dir,
+                         result_shape,
+                         result_dtype,
+                         single_file,
+                         write_pickle)
 
         # If requested, perform single-worker dry-run (and quit if desired)
         if dryrun:
             goOn = self.perform_dryrun(setup_interactive)
             if not goOn:
+                log.debug("Quitting after dryrun")
                 return
+            log.debug("Continuing after dryrun")
 
         # Either use existing dask client or start a fresh instance
         self.prepare_client(n_workers=n_workers,
@@ -208,81 +214,15 @@ def __init__(
                             setup_interactive=setup_interactive,
                             stop_client=stop_client)
 
-    def initialize(self, func, argv, kwargv, n_calls):
-        """
-        Parse (provided) inputs: make sure positional and keyword args are
-        properly formatted for concurrently calling `func`
-        """
-
-        # Allocate slots
-        self.func = None
-        self.argv = None
-        self.kwargv = None
-        self.n_calls = None
-        self.n_workers = None
-        self.acme_func = None
-        self.task_ids = None
-        self.out_dir = None
-        self.collect_results = None
-        self.results_container = None
-        self.result_shape = None
-        self.result_dtype = None
-        self.stacking_dim = None
-        self.client = None
-        self.stop_client = None
-        self.has_slurm = None
-        self.log = None
-
-        # Ensure `func` is callable
-        if not callable(func):
-            msg = "{} first input has to be a callable function, not {}"
-            raise TypeError(msg.format(self.msgName, str(type(func))))
-
-        # Next, vet `n_calls` which is needed to validate `argv` and `kwargv`
-        try:
-            acs._scalar_parser(n_calls, varname="n_calls", ntype="int_like", lims=[1, np.inf])
-        except Exception as exc:
-            raise exc
-
-        # Ensure all elements of `argv` are list-like with lengths `n_calls` or 1
-        msg = "{} `argv` has to be a list with list-like elements of length 1 or {}"
-        if not isinstance(argv, (list, tuple)):
-            raise TypeError(msg.format(self.msgName, n_calls))
-        try:
-            validArgv = all(len(arg) == n_calls or len(arg) == 1 for arg in argv)
-        except TypeError:
-            raise TypeError(msg.format(self.msgName, n_calls))
-        if not validArgv:
-            raise ValueError(msg.format(self.msgName, n_calls))
-
-        # Ensure all values of `kwargv` are list-like with lengths `n_calls` or 1
-        msg = "{} `kwargv` has to be a dictionary with list-like elements of length {}"
-        try:
-            validKwargv = all(len(value) == n_calls or len(value) == 1 for value in kwargv.values())
-        except TypeError:
-            raise TypeError(msg.format(self.msgName, n_calls))
-        if not validKwargv:
-            raise ValueError(msg.format(self.msgName, n_calls))
-
-        # Basal sanity checks have passed, keep the provided input signature
-        self.func = func
-        self.argv = argv
-        self.kwargv = kwargv
-        self.n_calls = n_calls
-
-        # Define list of taskIDs for distribution across workers
-        self.task_ids = list(range(n_calls))
-
-        # Finally, determine if the code is executed on a SLURM-enabled node
-        self.has_slurm = acs.is_slurm_node()
-
-    def prepare_output(self,
-                       write_worker_results,
-                       output_dir,
-                       result_shape,
-                       result_dtype,
-                       single_file,
-                       write_pickle):
+    def pre_process(self,
+                    verbose,
+                    logfile,
+                    write_worker_results,
+                    output_dir,
+                    result_shape,
+                    result_dtype,
+                    single_file,
+                    write_pickle):
         """
         If `write_*` is `True` set up directories for saving output HDF5 containers
         (or pickle files). Warn if results are to be collected in memory
@@ -291,40 +231,43 @@ def prepare_output(self,
         # Basal sanity check for Boolean flags
         if not isinstance(write_worker_results, bool):
             msg = "%s `write_worker_results` has to be `True` or `False`, not %s"
-            raise TypeError(msg%(self.msgName, str(write_worker_results)))
+            raise TypeError(msg%(self.objName, str(write_worker_results)))
+        log.debug("Found `write_worker_results = %s`", str(write_worker_results))
         if not isinstance(single_file, bool):
             msg = "%s `single_file` has to be `True` or `False`, not %s"
-            raise TypeError(msg%(self.msgName, str(single_file)))
+            raise TypeError(msg%(self.objName, str(single_file)))
+        log.debug("Found `single_file = %s`", str(single_file))
         if not isinstance(write_pickle, bool):
             msg = "%s `write_pickle` has to be `True` or `False`, not %s"
-            raise TypeError(msg%(self.msgName, str(write_pickle)))
+            raise TypeError(msg%(self.objName, str(write_pickle)))
+        log.debug("Found `write_pickle = %s`", str(write_pickle))
 
         # Check compatibility of provided optional args
         if not write_worker_results and write_pickle:
-            self.log.warning("Pickling of results only possible if `write_worker_results` is `True`. ")
+            log.warning("Pickling of results only possible if `write_worker_results` is `True`. ")
         if not write_worker_results and output_dir:
-            self.log.warning("Output directory specification has no effect if `write_worker_results` is `False`.")
+            log.warning("Output directory specification has no effect if `write_worker_results` is `False`.")
         if write_pickle and result_shape:
-            self.log.warning("Pickling of results does not support output array shape specification. ")
+            log.warning("Pickling of results does not support output array shape specification. ")
         if not write_worker_results and single_file:
-            self.log.warning("Generating a single output file only possible if `write_worker_results` is `True`. ")
+            log.warning("Generating a single output file only possible if `write_worker_results` is `True`. ")
         if write_pickle and single_file:
             msg = "%s Pickling of results does not support single output file creation. "
-            raise ValueError(msg%self.msgName)
+            raise ValueError(msg%self.objName)
 
         # Check validity of output shape/dtype specifications
         if result_shape is not None:
             if not isinstance(result_shape, (list, tuple)):
                 msg = "%s `result_shape` has to be either `None` or tuple, not %s"
-                raise TypeError(msg%(self.msgName, str(type(result_shape))))
+                raise TypeError(msg%(self.objName, str(type(result_shape))))
 
             if not isinstance(result_dtype, str):
                 msg = "%s `result_dtype` has to be a string, not %s"
-                raise TypeError(msg%(self.msgName, str(type(result_shape))))
+                raise TypeError(msg%(self.objName, str(type(result_shape))))
 
             if sum(spec is None for spec in result_shape) != 1:
                 msg = "%s `result_shape` must contain exactly one `None` entry"
-                raise ValueError(msg%self.msgName)
+                raise ValueError(msg%self.objName)
 
             rShape = list(result_shape)
             self.stacking_dim = result_shape.index(None)
@@ -334,140 +277,222 @@ def prepare_output(self,
 
             if not all(isinstance(spec, numbers.Number) for spec in rShape):
                 msg = "%s `result_shape` must only contain numerical values"
-                raise ValueError(msg%self.msgName)
+                raise ValueError(msg%self.objName)
             if any(spec < 0 or int(spec) != spec for spec in rShape):
                 msg = "%s `result_shape` must only contain non-negative integers"
-                raise ValueError(msg%self.msgName)
+                raise ValueError(msg%self.objName)
 
             self.result_shape = tuple(rShape)
-            ShapeSource = list(rShape)
-            ShapeSource.pop(self.stacking_dim)
-            ShapeSource = tuple(ShapeSource)
+            msg = "Found `result_shape = %s`. Set stacking dimension to %d"
+            log.debug(msg, str(result_shape), self.stacking_dim)
 
             try:
                 self.result_dtype = np.dtype(result_dtype)
             except Exception as exc:
                 msg = "%s `result_dtype` has to be a valid NumPy datatype specification. "
                 msg += "Original error message below:\n%s"
-                raise TypeError(msg%(self.msgName, str(exc)))
+                raise TypeError(msg%(self.objName, str(exc)))
+            log.debug("Set `result_dtype = %s`", self.result_dtype)
+        else:
+            log.debug("Found `result_shape = %s`", str(result_shape))
+            log.debug("Found `result_dtype = %s`", str(result_dtype))
 
         # If automatic saving of results is requested, make necessary preparations
         if write_worker_results:
-
-            # Check validity of output dir specification
-            if not isinstance(output_dir, (type(None), str)):
-                msg = "%s `output_dir` has to be either `None` or str, not %s"
-                raise TypeError(msg%(self.msgName, str(type(output_dir))))
-
-            # If provided, standardize output dir spec, otherwise use default locations
-            if output_dir is not None:
-                outDir = os.path.abspath(os.path.expanduser(output_dir))
-
-            else:
-                # On the ESI cluster, save results on HPC mount, otherwise use location of `func`
-                if self.has_slurm:
-                    outDir = "/cs/home/{usr:s}/".format(usr=getpass.getuser())
-                else:
-                    outDir = os.path.dirname(os.path.abspath(inspect.getfile(self.func)))
-                outDir = os.path.join(outDir, "ACME_{date:s}")
-                outDir = outDir.format(date=datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
-
-            # Unless specifically denied by the user, each worker stores results
-            # separately with a common container file pointing to the individual
-            # by-worker files residing in a "payload" directory
-            self.out_dir = str(outDir)
-            if not single_file and not write_pickle:
-                payloadName = "{}_payload".format(self.func.__name__)
-                outputDir = os.path.join(self.out_dir, payloadName)
-            else:
-                outputDir = self.out_dir
-            try:
-                os.makedirs(outputDir)
-            except Exception as exc:
-                msg = "{} automatic creation of output folder {} failed. Original error message below:\n{}"
-                raise OSError(msg.format(self.msgName, outputDir, str(exc)))
-
-            # Re-define or allocate key "taskID" to track concurrent processing results
-            self.kwargv["taskID"] = self.task_ids
-            self.collect_results = False
-
-            # Set up correct file-extension for output files; in case of HDF5
-            # containers, prepare "main" file for collecting/symlinking worker results
-            if write_pickle:
-                fExt = "pickle"
-            else:
-                fExt = "h5"
-                self.results_container = os.path.join(self.out_dir, "{}.h5".format(self.func.__name__))
-
-            # By default, `results_container` is a collection of links that point to
-            # worker-generated HDF5 containers; if `single_file` is `True`, then
-            # `results_container` is a "real" container with actual dataset(s)
-            if single_file:
-                self.kwargv["singleFile"] = [True]
-                self.kwargv["outFile"] = [self.results_container]
-
-                # If no output shape provided, prepare groups for storing datasets;
-                # otherwise allocate a single dataset w/specified dimension
-                if result_shape is None:
-                    with h5py.File(self.results_container, "w") as h5f:
-                        for i in self.task_ids:
-                            h5f.create_group("comp_{}".format(i))
-                else:
-                    with h5py.File(self.results_container, "w") as h5f:
-                        h5f.create_dataset("result_0", shape=self.result_shape, dtype=self.result_dtype)
-
-            else:
-                self.kwargv["outFile"] = [os.path.join(outputDir,
-                                                       "{}_{}.{}".format(self.func.__name__,
-                                                                         taskID,
-                                                                         fExt))
-                                                       for taskID in self.task_ids]
-                if not write_pickle:
-
-                    # If no output shape provided, generate links to external datasets;
-                    # otherwise allocate a virtual dataset w/specified dimension
-                    if result_shape is None:
-                        with h5py.File(self.results_container, "w") as h5f:
-                            for i, fname in enumerate(self.kwargv["outFile"]):
-                                relPath = os.path.join(payloadName, os.path.basename(fname))
-                                h5f["comp_{}".format(i)] = h5py.ExternalLink(relPath, "/")
-                    else:
-
-                        # Assemble virtual dataset
-                        layout = h5py.VirtualLayout(shape=self.result_shape, dtype=self.result_dtype)
-                        idx = [slice(None)] * len(self.result_shape)
-                        for i, fname in enumerate(self.kwargv["outFile"]):
-                            idx[self.stacking_dim] = i
-                            relPath = os.path.join(payloadName, os.path.basename(fname))
-                            vsource = h5py.VirtualSource(fname, "result_0", shape=ShapeSource)
-                            layout[tuple(idx)] = vsource
-                        with h5py.File(self.results_container, "w", libver="latest") as h5f:
-                            h5f.create_virtual_dataset("result_0", layout)
-
-            # Include logger name in keywords so that workers can use it
-            self.kwargv["logName"] = [self.log.name]
-
-            # Wrap the user-provided func and distribute it across workers
-            self.kwargv["userFunc"] = [self.func]
-            self.acme_func = self.func_wrapper
-
+            self.setup_output(output_dir, result_shape, single_file, write_pickle)
         else:
 
             # If `taskID` is not an explicit kw-arg of `func` and `func` does not
             # accept "anonymous" `**kwargs`, don't save anything but return stuff
+            log.debug("Automatic output processing disabled.")
             if self.kwargv.get("taskID") is None:
                 if not isSpyModule:
-                    msg = "`write_worker_results` is `False` and `taskID` is not a keyword argument of {}. " +\
+                    msg = "`write_worker_results` is `False` and `taskID` is not a keyword argument of %s. " +\
                         "Results will be collected in memory by caller - this might be slow and can lead " +\
                         "to excessive memory consumption. "
-                    self.log.warning(msg.format(self.func.__name__))
+                    log.warning(msg, self.func.__name__)
                 self.collect_results = True
             else:
                 self.kwargv["taskID"] = self.task_ids
                 self.collect_results = False
+                msg = "Not collecting results in memory, leaving output " +\
+                    "processing to user-provided function"
+                log.debug(msg)
 
             # The "raw" user-provided function is used in the computation
             self.acme_func = self.func
+            log.debug("Not wrapping user-provided function but invoking it directly")
+
+        # Unless specifically disabled by the user, enable progress-tracking
+        # in a log-file if results are auto-generated
+        if logfile is None and write_worker_results is True:
+            logfile = True
+
+        # Either parse provided `logfile` or set up an auto-generated file;
+        # After this test, `logfile` is either a filename or `None`
+        msg = "%s `logfile` has to be `None`, `True`, `False` or a valid file-name, not %s"
+        if logfile is None or isinstance(logfile, bool):
+            if logfile is True:
+                if write_worker_results:
+                    logfile = self.out_dir
+                else:
+                    logfile = os.path.dirname(os.path.abspath(inspect.getfile(self.func)))
+                logfile = os.path.join(logfile, "ACME_{func:s}_{date:s}.log")
+                logfile = logfile.format(func=self.func.__name__,
+                                         date=datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))
+            else:
+                logfile = None
+        elif isinstance(logfile, str):
+            if os.path.isdir(logfile):
+                raise IOError(msg%(self.objName, "a directory"))
+            logfile = os.path.abspath(os.path.expanduser(logfile))
+        else:
+            raise TypeError(msg%(self.objName, str(type(logfile))))
+
+        # If progress tracking in a log-file was requested, set it up now
+        prepare_log(logname="ACME", logfile=logfile, verbose=verbose)
+        log.debug("Set up logfile=%s", str(logfile))
+
+        return
+
+    def setup_output(self,
+                     output_dir,
+                     result_shape,
+                     single_file,
+                     write_pickle):
+        """
+        Local helper for creating output directories and preparing containers
+        """
+
+        # Check validity of output dir specification
+        if not isinstance(output_dir, (type(None), str)):
+            msg = "%s `output_dir` has to be either `None` or str, not %s"
+            raise TypeError(msg%(self.objName, str(type(output_dir))))
+        log.debug("Found `output_dir = %s`", str(output_dir))
+
+        # If provided, standardize output dir spec, otherwise use default locations
+        if output_dir is not None:
+            outDir = os.path.abspath(os.path.expanduser(output_dir))
+
+        else:
+            # On the ESI cluster, save results on HPC mount, otherwise use location of `func`
+            if self.has_slurm:
+                outDir = "/cs/home/{usr:s}/".format(usr=getpass.getuser())
+            else:
+                outDir = os.path.dirname(os.path.abspath(inspect.getfile(self.func)))
+            outDir = os.path.join(outDir, "ACME_{date:s}")
+            outDir = outDir.format(date=datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
+        log.debug("Using output directory %s", outDir)
+
+        # Unless specifically denied by the user, each worker stores results
+        # separately with a common container file pointing to the individual
+        # by-worker files residing in a "payload" directory
+        self.out_dir = str(outDir)
+        if not single_file and not write_pickle:
+            log.debug("Preparing payload directory for HDF5 containers")
+            payloadName = "{}_payload".format(self.func.__name__)
+            outputDir = os.path.join(self.out_dir, payloadName)
+        else:
+            msg = "Either single-file output or pickling was requested. " +\
+                "Not creating payload directory"
+            log.debug(msg)
+            outputDir = self.out_dir
+        try:
+            os.makedirs(outputDir)
+            log.debug("Created %s", outputDir)
+        except Exception as exc:
+            msg = "%s automatic creation of output folder %s failed: %s"
+            raise OSError(msg%(self.objName, outputDir, str(exc)))
+
+        # Re-define or allocate key "taskID" to track concurrent processing results
+        self.kwargv["taskID"] = self.task_ids
+        self.collect_results = False
+
+        # Set up correct file-extension for output files; in case of HDF5
+        # containers, prepare "main" file for collecting/symlinking worker results
+        if write_pickle:
+            fExt = "pickle"
+            log.debug("Pickling was requested")
+        else:
+            fExt = "h5"
+            self.results_container = os.path.join(self.out_dir, "{}.h5".format(self.func.__name__))
+            log.debug("Using HDF5 storage %s", self.results_container)
+
+        # By default, `results_container` is a collection of links that point to
+        # worker-generated HDF5 containers; if `single_file` is `True`, then
+        # `results_container` is a "real" container with actual dataset(s)
+        if single_file:
+            self.kwargv["singleFile"] = [True]
+            self.kwargv["outFile"] = [self.results_container]
+            log.debug("Saving results in single HDF5 container")
+
+            # If no output shape provided, prepare groups for storing datasets;
+            # otherwise allocate a single dataset w/specified dimension
+            if result_shape is None:
+                msg = "Created group comp_%d in single shared results container"
+                with h5py.File(self.results_container, "w") as h5f:
+                    for i in self.task_ids:
+                        h5f.create_group("comp_{}".format(i))
+                        log.debug(msg, i)
+            else:
+                msg = "Created unique dataset 'result_0' with shape %s " +\
+                    "in single shared results container"
+                with h5py.File(self.results_container, "w") as h5f:
+                    h5f.create_dataset("result_0",
+                                       shape=self.result_shape,
+                                       dtype=self.result_dtype)
+                    log.debug(msg, str(self.result_shape))
+
+        else:
+            self.kwargv["outFile"] = [os.path.join(outputDir,
+                                                    "{}_{}.{}".format(self.func.__name__,
+                                                                        taskID,
+                                                                        fExt))
+                                                    for taskID in self.task_ids]
+            if not write_pickle:
+
+                # If no output shape provided, generate links to external datasets;
+                # otherwise allocate a virtual dataset w/specified dimension
+                if result_shape is None:
+
+                    msg = "Created external link comp_%d pointing to " +\
+                        "%s in results container"
+                    with h5py.File(self.results_container, "w") as h5f:
+                        for i, fname in enumerate(self.kwargv["outFile"]):
+                            relPath = os.path.join(payloadName, os.path.basename(fname))
+                            h5f["comp_{}".format(i)] = h5py.ExternalLink(relPath, "/")
+                            log.debug(msg, i, relPath)
+                else:
+
+                    # Assemble virtual dataset
+                    ShapeSource = list(self.result_shape)
+                    ShapeSource.pop(self.stacking_dim)
+                    ShapeSource = tuple(ShapeSource)
+                    layout = h5py.VirtualLayout(shape=self.result_shape, dtype=self.result_dtype)
+                    idx = [slice(None)] * len(self.result_shape)
+                    msg = "Created virtual dataset result_0' with shape " +\
+                        "%s in results container"
+                    for i, fname in enumerate(self.kwargv["outFile"]):
+                        idx[self.stacking_dim] = i
+                        relPath = os.path.join(payloadName, os.path.basename(fname))
+                        vsource = h5py.VirtualSource(fname, "result_0", shape=ShapeSource)
+                        layout[tuple(idx)] = vsource
+                    with h5py.File(self.results_container, "w", libver="latest") as h5f:
+                        h5f.create_virtual_dataset("result_0", layout)
+                        log.debug(msg, self.result_shape)
+
+        # Include logger name in keywords so that workers can use it
+        self.kwargv["logName"] = [log.name]
+
+        # Wrap the user-provided func and distribute it across workers
+        self.kwargv["userFunc"] = [self.func]
+        self.acme_func = self.func_wrapper
+        log.debug("Wrapping user-provided function inside func_wrapper")
+
+        # Finally, attach verbosity flag to enable logging inside wrapper
+        self.kwargv["logLevel"] = [log.level]
+
+        return
 
     def perform_dryrun(self, setup_interactive):
         """
@@ -478,19 +503,24 @@ def perform_dryrun(self, setup_interactive):
         [dryRunIdx], [dryRunArgs], [dryRunKwargs] = self._dryrun_setup(n_runs=1)
 
         # Create log entry
-        msg = "Performing a single dry-run of {fname:s} simulating randomly " +\
-            "picked worker #{wrknum:d} with automatically distributed arguments"
-        self.log.info(msg.format(fname=self.func.__name__, wrknum=dryRunIdx))
+        msg = "Performing a single dry-run of %s simulating randomly " +\
+            "picked worker #%d with automatically distributed arguments"
+        log.info(msg, self.func.__name__, dryRunIdx)
 
         # Use resident memory size (in MB) to estimate job's memory footprint and measure elapsed time
         mem0 = psutil.Process().memory_info().rss / 1024 ** 2
+        log.debug("Initial memory consumption estimate: %3.f MB", mem0)
+        log.debug("Starting dryrun")
         tic = time.perf_counter()
         self.acme_func(*dryRunArgs, **dryRunKwargs)
         toc = time.perf_counter()
+        log.debug("Finished dryrun")
         mem1 = psutil.Process().memory_info().rss / 1024 ** 2
+        log.debug("Memory consumption estimate after dryrun: %3.f MB", mem1)
 
         # Remove any generated output files
         if self.out_dir is not None:
+            log.debug("Removing %s generated during dryrun", self.kwargv["outFile"][dryRunIdx])
             os.unlink(self.kwargv["outFile"][dryRunIdx])
 
         # Compute elapsed time and memory usage
@@ -502,9 +532,9 @@ def perform_dryrun(self, setup_interactive):
         if memUsage > 1000:
             memUsage /= 1024
             memUnit = "GB"
-        msg = "Dry-run completed. Elapsed time is {runtime:f} seconds, " +\
-            "estimated memory consumption was {memused:3.2f} {memunit:s}."
-        self.log.info(msg.format(runtime=elapsedTime, memused=memUsage, memunit=memUnit))
+        msg = "Dry-run completed. Elapsed time is %f seconds, " +\
+            "estimated memory consumption was %3.2f %s."
+        log.info(msg, elapsedTime, memUsage, memUnit)
 
         # If the worker setup is supposed to be interactive, ask for confirmation
         # here as well; if execution is terminated, remove auto-generated output directory
@@ -515,7 +545,6 @@ def perform_dryrun(self, setup_interactive):
                 if self.out_dir is not None:
                     shutil.rmtree(self.out_dir, ignore_errors=True)
                 goOn = False
-
         return goOn
 
     def _dryrun_setup(self, n_runs=None):
@@ -526,6 +555,7 @@ def _dryrun_setup(self, n_runs=None):
         # If not provided, attempt to infer a "sane" default for the number of jobs to pick
         if n_runs is None:
             n_runs = min(self.n_calls, max(5, min(1, int(0.05 * self.n_calls))))
+        log.debug("Picking %d jobs at random", n_runs)
 
         # Randomly pick `n_runs` jobs and extract positional and keyword args
         dryRunIdx = np.random.choice(self.n_calls, size=n_runs, replace=False)
@@ -556,68 +586,85 @@ def prepare_client(
         """
 
         # Modify automatic setting of `stop_client` if requested
-        msg = "{} `stop_client` has to be 'auto' or Boolean, not {}"
+        msg = "%s `stop_client` has to be 'auto' or Boolean, not %s"
         if isinstance(stop_client, str):
             if stop_client != "auto":
-                raise ValueError(msg.format(self.msgName, stop_client))
+                raise ValueError(msg%(self.objName, stop_client))
         elif isinstance(stop_client, bool):
             self.stop_client = stop_client
         else:
-            raise TypeError(msg.format(self.msgName, stop_client))
+            raise TypeError(msg%(self.objName, str(type(stop_client))))
+        log.debug("Using `stop_client = %s`", str(stop_client))
 
         # Check if a dask client is already running
         try:
             self.client = dd.get_client()
+            log.debug("Detected running client %s", str(self.client))
             if stop_client == "auto":
                 self.stop_client = False
-            self.n_workers = len(self.client.cluster.workers)
-            msg = "Attaching to global parallel computing client {}"
-            self.log.info(msg.format(str(self.client)))
+                msg = "%s Changing `stop_client` from `'auto'` to `False` " +\
+                    "to not terminate external client"
+                log.debug(msg)
+            self.n_workers = count_online_workers(self.client.cluster)
+            log.debug("Found %d alive workers in the client", self.n_workers)
+            msg = "Attaching to parallel computing client %s"
+            log.info(msg%(str(self.client)))
             return
         except ValueError:
+            msg = "No running client detected, preparing to start a new one"
+            log.debug(msg)
             if stop_client == "auto":
                 self.stop_client = True
+                msg = "Changing `stop_client` from `'auto'` to `True` " +\
+                    "to clean up client started by `ParallelMap`"
+                log.debug(msg)
 
         # If things are running locally, simply fire up a dask-distributed client,
         # otherwise go through the motions of preparing a full worker cluster
         if not self.has_slurm:
+            log.debug("SLURM not found, Calling `local_cluster_setup`")
             self.client = local_cluster_setup(interactive=False)
 
         else:
 
             # If `partition` is "auto", use `estimate_memuse` to heuristically determine
             # average memory consumption of jobs
+            log.debug("SLURM available parsing settings")
             if not isinstance(partition, str):
-                msg = "{} `partition` has to be 'auto' or a valid SLURM partition name, not {}"
-                raise TypeError(msg.format(self.msgName, str(partition)))
+                msg = "%s `partition` has to be 'auto' or a valid SLURM partition name, not %s"
+                raise TypeError(msg%(self.objName, str(type(partition))))
             if partition == "auto":
                 if is_esi_node():
                     msg = "Automatic SLURM partition selection is experimental"
-                    self.log.warning(msg)
+                    log.warning(msg)
                     mem_per_worker = self.estimate_memuse()
                 else:
                     err = "Automatic SLURM partition selection currently only available " +\
                         "on the ESI HPC cluster. "
-                    self.log.error(err)
+                    log.error(err)
 
             # If `n_workers` is `"auto`, set `n_workers = n_calls` (default)
-            msg = "{} `n_workers` has to be 'auto' or an integer >= 2, not {}"
+            msg = "%s `n_workers` has to be 'auto' or an integer >= 2, not %s"
             if isinstance(n_workers, str):
                 if n_workers != "auto":
-                    raise ValueError(msg.format(self.msgName, n_workers))
+                    raise ValueError(msg%(self.objName, n_workers))
                 n_workers = self.n_calls
+                log.debug("Changing `n_workers` from `'auto'` to %d", n_workers)
+            log.debug("Using provided `n_workers = %d` to start client", n_workers)
 
             # All set, remaining input processing is done by respective `*_cluster_setup` routines
             if is_esi_node():
+                msg = "Running on ESI compute node, Calling `esi_cluster_setup`"
+                log.debug(msg)
                 self.client = esi_cluster_setup(partition=partition, n_workers=n_workers,
                                                 mem_per_worker=mem_per_worker, timeout=setup_timeout,
                                                 interactive=setup_interactive, start_client=True)
 
             # Unknown cluster node, use vanilla config
             else:
-                wrng = "Cluster node {} not recognized. Falling back to vanilla " +\
+                wrng = "Cluster node %s not recognized. Falling back to vanilla " +\
                     "SLURM setup allocating one worker and one core per worker"
-                self.log.warning(wrng.format(socket.getfqdn()))
+                log.warning(wrng%(socket.getfqdn()))
                 processes_per_worker = 1
                 n_cores = 1
                 self.client = slurm_cluster_setup(partition=partition,
@@ -635,17 +682,23 @@ def prepare_client(
 
             # If startup is aborted by user, get outta here
             if self.client is None:
-                msg = "{} Could not start distributed computing client. "
-                raise ConnectionAbortedError(msg.format(self.msgName))
+                msg = "%s Could not start distributed computing client. "
+                raise ConnectionAbortedError(msg%(self.objName))
 
         # Set `n_workers` to no. of active workers in the initialized cluster
         self.n_workers = len(self.client.cluster.workers)
+        log.debug("Setting `n_workers = %d` based on active workers in %s",
+                  self.n_workers, str(self.client))
 
         # If single output file saving was chosen, initialize distributed
         # lock for shared writing to container
         if self.kwargv.get("singleFile") is not None:
+            msg = "Initializing distributed lock for writing to single shared results container"
+            log.debug(msg)
             dd.lock.Lock(name=os.path.basename(self.results_container))
 
+        return
+
     def estimate_memuse(self):
         """
         A brute-force guessing approach to determine memory consumption of provided
@@ -658,6 +711,8 @@ def estimate_memuse(self):
         # Append new dummy keyword to return before any disk-writes happen
         # in case ACME handles results output
         if self.out_dir is not None:
+            msg = "Appending `memEstRun` keyword to func_wrapper to prevent any disk-writes"
+            log.debug(msg)
             for k in range(len(dryRunKwargs)):
                 dryRunKwargs[k]["memEstRun"] = True
 
@@ -667,17 +722,12 @@ def estimate_memuse(self):
         memPerSec = np.zeros((runTime,))
         memPerJob = np.zeros((len(dryRunIdx),))
 
-        # # Check if auto-generated output files have to be removed
-        # rmOutDir = False
-        # if self.out_dir is not None and self.kwargv.get("singleFile") is None:
-        #     rmOutDir = True
-
         # Adequately warn about this heuristic gymnastics...
-        msg = "Estimating memory consumption of {fname:s} by running {numwrks:d} " +\
-            "random workers for max. {rtime:d} seconds..."
-        self.log.info(msg.format(fname=self.func.__name__, numwrks=len(dryRunIdx), rtime=runTime))
-        wmsg = "Launching worker #{wrknum:d}"
+        log.info("Estimating memory footprint of %s", self.func.__name__)
 
+        msg = "Running %d random workers evaluating %s for max. %d seconds"
+        log.debug(msg%(len(dryRunIdx), self.func.__name__, runTime))
+        wmsg = "Launching worker #{wrknum:d}"
         for i, idx in enumerate(dryRunIdx):
 
             # Set up dedicated process to execute user-provided function w/allocated args/kwargs
@@ -703,15 +753,19 @@ def estimate_memuse(self):
 
             # Compute peak memory consumption across `runTime` seconds
             memPerJob[i] = memPerSec.max()
+            log.debug("Peak memory for worker #%d: %3.2f GB", idx, memPerJob[i])
 
         # Compute aggregate average memory consumption across all runs
         memUsage = memPerJob.mean()
 
         # Communicate results
-        msg = "Estimated memory consumption across {numwrks:d} runs is {memuse:3.2f} GB "
-        self.log.info(msg.format(numwrks=len(dryRunIdx), memuse=memUsage))
+        msg = "Estimated memory consumption across %d runs is %3.2f GB "
+        log.info(msg%(len(dryRunIdx), memUsage))
 
-        return "estimate_memuse:" + str(max(1, int(np.ceil(memUsage))))
+        # Return specially formatted string
+        mem_per_worker = "estimate_memuse:" + str(max(1, int(np.ceil(memUsage))))
+        log.debug("Finished memory estimation, returning `mem_per_worker = %s`", mem_per_worker)
+        return mem_per_worker
 
     def compute(self, debug=False):
         """
@@ -724,38 +778,33 @@ def compute(self, debug=False):
 
         # If `prepare_client` has not been called yet, don't attempt to compute anything
         if self.client is None:
+            log.debug("No parallel computing client allocated, exiting")
             return
 
         # Ensure `debug` is a simple Boolean flag
         if not isinstance(debug, bool):
-            msg = "{} `debug` has to be `True` or `False`, not {}"
-            raise TypeError(msg.format(self.msgName, str(debug)))
-
-        # Deduce result output information
-        write_worker_results = self.acme_func == self.func_wrapper
-        single_file = False
-        if write_worker_results:
-            write_pickle = self.results_container is None
-            if not write_pickle and self.kwargv.get("singleFile") is not None:
-                single_file = True
-        else:
-            write_pickle = False
+            msg = "%s `debug` has to be `True` or `False`, not %s"
+            raise TypeError(msg%(self.objName, str(type(debug))))
+        log.debug("Found `debug = %s`", str(debug))
 
         # Check if the underlying parallel computing cluster hosts actually usable workers
-        if len([w["memory_limit"] for w in self.client.cluster.scheduler_info["workers"].values()]) == 0:
-            msg = "{} no active workers found in distributed computing cluster {} " +\
+        if count_online_workers(self.client.cluster) == 0:
+            msg = "%s no active workers found in distributed computing client %s " +\
                 "Consider running \n" +\
                 "\timport dask.distributed as dd; dd.get_client().restart()\n" +\
                 "If this fails to make workers come online, please use\n" +\
                 "\timport acme; acme.cluster_cleanup()\n" +\
                 "to shut down any defunct distributed computing clients"
-            raise RuntimeError(msg.format(self.msgName, self.client))
+            raise RuntimeError(msg%(self.objName, self.client))
+        log.debug("Found %d workers in client %s",
+                  count_online_workers(self.client.cluster), str(self.client))
 
         # Dask does not correctly forward the `sys.path` from the parent process
         # to its workers. Fix this.
         def init_acme(dask_worker, syspath):
             sys.path = list(syspath)
         self.client.register_worker_callbacks(setup=functools.partial(init_acme, syspath=sys.path))
+        log.debug("Registered worker callback to forward `sys.path`")
 
         # Format positional arguments for worker-distribution: broadcast all
         # inputs that are used by all workers and create a list of references
@@ -763,6 +812,7 @@ def init_acme(dask_worker, syspath):
         for ak, arg in enumerate(self.argv):
             if len(arg) == 1:
                 ftArg = self.client.scatter(arg, broadcast=True)
+                log.debug("Broadcasting single-element pos arg %s to client", str(arg))
                 if isinstance(ftArg, collections.abc.Sized):
                     ftArg = ftArg[0]
                 self.argv[ak] = [ftArg] * self.n_calls
@@ -772,6 +822,7 @@ def init_acme(dask_worker, syspath):
             if len(value) == 1:
                 ftVal = self.client.scatter(value, broadcast=True)[0]
                 self.kwargv[name] = [ftVal] * self.n_calls
+                log.debug("Broadcasting single-element kwarg `%s` to client", name)
 
         # Re-format keyword arguments to be usable with single-to-many arg submission.
         # Idea: with `self.n_calls = 3` and ``self.kwargv = {'a': [5, 5, 5], 'b': [6, 6, 6]}``
@@ -787,7 +838,9 @@ def init_acme(dask_worker, syspath):
 
         # In case a debugging run is performed, use the single-threaded scheduler and return
         if debug:
+            log.warning("Running in debug mode")
             with dask.config.set(scheduler='single-threaded'):
+                log.debug("Using single-threaded scheduler to evaluate function")
                 values = self.client.gather([self.client.submit(self.acme_func, *args, **kwargs) \
                     for args, kwargs in zip(zip(*self.argv), kwargList)])
                 return values
@@ -799,12 +852,13 @@ def init_acme(dask_worker, syspath):
         else:
             logFiles = []
             logDir = os.path.dirname(self.client.cluster.dashboard_link) + "/info/main/workers.html"
-        msg = "Preparing {} parallel calls of `{}` using {} workers"
-        self.log.info(msg.format(self.n_calls, self.func.__name__, self.n_workers))
-        msg = "Log information available at {}"
-        self.log.info(msg.format(logDir))
+        msg = "Preparing %d parallel calls of `%s` using %d workers"
+        log.info(msg%(self.n_calls, self.func.__name__, self.n_workers))
+        msg = "Log information available at %s"
+        log.debug(msg%(logDir))
 
         # Submit `self.n_calls` function calls to the cluster
+        log.debug("Submitting %d function calls to client %s", self.n_calls, str(self.client))
         futures = [self.client.submit(self.acme_func, *args, **kwargs) \
             for args, kwargs in zip(zip(*self.argv), kwargList)]
 
@@ -821,6 +875,7 @@ def init_acme(dask_worker, syspath):
 
         # Avoid race condition: give futures time to perform switch from 'pending'
         # to 'finished' so that `finishedTasks` is computed correctly
+        log.debug("Waiting %f seconds for futures", self.sleepTime)
         time.sleep(self.sleepTime)
 
         # If number of 'finished' tasks is less than expected, go into
@@ -834,9 +889,9 @@ def init_acme(dask_worker, syspath):
         if finishedTasks < totalTasks:
             schedulerLog = list(self.client.cluster.get_logs(cluster=False, scheduler=True, workers=False).values())[0]
             erredFutures = [f for f in futures if f.status == "error"]
-            msg = "{} Parallel computation failed: {}/{} tasks failed or stalled.\n"
-            msg = msg.format(self.msgName, totalTasks - finishedTasks, totalTasks)
-            msg += "Concurrent computing scheduler log below: \n\n"
+            msg = "%s Parallel computation failed: %d/%d tasks failed or stalled. "
+            msg = msg%(self.objName, totalTasks - finishedTasks, totalTasks)
+            msg += "Concurrent computing scheduler log info: "
             msg += schedulerLog + "\n"
 
             # If we're working w/`SLURMCluster`, perform the Herculean task of
@@ -858,11 +913,11 @@ def init_acme(dask_worker, syspath):
                         msg += "".join(logDir)
                     msg += "".join(errfile + "\n" for errfile in errFiles)
                 else:
-                    msg += "Please check SLURM logs in {}".format(logDir)
+                    msg += "Please check SLURM logs in %s"%(logDir)
 
             # In case of a `LocalCluster`, syphon worker logs
             else:
-                msg += "\nParallel worker logs below: \n"
+                msg += "Parallel worker log details: \n"
                 workerLogs = self.client.get_worker_logs().values()
                 for wLog in workerLogs:
                     if "Failed" in wLog:
@@ -871,16 +926,43 @@ def init_acme(dask_worker, syspath):
             # Finally, raise an error and get outta here
             raise RuntimeError(msg)
 
+        # Postprocessing of results
+        values = self.post_process(futures)
+
+        # Either return collected by-worker results or the filepaths of results
+        return values
+
+    def post_process(self, futures):
+        """
+        Local helper to post-process results on disk/in-memory
+
+        The return `values` is either
+        `None` : if neither in-memory results collection or auto-writing was requested
+        list of file-names: if `write_worker_results` is `True`
+        list of objects: if in-memory results collection was requested
+        """
+
+        # Deduce result output information
+        write_worker_results = self.acme_func == self.func_wrapper
+        single_file = False
+        if write_worker_results:
+            write_pickle = self.results_container is None
+            if not write_pickle and self.kwargv.get("singleFile") is not None:
+                single_file = True
+        else:
+            write_pickle = False
+        msg = "Inferred that `write_worker_results = %s`, `single_file = %s`, `write_pickle = %s`"
+        log.debug(msg, str(write_worker_results), str(single_file), str(write_pickle))
+
         # If wanted (not recommended) collect computed results in local memory
-        # The return `values` is either
-        # `None` : if neither in-memory results collection or auto-writing was requested
-        # list of file-names: if `write_worker_results` is `True`
-        # list of objects: if in-memory results collection was requested
         if self.collect_results:
             if not isSpyModule:
-                self.log.info("Gathering results in local memory")
+                log.info("Gathering results in local memory")
             collected = self.client.gather(futures)
+            log.debug("Gathered results from client in a %d-element list", len(collected))
             if self.result_shape is not None:
+                log.debug("Returning single NumPy array of shape %s and type %s",
+                          str(self.result_shape), str(self.result_dtype))
                 values = []
                 arrVal = np.empty(shape=self.result_shape, dtype=self.result_dtype)
                 idx = [slice(None)] * len(self.result_shape)
@@ -892,26 +974,35 @@ def init_acme(dask_worker, syspath):
                     for r in res[1:]:
                         values.append(r)
                 values.insert(0, arrVal)
+                # If `values` is a single array, don't wrap it inside a list
+                if len(values) == 1:
+                    values = values[0]
             else:
+                log.debug("Returning a list of values")
                 values = collected
         else:
             values = None
 
         # Prepare final output message
-        finalMsg = "{}Finished parallel computation. "
-        successMsg = "SUCCESS! "
+        successMsg = "SUCCESS!"
 
         # If automatic results writing was requested, perform some housekeeping
         if write_worker_results:
+            finalMsg = "Results have been saved to %s"
             if write_pickle:
+                log.debug("Saved results as pickle files")
                 values = list(self.kwargv["outFile"])
-                finalMsg += "Results have been saved to {}".format(self.out_dir)
+                finalMsg = finalMsg%(self.out_dir)
+                log.debug("Returning a list of file-names")
             else:
                 if single_file:
-                    finalMsg += "Results have been saved to {}".format(self.results_container)
+                    log.debug("Saved results to single shared container")
+                    finalMsg = finalMsg%(self.results_container)
                     if values is None:
                         values = [self.results_container]
+                        log.debug("Returning container name as single-element list")
                 else:
+                    log.debug("Scanning payload directory for emergency pickles")
                     picklesFound = False
                     values = []
                     for fname in self.kwargv["outFile"]:
@@ -921,8 +1012,11 @@ def init_acme(dask_worker, syspath):
                         elif os.path.isfile(pklName):
                             values.append(pklName)
                             picklesFound = True
+                            log.debug("Found emergency pickle %s", pklName)
                         else:
-                            values.append("Missing {}".format(fname.rstrip(".h5")))
+                            missing = fname.rstrip(".h5")
+                            values.append("Missing %s"%(missing))
+                            log.debug("Missing file %s", missing)
                     payloadDir = os.path.dirname(values[0])
 
                     # If pickles are found, remove global `results_container` as it
@@ -933,7 +1027,7 @@ def init_acme(dask_worker, syspath):
                         wrng = "Some compute runs could not be saved as HDF5, " +\
                             "collection container %s has been removed as it would " +\
                                 "comprise invalid file-links"
-                        self.log.warning(wrng, self.results_container)
+                        log.warning(wrng, self.results_container)
                         self.results_container = None
 
                         # Move files out of payload dir and update return `values`
@@ -941,10 +1035,13 @@ def init_acme(dask_worker, syspath):
                         for i, fname in enumerate(values):
                             shutil.move(fname, target)
                             self.kwargv["outFile"][i] = os.path.join(target, os.path.basename(fname))
+                            log.debug("Moved %s to %s", fname, target)
                         values = list(self.kwargv["outFile"])
+                        log.debug("Returning a list of file-names")
                         shutil.rmtree(payloadDir)
+                        log.debug("Deleted payload directory %s", payloadDir)
                         successMsg = ""
-                        finalMsg += "Results have been saved to {}".format(target)
+                        finalMsg = finalMsg%(target)
 
                     # All good, no pickle gymnastics was needed
                     else:
@@ -954,24 +1051,37 @@ def init_acme(dask_worker, syspath):
                         # if `result_shape` is not `None` and data-sets have to
                         # be pre-allocated), create "symlinks" to corresponding
                         # missing returns
+                        log.debug("No emergency pickles found")
                         if self.stacking_dim is not None:
+                            msg = "Check if additional return values " +\
+                                "need to be added to container with pre-allocated dataset"
+                            log.debug(msg)
                             with h5py.File(self.results_container, "r") as h5r:
                                 with h5py.File(values[0], "r") as h5Tmp:
                                     missingReturns = set(h5Tmp.keys()).difference(h5r.keys())
                             if len(missingReturns) > 0:
+                                log.debug("Found return values to be added")
                                 with h5py.File(self.results_container, "a") as h5r:
                                     for retVal in missingReturns:
                                         for i, fname in enumerate(values):
                                             relPath = os.path.join(os.path.basename(payloadDir), os.path.basename(fname))
                                             h5r["comp_{}/{}".format(i, retVal)] = h5py.ExternalLink(relPath, retVal)
+                                            log.debug("Added return value via external link comp_%d/%s", i, retVal)
 
-                        msg = "Results have been saved to {} with links to data payload located in {}"
-                        finalMsg += msg.format(self.results_container, payloadDir)
-
-        # Print final triumphant output message and get out
-        self.log.info(finalMsg.format(successMsg))
-
-        # Either return collected by-worker results or the filepaths of results
+                        finalMsg = finalMsg%(self.results_container)
+                        msg = "Container ready, links to data payload located in %s"
+                        log.debug(msg, payloadDir)
+                        log.debug("Returning a list of file-names")
+        else:
+            finalMsg = "Finished parallel computation"
+
+        # Print final triumphant output message and force-flush all logging handlers
+        if len(successMsg) > 0:
+            log.announce(successMsg)
+        log.info(finalMsg)
+        for h in log.handlers:
+            if hasattr(h, "flush"):
+                h.flush()
         return values
 
     def cleanup(self):
@@ -981,10 +1091,16 @@ def cleanup(self):
 
         # If `prepare_client` has not been launched yet, just get outta here
         if not hasattr(self, "client"):
+            log.debug("Helper `prepare_client` not yet launched, exiting")
             return
         if self.stop_client and self.client is not None:
+            log.debug("Found client %s, calling `cluster_cleanup`", str(self.client))
             cluster_cleanup(self.client)
             self.client = None
+            return
+        log.debug("Either `stop_client = False` or no client found, returning")
+
+        return
 
     @staticmethod
     def func_wrapper(*args, **kwargs):
@@ -1001,10 +1117,16 @@ def func_wrapper(*args, **kwargs):
         taskID = kwargs.pop("taskID")
         fname = kwargs.pop("outFile")
         logName = kwargs.pop("logName")
+        logLevel = kwargs.pop("logLevel")
         singleFile = kwargs.pop("singleFile", False)
         stackingDim = kwargs.pop("stackingDim", None)
         memEstRun = kwargs.pop("memEstRun", False)
+
+        # Set up logger
         log = logging.getLogger(logName)
+        log.setLevel(logLevel)
+        for h in log.handlers:
+            h.setLevel(logLevel)
 
         # Call user-provided function
         result = func(*args, **kwargs)
@@ -1021,12 +1143,6 @@ def func_wrapper(*args, **kwargs):
                 lock = dd.lock.Lock(name=os.path.basename(fname))
                 lock.acquire()
                 grpName = "comp_{}/".format(taskID)
-                errName = pname = fname.rstrip(".h5") + ".failed"
-                err = "Could not write to %s. File potentially corrupted. "
-                if os.path.isfile(errName):
-                    log.error(err, fname)
-                    lock.release()
-                    raise IOError(err%fname)
 
             if not isinstance(result, (list, tuple)):
                 result = [result]
@@ -1038,18 +1154,23 @@ def func_wrapper(*args, **kwargs):
                         if not all(isinstance(value, (numbers.Number, str)) for value in result):
                             for rk, res in enumerate(result):
                                 h5f.create_dataset(grpName + "result_{}".format(rk), data=res)
+                                log.debug("Created new dataset `result_%d` in %s", rk, fname)
                         else:
                             h5f.create_dataset(grpName + "result_0", data=result)
+                            log.debug("Created new dataset `result_0` in %s", fname)
                     else:
                         if singleFile:
                             idx = [slice(None)] * len(h5f["result_0"].shape)
                             idx[stackingDim] = taskID
                             h5f["result_0"][tuple(idx)] = result[0]
+                            log.debug("Wrote to pre-allocated dataset `result_0` in %s", fname)
                             for rk, res in enumerate(result[1:]):
                                 h5f.create_dataset(grpName + "result_{}".format(rk + 1), data=res)
+                                log.debug("Created new dataset `result_%d` in %s", rk+1, fname)
                         else:
                             for rk, res in enumerate(result):
                                 h5f.create_dataset(grpName + "result_{}".format(rk), data=res)
+                                log.debug("Created new dataset `result_%d` in %s", rk, fname)
 
                 if singleFile:
                     lock.release()
@@ -1090,6 +1211,7 @@ def func_wrapper(*args, **kwargs):
             try:
                 with open(os.path.join(fname), "wb") as pkf:
                     pickle.dump(result, pkf)
+                    log.debug("Pickled to %s", fname)
             except pickle.PicklingError as pexc:
                 err = "Could not pickle results to file %s. Original error message: %s"
                 log.error(err, fname, str(pexc))
diff --git a/acme/dask_helpers.py b/acme/dask_helpers.py
index 39c7a53..98b16e6 100644
--- a/acme/dask_helpers.py
+++ b/acme/dask_helpers.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Helper routines for working w/dask
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import os
@@ -19,37 +23,14 @@
     import colorama
     colorama.deinit()
     colorama.init(strip=False)
-
-# Local imports: differentiate b/w being imported as Syncopy sub-package or
-# standalone ACME module: if imported by Syncopy, use some lambda magic to avoid
-# circular imports due to (at import-time) only partially initialized Syncopy
-from acme import __deprecated__, __deprecation_wrng__
-from .shared import user_input, user_yesno
-if "syncopy" in sys.modules:
-    isSpyModule = True
-    import syncopy as spy
-    customIOError = lambda msg : spy.shared.errors.SPYIOError(msg)
-    customValueError = lambda legal=None, varname=None, actual=None : \
-        spy.shared.errors.SPYValueError(legal=legal, varname=varname, actual=actual)
-    customTypeError = lambda val, varname=None, expected=None : \
-        spy.shared.errors.SPYTypeError(val, varname=varname, expected=expected)
-    scalar_parser = lambda var, varname="", ntype=None, lims=None : \
-        spy.shared.parsers.scalar_parser(var, varname=varname, ntype=ntype, lims=lims)
-else:
-    isSpyModule = False
-    from warnings import showwarning
-    import logging
-    from .shared import _scalar_parser as scalar_parser
-    customIOError = IOError
-    customValueError = lambda legal=None, varname=None, actual=None : ValueError(legal)
-    customTypeError = lambda msg, varname=None, expected=None : TypeError(msg)
-
 from dask_jobqueue import SLURMCluster
 from dask.distributed import Client, get_client, LocalCluster
 from datetime import datetime, timedelta
 
-# Be optimistic: prepare success message to be used throughout this module
-_successMsg = "{name:s} Cluster dashboard accessible at {dash:s}"
+# Local imports
+from acme import __deprecated__, __deprecation_wrng__
+from .shared import user_input, user_yesno
+from .spy_interface import scalar_parser, log
 
 __all__ = ["esi_cluster_setup", "local_cluster_setup", "cluster_cleanup", "slurm_cluster_setup"]
 
@@ -148,38 +129,36 @@ def esi_cluster_setup(partition="8GBXS",
     cluster_cleanup : remove dangling parallel processing worker-clusters
     """
 
-    # Re-direct printing/warnings to ACME logger outside of SyNCoPy
-    customPrint, customWarning = _logging_setup()
-
     # For later reference: dynamically fetch name of current function
-    funcName = "{pre:s}<{pkg:s}{name:s}> ".format(pre="Syncopy " if isSpyModule else "",
-                                                 pkg="ACME: " if isSpyModule else "",
-                                                 name=inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
 
     # Backwards compatibility: legacy keywords are converted to new nomenclature
     if any(kw in kwargs for kw in __deprecated__):
-        wrng = "{name:s}{msg:s}"
-        customWarning(wrng.format(name="" if isSpyModule else funcName + " ",
-                                  msg=__deprecation_wrng__),
-                      DeprecationWarning,
-                      __file__,
-                      inspect.currentframe().f_lineno)
+        log.warning(__deprecation_wrng__)
         n_workers = kwargs.pop("n_jobs", n_workers)
         mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)
         n_workers_startup = kwargs.pop("n_jobs_startup", n_workers_startup)
+        log.debug("Set `n_workers = n_jobs`, `mem_per_worker = mem_per_job`\
+                  and `n_workers_startup = n_jobs_startup`")
 
     # Don't start a new cluster on top of an existing one
     try:
         client = get_client()
-        msg = "{}Found existing parallel computing client {}. Not starting new cluster."
-        customPrint(msg.format(funcName, str(client)))
-        if start_client:
-            return client
-        return client.cluster
+        log.debug("Found existing client")
+        if count_online_workers(client.cluster) == 0:
+            log.debug("No active workers detected in %s", str(client))
+            cluster_cleanup(client)
+        else:
+            log.info("Found existing parallel computing client %s. \
+                     Not starting new cluster.", str(client))
+            if start_client:
+                return client
+            return client.cluster
     except ValueError:
-        pass
+        log.debug("No existing clients detected")
 
     # Check if SLURM's `sinfo` can be accessed
+    log.debug("Test if `sinfo` is available")
     proc = subprocess.Popen("sinfo",
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             text=True, shell=True)
@@ -202,14 +181,13 @@ def esi_cluster_setup(partition="8GBXS",
                 return local_cluster_setup(interactive=interactive, start_client=start_client)
 
         # SLURM is installed, but something's wrong
-        msg = "{preamble:s}SLURM queuing system from node {node:s}. " +\
-              "Original error message below:\n{error:s}"
-        raise customIOError(msg.format(preamble=funcName + " Cannot access " if not isSpyModule else "",
-                                       node=socket.gethostname(),
-                                       error=err))
+        msg = "Cannot access SLURM queuing system from node %s: %s "
+        log.error(msg, socket.gethostname(), err)
+        raise IOError("%s %s"%(funcName, msg%(socket.gethostname(), err)))
 
     # Use default by-worker process count or extract it from anonymous keyword args (if provided)
     processes_per_worker = kwargs.pop("processes_per_worker", 1)
+    log.debug("Found `sinfo`, set `processes_per_worker` to %d", processes_per_worker)
 
     # If partition is "auto" use `mem_per_worker` to pick pseudo-optimal partition
     # Note: the `np.where` gymnastic below is necessary since `argmin` refuses
@@ -217,53 +195,59 @@ def esi_cluster_setup(partition="8GBXS",
     # however, 8GB won't fit a 12GB worker, so we have to pick the second match 16GB
     if isinstance(partition, str) and partition == "auto":
         if not isinstance(mem_per_worker, str) or mem_per_worker.find("estimate_memuse:") < 0:
-            msg = "{preamble:s}automatic partition selector without first invoking memory estimation in `ParallelMap`. "
-            raise customIOError(msg.format(preamble=funcName + " Cannot access " if not isSpyModule else ""))
+            msg = "Cannot auto-select partition without first invoking memory estimation in `ParallelMap`. "
+            log.error(msg)
+            raise IOError("%s %s"%(funcName, msg))
         memEstimate = int(mem_per_worker.replace("estimate_memuse:" ,""))
         mem_per_worker = "auto"
-        customPrint("{}Automatically selecting SLURM partition...".format(funcName))
-        availPartitions = _get_slurm_partitions(funcName)
+        log.info("Automatically selecting SLURM partition...")
+        availPartitions = _get_slurm_partitions()
         gbQueues = np.unique([int(queue.split("GB")[0]) for queue in availPartitions if queue[0].isdigit()])
         memDiff = np.abs(gbQueues - memEstimate)
         queueIdx = np.where(memDiff == memDiff.min())[0][-1]
         partition = "{}GBXS".format(gbQueues[queueIdx])
-        msg = "{preamble:s}Picked partition {p:s} based on estimated memory consumption of {m:d} GB"
-        customPrint(msg.format(preamble=funcName, p=partition, m=memEstimate))
+        msg = "Picked partition %s based on estimated memory consumption of %d GB"
+        log.info(msg, partition, memEstimate)
 
     # Extract by-worker CPU core count from anonymous keyword args or...
     if kwargs.get("n_cores") is not None:
         n_cores = kwargs.pop("n_cores")
+        log.debug("Set `n_cores = %d` from kwargs", n_cores)
     else:
         # ...get memory limit (*in MB*) of chosen partition and set core count
         # accordingly (multiple of 8 wrt to GB RAM)
         try:
+            log.debug("Using `scontrol` to get partition info")
             pc = subprocess.run("scontrol -o show partition {}".format(partition),
                                 capture_output=True, check=True, shell=True, text=True)
             defMem = int(pc.stdout.strip().partition("DefMemPerCPU=")[-1].split()[0])
+            log.debug("Found DefMemPerCPU=%d", defMem)
         except Exception as exc:
-            msg = "{preamble:s}available memory per CPU in chosen SLURM partition. " +\
-                "Original error message below:\n{error:s}"
-            raise customIOError(msg.format(preamble=funcName + " Cannot access " if not isSpyModule else "",
-                                           error=str(exc)))
+            msg = "Cannot fetch available memory per CPU in SLURM: %s"
+            log.error(msg, str(exc))
+            raise IOError("%s %s"%(funcName, msg%(str(exc))))
         n_cores = int(defMem / 8000)
+        log.debug("Using `n_cores=%d`", n_cores)
 
     # Determine if `job_extra`` is a list (this is also checked in `slurm_cluster_setup`,
     # but we may need to append to it, so ensure that's possible)
     if not isinstance(job_extra, list):
-        msg = "{} `job_extra` has to be List, not {}"
-        raise customTypeError(job_extra if isSpyModule else msg.format(funcName, str(job_extra)),
-                              varname="job_extra",
-                              expected="list")
+        msg = "`job_extra` has to be a list, not %s"
+        log.error(msg, str(type(job_extra)))
+        raise TypeError("%s %s"%(funcName, msg%(str(type(job_extra)))))
 
     # If '--output' was not provided, append default output folder to `job_extra`
     if not any(option.startswith("--output") or option.startswith("-o") for option in job_extra):
+        log.debug("Auto-populating `--output` setting for sbatch")
         usr = getpass.getuser()
         slurm_wdir = "/cs/slurm/{usr:s}/{usr:s}_{date:s}"
         slurm_wdir = slurm_wdir.format(usr=usr,
                                        date=datetime.now().strftime('%Y%m%d-%H%M%S'))
         os.makedirs(slurm_wdir, exist_ok=True)
+        log.debug("Using %s for slurm logs", slurm_wdir)
         out_files = os.path.join(slurm_wdir, "slurm-%j.out")
         job_extra.append("--output={}".format(out_files))
+        log.debug("Setting `--output=%s`", out_files)
 
     # Let the SLURM-specific setup function do the rest (returns client or cluster)
     return slurm_cluster_setup(partition, n_cores, n_workers, processes_per_worker, mem_per_worker,
@@ -347,79 +331,74 @@ def slurm_cluster_setup(partition="partition_name",
     cluster_cleanup : remove dangling parallel processing worker-clusters
     """
 
-    # Re-direct printing/warnings to ACME logger outside of SyNCoPy
-    customPrint, customWarning = _logging_setup()
-
     # For later reference: dynamically fetch name of current function
-    funcName = "{pre:s}<{pkg:s}{name:s}>".format(pre="Syncopy " if isSpyModule else "",
-                                                 pkg="ACME: " if isSpyModule else "",
-                                                 name=inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
 
     # Backwards compatibility: legacy keywords are converted to new nomenclature
     if any(kw in kwargs for kw in __deprecated__):
-        wrng = "{name:s}{msg:s}"
-        customWarning(wrng.format(name="" if isSpyModule else funcName + " ",
-                                  msg=__deprecation_wrng__),
-                      DeprecationWarning,
-                      __file__,
-                      inspect.currentframe().f_lineno)
+        log.warning(__deprecation_wrng__)
         n_workers = kwargs.pop("n_jobs", n_workers)
         processes_per_worker = kwargs.pop("workers_per_job", processes_per_worker)
         mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)
         n_workers_startup = kwargs.pop("n_jobs_startup", n_workers_startup)
+        log.debug("Set `n_workers = n_jobs`, `processes_per_worker = workers_per_job`, \
+                  `mem_per_worker = mem_per_job` \
+                  and `n_workers_startup = n_jobs_startup`")
 
     # Retrieve all partitions currently available in SLURM
-    availPartitions = _get_slurm_partitions(funcName)
+    availPartitions = _get_slurm_partitions()
 
     # Make sure we're in a valid partition
     if partition not in availPartitions:
         valid = list(set(availPartitions).difference(invalid_partitions))
         lgl = "'" + "or '".join(opt + "' " for opt in valid)
-        msg = "{} Invalid partition selection {}, available SLURM partitions are {}"
-        raise customValueError(legal=lgl if isSpyModule else msg.format(funcName, str(partition), lgl),
-                               varname="partition",
-                               actual=partition)
+        msg = "Invalid partition selection %s, available SLURM partitions are %s"
+        log.error(msg, str(partition), lgl)
+        raise ValueError("%s %s"%(funcName, msg%(str(partition), lgl)))
+    log.debug("Found `partition = %s`", partition)
 
     # Parse worker count
     try:
         scalar_parser(n_workers, varname="n_workers", ntype="int_like", lims=[1, np.inf])
     except Exception as exc:
+        log.error("Error parsing `n_workers`")
         raise exc
+    log.debug("Using `n_workers = %d`", n_workers)
 
     # Get requested memory per worker
     if isinstance(mem_per_worker, str):
         if mem_per_worker == "auto":
             mem_per_worker = None
+            log.debug("Using auto-memory selection")
     if mem_per_worker is not None:
-        msg = "{} `mem_per_worker` has to be a valid memory specifier (e.g., '8GB', '12000MB'), not {}"
-        lgl = "string representation of requested memory (e.g., '8GB', '12000MB')"
+        msg = "`mem_per_worker` has to be a valid memory specifier (e.g., '8GB', '12000MB'), not %s"
         if not isinstance(mem_per_worker, str):
-            raise customTypeError(mem_per_worker if isSpyModule else msg.format(funcName, str(mem_per_worker)),
-                                  varname="mem_per_worker",
-                                  expected="string")
+            log.error(msg, str(type(mem_per_worker)))
+            raise TypeError("%s %s"%(funcName, msg%(str(type(mem_per_worker)))))
         if not any(szstr in mem_per_worker for szstr in ["MB", "GB"]):
-            raise customValueError(legal=lgl if isSpyModule else msg.format(funcName, str(mem_per_worker)),
-                                   varname="mem_per_worker",
-                                   actual=mem_per_worker)
+            log.error(msg, mem_per_worker)
+            raise ValueError("%s %s"%(funcName, msg%(mem_per_worker)))
         memNumeric = mem_per_worker.replace("MB","").replace("GB","")
+        log.debug("Found `mem_per_worker = %s` in input args", mem_per_worker)
         try:
             memVal = float(memNumeric)
         except:
-            raise customValueError(legal=lgl if isSpyModule else msg.format(funcName, str(mem_per_worker)),
-                                   varname="mem_per_worker",
-                                   actual=mem_per_worker)
+            log.error(msg, mem_per_worker)
+            raise ValueError("%s %s"%(funcName, msg%(mem_per_worker)))
         if memVal <= 0:
-            raise customValueError(legal=lgl if isSpyModule else msg.format(funcName, str(mem_per_worker)),
-                                   varname="mem_per_worker",
-                                   actual=mem_per_worker)
+            log.error(msg, mem_per_worker)
+            raise ValueError("%s %s"%(funcName, msg%(mem_per_worker)))
 
     # Parse worker-waiter count
     try:
         scalar_parser(n_workers_startup, varname="n_workers_startup", ntype="int_like", lims=[0, np.inf])
     except Exception as exc:
+        log.error("Error parsing `n_workers_startup`")
         raise exc
+    log.debug("Using `n_workers_startup = %d`", n_workers_startup)
 
     # Get memory limit (*in MB*) of chosen partition (guaranteed to exist, cf. above)
+    log.debug("Use `scontrol` to fetch partition's memory limit")
     pc = subprocess.run("scontrol -o show partition {}".format(partition),
                         capture_output=True, check=True, shell=True, text=True)
     try:
@@ -429,80 +408,89 @@ def slurm_cluster_setup(partition="partition_name",
             mem_lim = int(pc.stdout.strip().partition("DefMemPerCPU=")[-1].split()[0])
         except IndexError:
             mem_lim = np.inf
+    log.debug("Found a limit of  %s MB", str(mem_lim))
 
     # Consolidate requested memory with chosen partition (or assign default memory)
     if mem_per_worker is None:
         mem_per_worker = str(mem_lim) + "MB"
+        log.debug("Using partition limit of %s MB", str(mem_lim))
     else:
         if "MB" in mem_per_worker:
             mem_req = int(mem_per_worker[:mem_per_worker.find("MB")])
         else:
             mem_req = int(round(float(mem_per_worker[:mem_per_worker.find("GB")]) * 1000))
         if mem_req > mem_lim:
-            msg = "{name:s}`mem_per_worker` exceeds limit of {lim:d}MB for partition {par:s}. " +\
+            msg = "`mem_per_worker` exceeds limit of %d MB for partition %s. " +\
                 "Capping memory at partition limit. "
-            customWarning(msg.format(name=funcName + " " if not isSpyModule else "", lim=mem_lim, par=partition),
-                          RuntimeWarning, __file__, inspect.currentframe().f_lineno)
+            log.warning(msg, mem_lim, partition)
             mem_per_worker = str(int(mem_lim)) + "MB"
 
     # Parse requested timeout period
     try:
         scalar_parser(timeout, varname="timeout", ntype="int_like", lims=[1, np.inf])
     except Exception as exc:
+        log.error("Error parsing `timeout`")
         raise exc
+    log.debug("Using `timeout = %d`", timeout)
 
     # Parse requested interactive waiting period
     try:
         scalar_parser(interactive_wait, varname="interactive_wait", ntype="int_like",
                       lims=[0, np.inf])
     except Exception as exc:
+        log.error("Error parsing `interactive_wait`")
         raise exc
+    log.debug("Using `interactive_wait = %d`", interactive_wait)
 
     # Determine if cluster allocation is happening interactively
     if not isinstance(interactive, bool):
-        msg = "{} `interactive` has to be Boolean, not {}"
-        raise customTypeError(interactive if isSpyModule else msg.format(funcName, str(interactive)),
-                              varname="interactive",
-                              expected="bool")
+        msg = "`interactive` has to be Boolean, not %s"
+        log.error(msg, str(type(interactive)))
+        raise TypeError("%s %s"%(funcName, msg%(str(type(interactive)))))
+    log.debug("Using `interactive = %s`", str(interactive))
 
     # Determine if a dask client was requested
     if not isinstance(start_client, bool):
-        msg = "{} `start_client` has to be Boolean, not {}"
-        raise customTypeError(start_client if isSpyModule else msg.format(funcName, str(start_client)),
-                              varname="start_client",
-                              expected="bool")
+        msg = "`start_client` has to be Boolean, not %s"
+        log.error(msg, str(type(start_client)))
+        raise TypeError("%s %s"%(funcName, msg%(str(type(start_client)))))
+    log.debug("Using `start_client = %s`", str(start_client))
 
     # Determine if job_extra is a list
     if not isinstance(job_extra, list):
-        msg = "{} `job_extra` has to be List, not {}"
-        raise customTypeError(job_extra if isSpyModule else msg.format(funcName, str(job_extra)),
-                              varname="job_extra",
-                              expected="list")
+        msg = "`job_extra` has to be List, not %s"
+        log.error(msg, str(type(job_extra)))
+        raise TypeError("%s %s"%(funcName, msg%(str(type(job_extra)))))
 
     # Determine if job_extra options are valid
     for option in job_extra:
-        msg = "{} `job_extra` has to be a valid sbatch option, not {}"
+        msg = "`job_extra` has to be a valid sbatch option, not %s"
         if not isinstance(option, str):
-            raise customTypeError(option if isSpyModule else msg.format(funcName, str(option)),
-                                  varname="option",
-                                  expected="string")
+            log.error(msg, str(type(option)))
+            raise TypeError("%s %s"%(funcName, msg%(str(type(option)))))
         if not option[0] == "-":
-            lgl = "job_extra options should be flagged with - or --"
-            raise customValueError(legal=lgl, varname="option", actual=option)
+            msg = "`job_extra` options must be flagged with - or --"
+            log.error(msg)
+            raise ValueError("%s %s"%(funcName, msg))
+    log.debug("Using `job_extra = %s`", str(job_extra))
 
     # Ensure validity of requested worker processes
     try:
         scalar_parser(processes_per_worker, varname="processes_per_worker",
                       ntype="int_like", lims=[1, 16])
     except Exception as exc:
+        log.error("Error parsing `processes_per_worker`")
         raise exc
+    log.debug("Using `processes_per_worker = %d`", processes_per_worker)
 
     # Check for sanity of requested core count
     try:
         scalar_parser(n_cores, varname="n_cores",
                       ntype="int_like", lims=[1, np.inf])
     except Exception as exc:
+        log.error("Error parsing `n_cores`")
         raise exc
+    log.debug("Using `n_cores = %d`", n_cores)
 
     # Check validity of '--output' option if provided
     userOutSpec = [option.startswith("--output") or option.startswith("-o") for option in job_extra]
@@ -510,25 +498,20 @@ def slurm_cluster_setup(partition="partition_name",
         userOut = job_extra[userOutSpec.index(True)]
         outSpec = userOut.split("=")
         if len(outSpec) != 2:
-            lgl = "the SLURM output directory must be specified using -o/--output=/path/to/file"
-            raise customValueError(legal=lgl if isSpyModule else "{} {}, not {}".format(funcName, lgl, userOut),
-                                   varname="job_extra",
-                                   actual=userOut)
+            msg = "SLURM output directory must be specified using -o/--output=/path/to/file, not %s"
+            log.error(msg, userOut)
+            raise ValueError("%s %s"%(funcName, msg%(userOut)))
         slurm_wdir = os.path.split(outSpec[1])[0]
-        if len(slurm_wdir) > 0:
-            if isSpyModule:
-                try:
-                    spy.shared.parsers.io_parser(slurm_wdir, varname="job_extra", isfile=False)
-                except Exception as exc:
-                    raise exc
-            else:
-                msg = "{} `slurmWorkingDirectory` has to be an existing directory, not {}"
-                if not os.path.isdir(os.path.expanduser(slurm_wdir)):
-                    raise ValueError(msg.format(funcName, str(slurm_wdir)))
+        if len(slurm_wdir) > 0 and not os.path.isdir(os.path.expanduser(slurm_wdir)):
+            msg = "SLURM output location has to be an existing directory, not %s"
+            log.error(msg, slurm_wdir)
+            raise ValueError("%s %s"%(funcName, msg%(slurm_wdir)))
     else:
         slurm_wdir = None
+    log.debug("Using `local_directory = %s`", slurm_wdir)
 
     # Create `SLURMCluster` object using provided parameters
+    log.debug("Instantiating `SLURMCluster` object")
     cluster = SLURMCluster(cores=n_cores,
                            memory=mem_per_worker,
                            processes=processes_per_worker,
@@ -540,30 +523,27 @@ def slurm_cluster_setup(partition="partition_name",
                            # interface="asdf", # interface is set via `psutil.net_if_addrs()`
 
     # Compute total no. of workers and up-scale cluster accordingly
-    total_workers = n_workers * processes_per_worker
-    worker_count = min(total_workers, n_workers_startup)
-    if worker_count < total_workers:
-        # cluster.adapt(minimum=worker_count, maximum=total_workers)
-        cluster.scale(total_workers)
-        msg = "{} Requested worker-count {} exceeds `n_workers_startup`: " +\
-            "waiting for {} workers to come online, then proceed"
-        customPrint(msg.format(funcName, total_workers, n_workers_startup))
-    else:
-        cluster.scale(total_workers)
-
-    # Fire up waiting routine to avoid unfinished cluster setups
-    if _cluster_waiter(cluster, funcName, worker_count, timeout, interactive, interactive_wait):
+    if n_workers_startup < n_workers:
+        msg = "Requested worker-count %d exceeds `n_workers_startup = %d`, " +\
+            "waiting for %d workers to come online"
+        log.debug(msg, n_workers, n_workers_startup, n_workers_startup)
+    cluster.scale(n_workers)
+
+    # Fire up waiting routine to avoid returning an undercooked cluster
+    if _cluster_waiter(cluster, funcName, n_workers, timeout, interactive, interactive_wait):
         return
 
     # Kill a zombie cluster in non-interactive mode
-    if not interactive and _count_running_workers(cluster) == 0:
+    if not interactive and count_online_workers(cluster) == 0:
         cluster.close()
-        err = "SLURM workers could not be started within given time-out " +\
-              "interval of {0:d} seconds"
-        raise TimeoutError(err.format(timeout))
+        msg = "SLURM workers could not be started within given time-out " +\
+              "interval of %d seconds"
+        log.error(msg, timeout)
+        raise TimeoutError("%s %s"%(funcName, msg%(timeout)))
 
     # Highlight how to connect to dask performance monitor
-    customPrint(_successMsg.format(name=funcName, dash=cluster.dashboard_link))
+    msg = "Parallel computing client ready, dashboard accessible at %s"
+    log.info(msg, cluster.dashboard_link)
 
     # If client was requested, return that instead of the created cluster
     if start_client:
@@ -571,12 +551,16 @@ def slurm_cluster_setup(partition="partition_name",
     return cluster
 
 
-def _get_slurm_partitions(funcName):
+def _get_slurm_partitions():
     """
-    Coming soon...
+    Local helper to fetch all partitions defined in SLURM
     """
 
+    # For later reference: dynamically fetch name of current function
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+
     # Retrieve all partitions currently available in SLURM
+    log.debug("Use `sinfo` to fetch available partitions")
     proc = subprocess.Popen("sinfo -h -o %P",
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             text=True, shell=True)
@@ -584,13 +568,12 @@ def _get_slurm_partitions(funcName):
 
     # Any non-zero return-code means SLURM is not ready to use
     if proc.returncode != 0:
-        msg = "{preamble:s}SLURM queuing system from node {node:s}. " +\
-              "Original error message below:\n{error:s}"
-        raise customIOError(msg.format(preamble=funcName + " Cannot access " if not isSpyModule else "",
-                                       node=socket.gethostname(),
-                                       error=err))
+        msg = "Error fetching SLURM partition setup from node %s: %s"
+        log.error(msg, socket.gethostname(), err)
+        raise IOError("%s %s"%(funcName, msg%(socket.gethostname(), err)))
 
     # Return formatted subprocess shell output
+    log.debug("Found partitions: %s", out)
     return out.split()
 
 
@@ -599,29 +582,26 @@ def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, inte
     Local helper that can be called recursively
     """
 
-    # Re-direct printing/warnings to ACME logger outside SyNCoPy
-    customPrint, _ = _logging_setup()
-
     # Wait until all workers have been started successfully or we run out of time
-    wrkrs = _count_running_workers(cluster)
+    wrkrs = count_online_workers(cluster)
     to = str(timedelta(seconds=timeout))[2:]
     fmt = "{desc}: {n}/{total} \t[elapsed time {elapsed} | timeout at " + to + "]"
     ani = tqdm(desc="{} SLURM workers ready".format(funcName), total=total_workers,
                leave=True, bar_format=fmt, initial=wrkrs, position=0)
     counter = 0
-    while _count_running_workers(cluster) < total_workers and counter < timeout:
+    while count_online_workers(cluster) < total_workers and counter < timeout:
         time.sleep(1)
         counter += 1
-        ani.update(max(0, _count_running_workers(cluster) - wrkrs))
-        wrkrs = _count_running_workers(cluster)
+        ani.update(max(0, count_online_workers(cluster) - wrkrs))
+        wrkrs = count_online_workers(cluster)
         ani.refresh()   # force refresh to display elapsed time every second
     ani.close()
 
     # If we ran out of time before all workers could be started, ask what to do
     if counter == timeout and interactive:
-        msg = "{name:s} SLURM workers could not be started within given time-out " +\
-              "interval of {time:d} seconds"
-        customPrint(msg.format(name=funcName, time=timeout))
+        msg = "SLURM workers could not be started within given time-out " +\
+              "interval of %d seconds"
+        log.info(msg, timeout)
         query = "{name:s} Do you want to [k]eep waiting for 60s, [a]bort or " +\
                 "[c]ontinue with {wrk:d} workers?"
         choice = user_input(query.format(name=funcName, wrk=wrkrs),
@@ -630,7 +610,7 @@ def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, inte
         if choice == "k":
             return _cluster_waiter(cluster, funcName, total_workers, 60, True, 60)
         elif choice == "a":
-            customPrint("{} Closing cluster...".format(funcName))
+            log.info("Closing cluster...")
             cluster.close()
             return True
         else:
@@ -642,7 +622,7 @@ def _cluster_waiter(cluster, funcName, total_workers, timeout, interactive, inte
                 if choice == "k":
                     _cluster_waiter(cluster, funcName, total_workers, 60, True, 60)
                 else:
-                    customPrint("{} Closing cluster...".format(funcName))
+                    log.info("Closing cluster...")
                     cluster.close()
                     return True
 
@@ -726,42 +706,40 @@ def local_cluster_setup(n_workers=None,
     cluster_cleanup : remove dangling parallel processing worker-clusters
     """
 
-    # Re-direct printing/warnings to ACME logger outside of SyNCoPy
-    customPrint, _ = _logging_setup()
-
     # For later reference: dynamically fetch name of current function
-    funcName = "{pre:s}<{pkg:s}{name:s}>".format(pre="Syncopy " if isSpyModule else "",
-                                                 pkg="ACME: " if isSpyModule else "",
-                                                 name=inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
 
     # Determine if cluster allocation is happening interactively
     if not isinstance(interactive, bool):
-        msg = "{} `interactive` has to be Boolean, not {}"
-        raise customTypeError(interactive if isSpyModule else msg.format(funcName, str(interactive)),
-                              varname="interactive",
-                              expected="bool")
+        msg = "`interactive` has to be Boolean, not %s"
+        log.error(msg, str(type(interactive)))
+        raise TypeError("%s %s"%(funcName, msg%(str(type(interactive)))))
+    log.debug("Using `interactive = %s`", str(interactive))
 
     # Determine if a dask client was requested
     if not isinstance(start_client, bool):
-        msg = "{} `start_client` has to be Boolean, not {}"
-        raise customTypeError(start_client if isSpyModule else msg.format(funcName, str(start_client)),
-                              varname="start_client",
-                              expected="bool")
+        msg = "`start_client` has to be Boolean, not $s"
+        log.error(msg, str(type(start_client)))
+        raise TypeError("%s %s"%(funcName, msg%(str(type(start_client)))))
+    log.debug("Using `start_client = %s`", str(start_client))
 
     # Check, if we're running inside a Jupyter notebook...
     try:
         ipy = get_ipython()
         if ipy.__class__.__name__ == "ZMQInteractiveShell":
             maybeScript = False # Jupyter Notebook
+            log.debug("Running in a Jupyter Notebook")
         else:
             maybeScript = True  # iPython shell
+            log.debug("Running in an iPython shell")
     except NameError:
         maybeScript = True      # Python shell
+        log.debug("Running in a standard Python shell")
 
     # ...if not, print warning/info message
     if maybeScript:
         msg = """\
-        {name:s}If you use a script to start a local parallel computing client, please ensure
+        If you use a script to start a local parallel computing client, please ensure
         the call to `local_cluster_setup` is wrapped inside a main module block, i.e.,
 
             if __name__ == "__main__":
@@ -773,23 +751,27 @@ def local_cluster_setup(n_workers=None,
         new processes being started before the calling process can finish its bootstrapping
         phase.
         """
-        customPrint(textwrap.dedent(msg.format(name=funcName + " " if not isSpyModule else "")))
+        msg = textwrap.dedent(msg)
+        log.debug(msg)
 
     # Additional safe-guard: if a script is executed, double-check with the user
     # for proper main idiom usage
     if interactive:
-        msg = "{name:s}If launched from a script, did you wrap your code inside a main module block?"
-        if not user_yesno(msg.format(name=funcName + " " if not isSpyModule else ""), default="no"):
+        msg = "{name:s} If launched from a script, did you wrap your code " +\
+            "inside a __main__ module block?"
+        if not user_yesno(msg.format(name=funcName), default="no"):
             return
 
     # Start the actual distributed client
     if n_workers is not None or mem_per_worker is not None:
+        msg = "Starting `LocalCluster` with `n_workers = %s` and `memory_limit = %s`"
+        log.debug(msg, str(n_workers), str(mem_per_worker))
         cluster = LocalCluster(n_workers=n_workers, memory_limit=mem_per_worker)
         client = Client(cluster)
     else:
         client = Client()
-    successMsg = "{name:s} Local parallel computing client ready. \n" + _successMsg
-    customPrint(successMsg.format(name=funcName, dash=client.cluster.dashboard_link))
+    msg = "Local parallel computing client ready, dashboard accessible at %s"
+    log.info(msg, client.cluster.dashboard_link)
     if start_client:
         return client
     return client.cluster
@@ -817,33 +799,25 @@ def cluster_cleanup(client=None):
     local_cluster_setup : start a local Dask multi-processing cluster on the host machine
     """
 
-    # Re-direct printing/warnings to ACME logger outside SyNCoPy
-    customPrint, customWarning = _logging_setup()
-
     # For later reference: dynamically fetch name of current function
-    funcName = "{pre:s}<{pkg:s}{name:s}>".format(pre="Syncopy " if isSpyModule else "",
-                                                 pkg="ACME: " if isSpyModule else "",
-                                                 name=inspect.currentframe().f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
 
     # Attempt to establish connection to dask client
     if client is None:
         try:
             client = get_client()
         except ValueError:
-            msg = "{name:s}No dangling clients or clusters found."
-            customWarning(msg.format(name="" if isSpyModule else funcName + " "),
-                          RuntimeWarning,
-                          __file__,
-                          inspect.currentframe().f_lineno)
+            log.warning("No dangling clients or clusters found.")
             return
         except Exception as exc:
+            log.error("Error looking for dask client")
             raise exc
     else:
         if not isinstance(client, Client):
-            msg = "{} `client` has to be a dask client object, not {}"
-            customTypeError(client if isSpyModule else msg.format(funcName, str(client)),
-                            varname="client",
-                            expected="dask client object")
+            msg = "`client` has to be a dask client object, not %s"
+            log.error(msg, str(type(client)))
+            raise TypeError("%s %s"%(funcName, msg%(str(type(client)))))
+    log.debug("Found client %s", str(client))
 
     # Prepare message for prompt
     if client.cluster.__class__.__name__ == "LocalCluster":
@@ -853,43 +827,21 @@ def cluster_cleanup(client=None):
         outDir = client.cluster.job_header.partition("--output=")[-1]
         jobID = outDir.partition("{}_".format(userName))[-1].split(os.sep)[0]
         userClust = "cluster {0}_{1}".format(userName, jobID)
-    nWorkers = len(client.cluster.workers)
+    nWorkers = count_online_workers(client.cluster)
 
     # If connection was successful, first close the client, then the cluster
     client.close()
     client.cluster.close()
 
     # Communicate what just happened and get outta here
-    msg = "{fname:s} Successfully shut down {cname:s} containing {nj:d} workers"
-    customPrint(msg.format(fname=funcName,
-                     nj=nWorkers,
-                     cname=userClust))
+    msg = "Successfully shut down %s containing %d workers"
+    log.info(msg, userClust, nWorkers)
 
     return
 
 
-def _count_running_workers(cluster):
+def count_online_workers(cluster):
     """
     Local replacement for the late `._count_active_workers` class method
     """
-    return len(cluster.scheduler_info.get('workers'))
-
-
-def _logging_setup():
-    """
-    Local helper to customize warning and print functionality at runtime
-    """
-    if isSpyModule:
-        pFunc = print
-        wFunc = lambda msg, kind, caller, lineno : spy.shared.errors.SPYWarning(msg, caller=caller)
-    else:
-        pFunc = print
-        wFunc = showwarning
-        allLoggers = list(logging.root.manager.loggerDict.keys())
-        idxList = [allLoggers.index(loggerName) for loggerName in allLoggers \
-            for moduleName in ["ACME", "ParallelMap"] if moduleName in loggerName]
-        if len(idxList) > 0:
-            logger = logging.getLogger(allLoggers[idxList[0]])
-            pFunc = logger.info
-            wFunc = lambda msg, kind, caller, lineno: logger.warning(msg)
-    return pFunc, wFunc
+    return len([w["memory_limit"] for w in cluster.scheduler_info["workers"].values()])
diff --git a/acme/frontend.py b/acme/frontend.py
index cc6fcd5..8ee4a63 100644
--- a/acme/frontend.py
+++ b/acme/frontend.py
@@ -1,34 +1,38 @@
-# -*- coding: utf-8 -*-
 #
 # User-exposed interface of acme
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import inspect
-import sys
 import numpy as np
+import logging
 
 # Local imports
-from acme import __deprecated__, __deprecation_wrng__
+from acme import __deprecated__, __deprecation_wrng__, __version__
 from .backend import ACMEdaemon
+from .logger import prepare_log
+from .shared import _scalar_parser, sizeOf
 from . import shared as acs
-isSpyModule = False
-if "syncopy" in sys.modules:
-    isSpyModule = True
 
 __all__ = ["ParallelMap"]
 
+# Fetch logger
+log = logging.getLogger("ACME")
+
 
 # Main context manager for parallel execution of user-defined functions
 class ParallelMap(object):
 
-    msgName = "{pre:s}<{pkg:s}ParallelMap>".format(pre="Syncopy " if isSpyModule else "",
-                                                   pkg="ACME: " if isSpyModule else "")
+    objName = "<ParallelMap>"
     argv = None
     kwargv = None
     func = None
     n_inputs = None
-    log = None
     _maxArgSize = 1024
 
     def __init__(
@@ -170,12 +174,15 @@ def __init__(
             as well as runtime of worker jobs prior to the actual concurrent
             computation. See [1]_ and [2]_ for more information.
         logfile : None or bool or str
-            If `None` (default) or `False`, all run-time information as well as errors and
-            warnings are printed to the command line only. If `True`, an auto-generated
-            log-file is set up that records run-time progress. Alternatively, the
-            name of a custom log-file can be provided (must not exist). The verbosity
-            of recorded information can be controlled via setting `verbose`.
-            See [2]_ for more details.
+            If `None` (default) and ``write_worker_results = True``,
+            a logfile is created alongside the auto-generated on-disk results.
+            If `None` and ``write_worker_results = False``, no logfile is
+            created. To override this mechanism, either explicitly set
+            `logfile` to `True` or `False` to enforce or suppress logfile
+            creation.
+            Alternatively, the name of a custom log-file can be provided.
+            The verbosity of recorded runtime information can be controlled
+            via setting `verbose`. See [2]_ for more details.
 
         Returns
         -------
@@ -230,20 +237,24 @@ def __init__(
         .. [2] https://esi-acme.readthedocs.io/en/latest/advanced_usage.html
         """
 
-        # First and foremost, set up logging system (unless logger is already present)
-        self.log = acs.prepare_log(func, caller=self.msgName, logfile=logfile, verbose=verbose)
+        # First and foremost, set up logging system - logfile is processed later
+        prepare_log(logname="ACME", verbose=verbose)
+        log.announce("This is ACME v. %s", __version__)
 
         # Backwards compatibility: legacy keywords are converted to new nomenclature
         if any(kw in kwargs for kw in __deprecated__):
-            self.log.warning(__deprecation_wrng__)
+            log.warning(__deprecation_wrng__)
             n_workers = kwargs.pop("n_jobs", n_workers)
             mem_per_worker = kwargs.pop("mem_per_job", mem_per_worker)
+            log.debug("Set `n_workers = n_jobs` and \
+                       mem_per_worker = mem_per_job`")
 
         # Either guess `n_inputs` or use provided value to duplicate input args
         # and set class attributes `n_inputs`, `argv` and `kwargv`
         self.prepare_input(func, n_inputs, *args, **kwargs)
 
         # Create an instance of `ACMEdaemon` that does the actual parallel computing work
+        log.debug("Instantiating `ACMEdaemon`")
         self.daemon = ACMEdaemon(self,
                                  n_workers=n_workers,
                                  write_worker_results=write_worker_results,
@@ -257,7 +268,9 @@ def __init__(
                                  mem_per_worker=mem_per_worker,
                                  setup_timeout=setup_timeout,
                                  setup_interactive=setup_interactive,
-                                 stop_client=stop_client)
+                                 stop_client=stop_client,
+                                 verbose=verbose,
+                                 logfile=logfile)
 
     def prepare_input(self, func, n_inputs, *args, **kwargs):
         """
@@ -271,19 +284,22 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
 
         # Ensure `func` really is a function and `n_inputs` makes sense
         if not callable(func):
-            msg = "{} first input has to be a callable function, not {}"
-            raise TypeError(msg.format(self.msgName, str(type(func))))
-        msg = "{} `n_inputs` has to be 'auto' or an integer >= 2, not {}"
+            msg = "%s first input has to be a callable function, not %s"
+            raise TypeError(msg%(self.objName, str(type(func))))
         if isinstance(n_inputs, str):
             if n_inputs != "auto":
-                raise ValueError(msg.format(self.msgName, n_inputs))
+                msg = "%s `n_inputs` has to be 'auto' or an integer >= 2, not %s"
+                raise ValueError(msg%(self.objName, n_inputs))
             guessInputs = True
+            log.debug("Using `n_inputs = 'auto'`")
         else:
             try:
-                acs._scalar_parser(n_inputs, varname="n_inputs", ntype="int_like", lims=[1, np.inf])
+                _scalar_parser(n_inputs, varname="n_inputs", ntype="int_like", lims=[1, np.inf])
             except Exception as exc:
+                log.error("Error parsing `n_inputs`")
                 raise exc
             guessInputs = False
+            log.debug("Using provided `n_inputs = %d`", n_inputs)
 
         # Get `func`'s signature to extract its positional/keyword arguments
         funcSignature = inspect.signature(func)
@@ -301,34 +317,36 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
                 posArgNames.append(name)
         for name in posArgNames:
             kwargs.pop(name)
+            log.debug("Moved %s from `kwargs` to positional args", name)
 
         # If "taskID" is a keyword arg, include/overwrite it in `kwargs` - the rest
         # is done by `ACMEdaemon`
         if "taskID" in funcKwargs:
             kwargs["taskID"] = None
+            log.debug("Found `taskID` in kwargs - overriding it")
 
         # Compare provided `args`/`kwargs` to actually defined quantities in `func`
         if len(args) != len(funcPosArgs):
-            msg = "{} {} expects {} positional arguments ({}), found {}"
+            msg = "%s %s expects %d positional arguments (%s), found %d"
             validArgs = "'" + "'".join(arg + "', " for arg in funcPosArgs)[:-2]
-            raise ValueError(msg.format(self.msgName,
-                                        func.__name__,
-                                        len(funcPosArgs),
-                                        validArgs,
-                                        len(args)))
+            raise ValueError(msg%(self.objName,
+                                  func.__name__,
+                                  len(funcPosArgs),
+                                  validArgs,
+                                  len(args)))
         if len(kwargs) > len(funcKwargs):
-            msg = "{} {} accepts at maximum {} keyword arguments ({}), found {}"
+            msg = "%s %s accepts at maximum %d keyword arguments (%s), found %d"
             validArgs = "'" + "'".join(arg + "', " for arg in funcKwargs)[:-2]
-            raise ValueError(msg.format(self.msgName,
-                                        func.__name__,
-                                        len(funcKwargs),
-                                        validArgs,
-                                        len(kwargs)))
+            raise ValueError(msg%(self.objName,
+                                  func.__name__,
+                                  len(funcKwargs),
+                                  validArgs,
+                                  len(kwargs)))
 
         # Prepare argument parsing: collect the the length of anything 1D-array-like
         # in `argLens` and check the size of all provided positional and keyword args
         argLens = []
-        wrnMsg = "argument size {0:4.2f} MB exceeds recommended limit of {1} MB. " +\
+        wrnMsg = "argument size %4.2f MB exceeds recommended limit of %d MB. " +\
             "Distributing large variables across workers may result in poor performance. "
 
         # Cycle through positional args
@@ -337,9 +355,10 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
                 arg = list(arg)
                 args[k] = arg
             acs.callCount = 0
-            argsize = acs.sizeOf(arg, "positional arguments")
+            argsize = sizeOf(arg, "positional arguments")
+            log.debug("Computed size of pos arg #%d as %4.2f bytes", k, argsize)
             if argsize > self._maxArgSize:
-                self.log.warning(wrnMsg.format(argsize, self._maxArgSize))
+                log.warning(wrnMsg, argsize, self._maxArgSize)
             if isinstance(arg, (list, tuple)):
                 argLens.append(len(arg))
             elif isinstance(arg, np.ndarray):
@@ -354,9 +373,10 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
                 value = list(value)
                 kwargs[name] = value
             acs.callCount = 0
-            valsize = acs.sizeOf(value, "keyword arguments")
+            valsize = sizeOf(value, "keyword arguments")
+            log.debug("Computed size of %s as %4.2f bytes", name, valsize)
             if valsize > self._maxArgSize:
-                self.log.warning(wrnMsg.format(valsize, self._maxArgSize))
+                log.warning(wrnMsg, valsize, self._maxArgSize)
             if isinstance(value, (list, tuple)):
                 if isinstance(defaultValue, (list, tuple)):
                     if len(defaultValue) != len(value):
@@ -374,18 +394,19 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
         # or at at least one input argument actually contains `n_input` elements
         if guessInputs:
             if len(set(argLens)) > 1 or len(argLens) == 0:
-                msg = "{} automatic input distribution failed: found {} objects " +\
-                    "containing {} to {} elements. Please specify `n_inputs` manually. "
-                raise ValueError(msg.format(self.msgName,
-                                            len(argLens),
-                                            min(argLens, default=0),
-                                            max(argLens, default=0)))
+                msg = "%s automatic input distribution failed: found %d objects " +\
+                    "containing %d to %d elements. Please specify `n_inputs` manually. "
+                raise ValueError(msg%(self.objName,
+                                      len(argLens),
+                                      min(argLens, default=0),
+                                      max(argLens, default=0)))
             n_inputs = argLens[0]
         else:
             if n_inputs not in set(argLens) and not all(arglen == 1 for arglen in argLens):
-                msg = "{} No object has required length of {} matching `n_inputs`. "
-                raise ValueError(msg.format(self.msgName, n_inputs))
+                msg = "%s No object has required length of %d matching `n_inputs`. "
+                raise ValueError(msg%(self.objName, n_inputs))
         self.n_inputs = int(n_inputs)
+        log.debug("Inferred `n_inputs = %d`", n_inputs)
 
         # Anything that does not contain `n_input` elements is converted to a one-element list
         wrnMsg = "Found a single callable object in positional arguments. " +\
@@ -399,12 +420,12 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
                 if len(arg.squeeze().shape) == 1 and arg.squeeze().size == self.n_inputs:
                     continue
             elif callable(arg):
-                self.log.warning(wrnMsg)
+                log.warning(wrnMsg)
             self.argv[ak] = [arg]
 
         # Same for keyword arguments with the caveat that default values have to
         # be taken into account (cf. above)
-        wrnMsg = "Found a single callable object in keyword arguments: {}. " +\
+        wrnMsg = "Found a single callable object in keyword arguments: %s. " +\
             "It will be executed just once and shared by all workers"
         self.kwargv = dict(kwargs)
         for name, value in kwargs.items():
@@ -416,16 +437,20 @@ def prepare_input(self, func, n_inputs, *args, **kwargs):
                 if len(value.squeeze().shape) == 1 and value.squeeze().size == self.n_inputs:
                     continue
             elif callable(value):
-                self.log.warning(wrnMsg.format(name))
+                log.warning(wrnMsg, name)
             self.kwargv[name] = [value]
 
         # Finally, attach user-provided function to class instance
         self.func = func
 
+        # Get out
+        return
+
     def compute(self):
         """
         Shortcut to launch parallel computation via `ACMEdaemon`
         """
+        log.debug("Invoking `compute` method")
         if hasattr(self, "daemon"):
             self.daemon.compute()
 
@@ -433,13 +458,15 @@ def cleanup(self):
         """
         Shortcut to corresponding cleanup-routine provided by `ACMEdaemon`
         """
+        log.debug("Invoking `cleanup` method")
         if hasattr(self, "daemon"):
-            self.daemon.cleanup
+            self.daemon.cleanup()
 
     def __enter__(self):
         """
         If `ParallelMap` is used as context manager, launch `ACMEdaemon`
         """
+        log.debug("Entering `ACMEdaemon` context")
         return self.daemon
 
     def __exit__(self, exception_type, exception_value, exception_traceback):
@@ -447,4 +474,5 @@ def __exit__(self, exception_type, exception_value, exception_traceback):
         If `ParallelMap` is used as context manager, close any ad-hoc computing
         clients created by `ACMEdaemon`
         """
+        log.debug("Exiting `ACMEdaemon` context")
         self.daemon.cleanup()
diff --git a/acme/logger.py b/acme/logger.py
new file mode 100644
index 0000000..dbfbde6
--- /dev/null
+++ b/acme/logger.py
@@ -0,0 +1,185 @@
+#
+# ACME's logging facilities
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
+# Builtin/3rd party package imports
+import warnings
+import inspect
+import logging
+import os
+from logging import handlers
+
+__all__ = []
+
+
+def prepare_log(logname="ACME", logfile=None, verbose=None):
+    """
+    Convenience function to set up ACME logger
+
+    Parameters
+    ----------
+    logname : str
+        Name of the logger to set up
+    logfile : None or str
+        If `None`, logging information is streamed to stdout only, otherwise
+        `logfile` is interpreted as path to a file used for logging.
+    verbose : bool or None
+        If `None`, the logging-level only contains messages of `'INFO'` priority and
+        higher (`'WARNING'` and `'ERROR'`). If `verbose` is `True`, logging is
+        performed on ``DEBUG`', `'INFO`', `'WARNING'` and `'ERROR'` levels. If
+        `verbose` is `False` only `'WARNING'` and `'ERROR'` messages are propagated.
+
+    Returns
+    -------
+    log : logger object
+        A Python :class:`logging.Logger` instance
+    """
+
+    # For later reference: dynamically fetch name of current function
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+
+    # Ensure `logname` can be processed
+    if not isinstance(logname, str):
+        msg = "%s `caller` has to be a string, not %s"
+        raise TypeError(msg%(funcName, str(type(logname))))
+
+    # Basal sanity check for Boolean flag
+    if verbose is not None and not isinstance(verbose, bool):
+        msg = "%s `verbose` has to be `True`, `False` or `None`, not %s"
+        raise TypeError(msg%(funcName, str(type(verbose))))
+
+    # Either parse provided `logfile` or set up an auto-generated file
+    if logfile is not None and os.path.isfile(logfile):
+        msg = "%s log-file %s already exists, appending to it"
+        warnings.showwarning(msg%(logname, logfile), RuntimeWarning,
+                             __file__, inspect.currentframe().f_lineno)
+
+    # Add custom "announce" level to logger
+    announceLvl = logging.INFO + 5
+    logging.addLevelName(announceLvl, "ANNOUNCE")
+    logging.ANNOUNCE = announceLvl
+
+    def announce(self, msg, *args, **kwargs):
+        """
+        Log 'msg % args' with severity 'ANNOUNCE'.
+
+        To pass exception information, use the keyword argument exc_info with
+        a true value, e.g.
+
+        logger.announce("Houston, we have a %s", "thorny problem", exc_info=1)
+        """
+        if self.isEnabledFor(announceLvl):
+            self._log(announceLvl, msg, args, **kwargs)
+
+    logging.getLoggerClass().announce = announce
+    logging.announce = announce
+
+    # Fetch/set up ACME logger
+    log = logging.getLogger(logname)
+
+    # Set logging verbosity based on `verbose` flag
+    if verbose is None:
+        loglevel = logging.INFO
+    elif verbose is True:
+        loglevel = logging.DEBUG
+    else:
+        loglevel = logging.WARNING
+    log.setLevel(loglevel)
+
+    # Create logging formatters
+    acmeFmt = "%(name)s %(levelname)s <%(funcName)s> %(message)s"
+    streamFrmt = AcmeFormatter(acmeFmt, color=True)
+    fileFrmt = AcmeFormatter(acmeFmt, color=False)
+
+    # Upon package import, create stdout handler + memory handler for
+    # temporary buffering of all log messages
+    if len(log.handlers) == 0:
+        stdoutHandler = logging.StreamHandler()
+        log.addHandler(stdoutHandler)
+        memHandler = handlers.MemoryHandler(1000,
+                                            flushLevel=logging.ERROR,
+                                            target=None,
+                                            flushOnClose=True)
+        log.addHandler(memHandler)
+
+    # If log-file creation was requested, add a target to the initially
+    # created `MemoryHandler` (initiated by `ACMEdaemon`)
+    if logfile is not None:
+        memHandler = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)][0]
+        fileHandler = logging.FileHandler(logfile)
+        fileHandler.setLevel(loglevel)
+        fileHandler.setFormatter(fileFrmt)
+        memHandler.setTarget(fileHandler)
+
+    # No matter if called for the first or n-th time, (re)set log-level
+    # and formatter
+    for h in log.handlers:
+        h.setLevel(loglevel)
+        h.setFormatter(streamFrmt)
+
+    return
+
+
+class AcmeFormatter(logging.Formatter):
+    """
+    Adapted from https://alexandra-zaharia.github.io/posts/make-your-own-custom-color-formatter-with-python-logging/
+    """
+
+    def __init__(self, fmt, color=True):
+
+        super().__init__()
+
+        if color:
+            green = "\x1b[92m"
+            gray = "\x1b[90m"
+            blue = "\x1b[38;5;39m"
+            magenta = "\x1b[35m"
+            red = "\x1b[38;5;196m"
+            bold = "\x1b[1m"
+            reset = "\x1b[0m"
+        else:
+            green = ""
+            gray = ""
+            blue = ""
+            magenta = ""
+            red = ""
+            bold = ""
+            reset = ""
+
+        fmtName = fmt.partition("%(name)s")
+        fmtName = fmtName[0] + bold + fmtName[1] + reset + fmtName[2]
+        fmt = "".join(fmtName)
+
+        fmtLvl = fmt.partition("%(levelname)s")
+        fmtDebug = fmtLvl[0] + bold + green + \
+            "# " + fmtLvl[1] + " #" + reset + gray + fmtLvl[2] + reset
+        fmtInfo = fmtLvl[0] + bold + blue + \
+            "- " + fmtLvl[1] + " -" + reset + fmtLvl[2]
+        fmtAnnounce = fmtLvl[0] + bold + blue + \
+            "> " + fmtLvl[1] + " <" + reset + bold + fmtLvl[2] + reset
+        fmtWarn = fmtLvl[0] + bold + magenta + \
+            "! " + fmtLvl[1] + " !" + reset + fmtLvl[2]
+        fmtError = fmtLvl[0] + bold + red + \
+            "| " + fmtLvl[1] + " |" + reset + red + fmtLvl[2] + reset
+
+        fmtAnnounce = "".join(fmtAnnounce).replace("<%(funcName)s>", "")
+        fmtInfo = "".join(fmtInfo).replace("<%(funcName)s>", "")
+
+        self.FORMATS = {
+            logging.DEBUG: "".join(fmtDebug),
+            logging.INFO: "".join(fmtInfo),
+            logging.ANNOUNCE: "".join(fmtAnnounce),
+            logging.WARNING: "".join(fmtWarn),
+            logging.ERROR: "".join(fmtError),
+            logging.CRITICAL: "".join(fmtError),
+        }
+
+    def format(self, record):
+        logFmt = self.FORMATS.get(record.levelno)
+        formatter = logging.Formatter(logFmt)
+        return formatter.format(record)
diff --git a/acme/shared.py b/acme/shared.py
index c7fdd22..21e2ccb 100644
--- a/acme/shared.py
+++ b/acme/shared.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Auxiliaries needed across the entire package
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import os
@@ -10,15 +14,15 @@
 import subprocess
 import inspect
 import logging
-import warnings
-import datetime
+import traceback
 import multiprocessing
 import time
 import numpy as np
 import dask.distributed as dd
 from tqdm import tqdm
+from logging import handlers
 
-# from .dask_helpers import cluster_cleanup
+# Local imports
 from acme import __version__
 from . import dask_helpers as dh
 
@@ -53,12 +57,14 @@ def sizeOf(obj, varname):
     # Keep track of the no. of recursive calls
     global callCount
 
+    # For later reference: dynamically fetch name of current function
+    funcName = "<{}>".format(inspect.currentframe().f_code.co_name)
+
     # Protect against circular object references
     callCount += 1
     if callCount >= callMax:
-        msgName = sys._getframe().f_back.f_code.co_name
-        msg = "{} maximum recursion depth {} exceeded when processing {}"
-        raise RecursionError(msg.format(msgName, callMax, varname))
+        msg = "%s maximum recursion depth %s exceeded while processing %s"
+        raise RecursionError(msg%(funcName, callMax, varname))
 
     # Use `sys.getsizeof` to estimate memory consumption of primitive objects
     objsize = sys.getsizeof(obj) / 1024**2
@@ -75,7 +81,11 @@ def is_slurm_node():
     otherwise
     """
 
+    # Fetch ACME logger
+    log = logging.getLogger("ACME")
+
     # Simply test if the srun command is available
+    log.debug("Test if `sinfo` is available")
     out, _ = subprocess.Popen("sinfo --version",
                               stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                               text=True, shell=True).communicate()
@@ -86,6 +96,10 @@ def is_esi_node():
     """
     Returns `True` if code is running on an ESI cluster node, `False` otherwise
     """
+
+    # Fetch ACME logger and write debug message
+    log = logging.getLogger("ACME")
+    log.debug("Test if hostname matches the pattern 'esi-sv*'")
     return socket.gethostname().startswith("esi-sv") and os.path.isdir("/cs")
 
 
@@ -95,10 +109,10 @@ def _scalar_parser(var, varname="varname", ntype="int_like", lims=[-np.inf, np.i
     """
 
     # Get name of calling method/function
-    caller = "<{}>".format(inspect.currentframe().f_back.f_code.co_name)
+    funcName = "<{}>".format(inspect.currentframe().f_back.f_code.co_name)
 
     # Make sure `var` is a scalar-like number
-    msg = "{caller:s} `{varname:s}` has to be {scalartype:s} between {lower:s} and {upper:s}, not {var:s}"
+    msg = "%s `%s` has to be %s between %s and %s, not %s"
     if np.issubdtype(type(var), np.number):
         error = False
         if ntype == "int_like":
@@ -110,15 +124,15 @@ def _scalar_parser(var, varname="varname", ntype="int_like", lims=[-np.inf, np.i
         if var < lims[0] or var > lims[1]:
             error = True
         if error:
-            raise ValueError(msg.format(caller=caller,
-                                        varname=varname,
-                                        scalartype=scalartype,
-                                        lower=str(lims[0]),
-                                        upper=str(lims[1]),
-                                        var=str(var)))
+            raise ValueError(msg%(funcName,
+                                  varname,
+                                  scalartype,
+                                  str(lims[0]),
+                                  str(lims[1]),
+                                  str(var)))
     else:
-        msg = "{caller:s} `{varname:s}` has to be a scalar, not {var:s}"
-        raise TypeError(msg.format(caller=caller, varname=varname, var=str(var)))
+        msg = "%s `%s` has to be a scalar, not %s"
+        raise TypeError(msg%(funcName, varname, str(type(var))))
 
     return
 
@@ -217,106 +231,6 @@ def _queuing_input(procQueue, stdin_fd, query, valid, default):
     procQueue.put(_get_user_input(query, valid, default))
 
 
-def prepare_log(func, caller=None, logfile=False, verbose=None):
-    """
-    Convenience function to set up ACME logger
-
-    Parameters
-    ----------
-    func : callable
-        User-provided function to be called concurrently by ACME
-    caller : None or str
-        Routine/class that initiated logging (presumable :class:~`acme.ParallelMap`
-        or :class:~`acme.ACMEDaemon`)
-    logfile : None or bool or str
-        If `True` an auto-generated log-file is set up. If `logfile` is a string
-        it is interpreted as file-name for a new log-file (must not exist). If
-        `False` or `None` logging information is streamed to stdout only.
-    verbose : bool or None
-        If `None`, the logging-level only contains messages of `'INFO'` priority and
-        higher (`'WARNING'` and `'ERROR'`). If `verbose` is `True`, logging is
-        performed on ``DEBUG`', `'INFO`', `'WARNING'` and `'ERROR'` levels. If
-        `verbose` is `False` only `'WARNING'` and `'ERROR'` messages are propagated.
-
-    Returns
-    -------
-    log : logger object
-        A Python :class:`logging.Logger` instance
-    """
-
-    # If not provided, get name of calling method/function
-    if caller is None:
-        caller = "<{}>".format(inspect.currentframe().f_back.f_code.co_name)
-    elif not isinstance(caller, str):
-        msg = "{} `caller` has to be a string, not {}"
-        raise TypeError(msg.format(inspect.currentframe().f_back.f_code.co_name),
-                        str(caller))
-
-    # Basal sanity check for Boolean flag
-    if verbose is not None and not isinstance(verbose, bool):
-        msg = "{} `verbose` has to be `True`, `False` or `None`, not {}"
-        raise TypeError(msg.format(caller, str(verbose)))
-
-    # Either parse provided `logfile` or set up an auto-generated file
-    msg = "{} `logfile` has to be `None`, `True`, `False` or a valid file-name, not {}"
-    if logfile is None or isinstance(logfile, bool):
-        if logfile is True:
-            logfile = os.path.dirname(os.path.abspath(inspect.getfile(func)))
-            logfile = os.path.join(logfile, "ACME_{func:s}_{date:s}.log")
-            logfile = logfile.format(func=func.__name__,
-                                     date=datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))
-        else:
-            logfile = None
-    elif isinstance(logfile, str):
-        if os.path.isdir(logfile):
-            raise IOError(msg.format(caller, "a directory"))
-        logfile = os.path.abspath(os.path.expanduser(logfile))
-    else:
-        raise TypeError(msg.format(caller, str(logfile)))
-    if logfile is not None and os.path.isfile(logfile):
-        msg = "{} log-file {} already exists, appending to it"
-        warnings.showwarning(msg.format(caller, logfile), RuntimeWarning,
-                             __file__, inspect.currentframe().f_lineno)
-
-    # Set logging verbosity based on `verbose` flag
-    if verbose is None:
-        loglevel = logging.INFO
-    elif verbose is True:
-        loglevel = logging.DEBUG
-    else:
-        loglevel = logging.WARNING
-    log = logging.getLogger(caller)
-    log.setLevel(loglevel)
-
-    # Create logging formatter
-    formatter = logging.Formatter("%(name)s %(levelname)s: %(message)s")
-
-    # Output handlers: print log messages to `stderr` via `StreamHandler` as well
-    # as to a provided text file `logfile using a `FileHandler`.
-    # Note: avoid adding the same log-file location as distinct handlers to the logger
-    # in case `ParallelMap` is executed repeatedly; also remove existing non-default
-    # logfile handlers to avoid generating multiple logs (and accidental writes to existing logs)
-    if len(log.handlers) == 0:
-        stdoutHandler = logging.StreamHandler()
-        stdoutHandler.setLevel(loglevel)
-        stdoutHandler.setFormatter(formatter)
-        log.addHandler(stdoutHandler)
-    if logfile is not None:
-        fHandlers = [h for h in log.handlers if isinstance(h, logging.FileHandler)]
-        for handler in fHandlers:
-            if handler.baseFilename == logfile:
-                break
-            else:
-                log.handlers.remove(handler)
-        fileHandler = logging.FileHandler(logfile)
-        fileHandler.setLevel(loglevel)
-        fileHandler.setFormatter(formatter)
-        log.addHandler(fileHandler)
-
-    # Start log w/version info
-    log.info("This is ACME v. %s", __version__)
-
-    return log
 
 
 def ctrlc_catcher(*excargs, **exckwargs):
@@ -334,14 +248,14 @@ def ctrlc_catcher(*excargs, **exckwargs):
         shell, = excargs
         etype, evalue, etb = sys.exc_info()
         try:                            # careful: if iPython is used to launch a script, ``get_ipython`` is not defined
-            ipy = get_ipython()
+            get_ipython()
             isipy = True
             sys.last_traceback = etb    # smartify ``sys``
         except NameError:
             isipy = False
 
     # Prepare to log any uncaught exceptions
-    print, _ = dh._logging_setup()
+    log = logging.getLogger("ACME")
 
     # The only exception we really care about is a `KeyboardInterrupt`: if CTRL + C
     # is pressed, ensure graceful shutdown of any parallel processing clients
@@ -355,14 +269,23 @@ def ctrlc_catcher(*excargs, **exckwargs):
                 st.cancel()
             client.futures.clear()
             dh.cluster_cleanup(client)
-            print("<ACME> CTRL + C acknowledged, client and workers successfully killed")
-
-    # Log/print exception
-    print("<ACME> Exception received: {}: {}".format(str(etype), str(evalue)))
+            log.debug("CTRL + C acknowledged, client and workers successfully killed")
 
     # Relay exception handling back to appropriate system tools
     if isipy:
         shell.ipyTBshower(shell, exc_tuple=(etype, evalue, etb), **exckwargs)
     else:
         sys.__excepthook__(etype, evalue, etb)
+
+    # Write to all logging locations, manually print traceback to file (stdout
+    # printing was handled above)
+    log.error("Exception received.")
+    memHandler = [h for h in log.handlers if isinstance(h, handlers.MemoryHandler)][0]
+    if memHandler.target is not None:
+        memHandler.acquire()
+        with open(memHandler.target.baseFilename, "a", encoding="utf-8") as logfile:
+            logfile.write("".join(traceback.format_exception_only(etype, evalue)))
+            logfile.write("".join(traceback.format_tb(etb)))
+        memHandler.release()
+
     return
diff --git a/acme/spy_interface.py b/acme/spy_interface.py
new file mode 100644
index 0000000..29d5df0
--- /dev/null
+++ b/acme/spy_interface.py
@@ -0,0 +1,24 @@
+#
+# Interface for integrating with Syncopy
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
+# Builtin/3rd party package imports
+import sys
+import logging
+
+# Differentiate b/w being imported as Syncopy sub-package or standalone
+# ACME module: do not attempt to import syncopy but instead see if it has
+# already been imported; then ACME should use Syncopy's built-in logging
+if "syncopy" in sys.modules:
+    import syncopy as spy
+    log = logging.getLogger("syncopy")
+    scalar_parser = lambda var, varname="", ntype=None, lims=None : \
+        spy.shared.parsers.scalar_parser(var, varname=varname, ntype=ntype, lims=lims)
+else:
+    from .shared import _scalar_parser as scalar_parser
+    log = logging.getLogger("ACME")
diff --git a/codecov.yml b/codecov.yml
index 39c1ed0..f9d3910 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,3 +1,10 @@
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: CC0-1.0
+#
+
 coverage:
   status:
     project:
diff --git a/doc/Makefile b/doc/Makefile
index 014db20..62c76c6 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -1,5 +1,11 @@
+#
 # Minimal makefile for Sphinx documentation
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # You can set these variables from the command line, and also
 # from the environment for the first two.
diff --git a/doc/make.bat b/doc/make.bat
index 1a1774a..85e8f05 100644
--- a/doc/make.bat
+++ b/doc/make.bat
@@ -1,3 +1,10 @@
+REM
+REM Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+REM in Cooperation with Max Planck Society
+REM
+REM SPDX-License-Identifier: BSD-3-Clause
+REM
+
 @ECHO OFF
 
 pushd %~dp0
diff --git a/doc/source/_static/acme_demo.gif.license b/doc/source/_static/acme_demo.gif.license
new file mode 100644
index 0000000..af5efa4
--- /dev/null
+++ b/doc/source/_static/acme_demo.gif.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
diff --git a/doc/source/_static/acme_icon.ico b/doc/source/_static/acme_icon.ico
new file mode 100644
index 0000000..0ac7655
Binary files /dev/null and b/doc/source/_static/acme_icon.ico differ
diff --git a/doc/source/_static/acme_icon.ico.license b/doc/source/_static/acme_icon.ico.license
new file mode 100644
index 0000000..b9f0766
--- /dev/null
+++ b/doc/source/_static/acme_icon.ico.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-ND-1.0
diff --git a/doc/source/_static/acme_icon.png.license b/doc/source/_static/acme_icon.png.license
new file mode 100644
index 0000000..b9f0766
--- /dev/null
+++ b/doc/source/_static/acme_icon.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-ND-1.0
diff --git a/doc/source/_static/acme_icon.svg.license b/doc/source/_static/acme_icon.svg.license
new file mode 100644
index 0000000..b9f0766
--- /dev/null
+++ b/doc/source/_static/acme_icon.svg.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-ND-1.0
diff --git a/doc/source/_static/acme_logo.png.license b/doc/source/_static/acme_logo.png.license
new file mode 100644
index 0000000..b9f0766
--- /dev/null
+++ b/doc/source/_static/acme_logo.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-ND-1.0
diff --git a/doc/source/_static/acme_logo.svg.license b/doc/source/_static/acme_logo.svg.license
new file mode 100644
index 0000000..b9f0766
--- /dev/null
+++ b/doc/source/_static/acme_logo.svg.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-ND-1.0
diff --git a/doc/source/_static/dashboard.gif.license b/doc/source/_static/dashboard.gif.license
new file mode 100644
index 0000000..af5efa4
--- /dev/null
+++ b/doc/source/_static/dashboard.gif.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
+
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
diff --git a/doc/source/_static/esi-style.css b/doc/source/_static/esi-style.css
index 03254e0..1d65516 100644
--- a/doc/source/_static/esi-style.css
+++ b/doc/source/_static/esi-style.css
@@ -1,11 +1,77 @@
+/*
+Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+in Cooperation with Max Planck Society
+SPDX-License-Identifier: CC-BY-NC-SA-1.0
+*/
 
-a.reference.internal {
-    color: #25565E !important;
+/* Set general link colors ("Back To Top" etc.) */
+a {
+	color: #0c7869;
 }
-a.reference.external{
-    color:#3572b0;
+
+/* Styling of hyperlinks */
+a.reference {
+    border-bottom: 1px dotted #0c7869;
+    color: #0c7869;
+    /* font-size: 1.1em; */
+}
+
+/* Change color of links on hover */
+a:hover {
+    color: #084d43;
+    text-decoration: none;
+}
+
+/* Use same color for toc-links as for hyperlinks */
+a.toc-backref {
+    color: #0c7869;
+}
+
+/* Override font-size in dropdown menus */
+.dropdown-menu li a {
+    font-size: 1.3em !important;
+    border-bottom: none;
+}
+
+/* Font-size in code blocks */
+pre {
+	font-size: 1em;
+}
+
+/* Background colors in function headers of API docs */
+dt:target {
+    background-color: #0c786a2d;
 }
 
-p.rubric {
-    font-size: 16px;
-}
\ No newline at end of file
+/* Layout of "Note" admonitions */
+.alert-info {
+	color: #ffffff;
+    background-color: #0c786a62;
+	border-color: #0c7869;
+    font-weight: 500;
+}
+
+/* Tweak size of logo in navbar */
+.navbar-brand img {
+    height: 180%;
+    margin-top: -0.4em;
+}
+
+/* Sphinx Tab configuration */
+.sphinx-tabs-tab {
+	color: #0c7869 !important;
+	font-size: 1em;
+}
+
+.sphinx-tabs-panel {
+	width: 60%;
+}
+
+[role="tablist"] {
+	width: 60%;
+}
+
+/* Global font-size  */
+body {
+    font-size: 1.7em;
+}
diff --git a/doc/source/advanced_usage.rst b/doc/source/advanced_usage.rst
index 6f8bc0c..65f4455 100644
--- a/doc/source/advanced_usage.rst
+++ b/doc/source/advanced_usage.rst
@@ -1,3 +1,8 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
 Advanced Usage and Customization
 ================================
 One of ACME's main objectives is to be as application-agnostic as possible
@@ -541,23 +546,27 @@ which yields
 Note that internally ACME keeps track of computational runs by injecting the
 keyword ``taskID`` into user-provided functions.
 
-Non-Interactive Logging (``logfile``)
--------------------------------------
+Messaging verbosity and logging (``verbose`` and ``logfile``)
+-------------------------------------------------------------
 Suppose some function ``f`` has to be called for 20000 different values of ``z``.
 Under the assumption that this computation takes a while, any run-time
-messages are to be written to a an auto-generated log-file:
+messages are to be written to a file ``my_log.txt``
 
 .. code-block:: python
 
     z = rng.integers(low=1, high=10, size=20000, endpoint=True)
-    with ParallelMap(f, x, y, z=z, logfile=True) as pmap:
+    with ParallelMap(f, x, y, z=z, logfile="my_log.txt") as pmap:
         results = pmap.compute()
 
-Alternatively, logging information may be written to a file ``my_log.txt`` instead
+To make ACME less "chatty" in its output, decrease the employed verbosity level:
 
 .. code-block:: python
 
     z = rng.integers(low=1, high=10, size=20000, endpoint=True)
-    with ParallelMap(f, x, y, z=z, logfile="my_log.txt") as pmap:
+    with ParallelMap(f, x, y, z=z, logfile="my_log.txt", verbose=False) as pmap:
         results = pmap.compute()
 
+Conversely, by setting ``verbose`` to ``True`` all internal debug messages
+are logged alongside standard output.
+
+
diff --git a/doc/source/api.rst b/doc/source/api.rst
index 224b28a..7ac8d9b 100644
--- a/doc/source/api.rst
+++ b/doc/source/api.rst
@@ -1,3 +1,8 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
 ACME API
 --------
 
diff --git a/doc/source/best_practices.rst b/doc/source/best_practices.rst
index d823d05..b2e8e57 100644
--- a/doc/source/best_practices.rst
+++ b/doc/source/best_practices.rst
@@ -1,3 +1,8 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
 Best Practices
 ==============
 Using ACME inside a Python script requires some precautions. Please follow this
@@ -17,7 +22,6 @@ inside a ``__main__`` block. Thus, if your script looks like this
 
 .. code-block:: python
 
-    # -*- coding: utf-8 -*-
     # Do something, then otherthing
 
     import package1
@@ -45,7 +49,6 @@ intended to be executed *just once*:
 
 .. code-block:: python
 
-    # -*- coding: utf-8 -*-
     # Do something, then otherthing
 
     import package1
@@ -85,7 +88,6 @@ single script
 
 .. code-block:: python
 
-    # -*- coding: utf-8 -*-
     # My processing script
 
     import numpy as np
@@ -107,7 +109,6 @@ split up the definition of `func` and its ACME parallelization:
 
 .. code-block:: python
 
-    # -*- coding: utf-8 -*-
     # My processing function
 
     import numpy as np
@@ -119,7 +120,6 @@ split up the definition of `func` and its ACME parallelization:
 
 .. code-block:: python
 
-    # -*- coding: utf-8 -*-
     # My ACME script for func
 
     import numpy as np
@@ -147,7 +147,6 @@ routines, they can all be migrated to ``my_func.py``. For instance:
 
 .. code-block:: python
 
-    # -*- coding: utf-8 -*-
     # My processing function
 
     import numpy as np
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 85fb5f4..d959120 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Configuration file for the Sphinx documentation builder.
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 # This file only contains a selection of the most common options. For a full
 # list see the documentation:
 # https://www.sphinx-doc.org/en/master/usage/configuration.html
@@ -13,15 +17,15 @@
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 import os
 import sys
+import time
 sys.path.insert(0, os.path.abspath(".." + os.sep + ".." + os.sep))
 import sphinx_bootstrap_theme
 import acme
 
 # -- Project information -----------------------------------------------------
-
-project = 'ACME'
-copyright = '2020, Ernst Strüngmann Institute for Neuroscience in Cooperation with Max Planck Society'
+project = ''
 author = 'Ernst Strüngmann Institute for Neuroscience in Cooperation with Max Planck Society'
+copyright = f'2020-{time.strftime("%Y")}, {author}'
 
 # The short X.Y version
 version = acme.__version__
@@ -46,6 +50,7 @@
     'sphinx.ext.viewcode',
     'sphinx.ext.inheritance_diagram',
     'sphinx_automodapi.automodapi',
+    'sphinx_tabs.tabs',
 ]
 
 autodoc_default_options = {
@@ -81,35 +86,44 @@ def setup(app):
 # This pattern also affects html_static_path and html_extra_path.
 exclude_patterns = []
 
-
 # -- Options for HTML output -------------------------------------------------
 
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
 html_theme = 'bootstrap'
 
-html_theme_path = sphinx_bootstrap_theme.get_html_theme_path()
+# Do not display permalink symbol next to headers
+html_permalinks = False
+
+# Path to favicon
+html_favicon = "_static/acme_icon.ico"
+
+# Logo displayed in navbar
+html_logo = "_static/acme_logo.png"
+
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
-#
-# html_logo = "_static/syncopy_icon.png"
+html_theme_path = sphinx_bootstrap_theme.get_html_theme_path()
 html_theme_options = {
-    "navbar_title": "ACME",
+    "navbar_title": "",
     "navbar_site_name": "Documentation",
     # Render the next and previous page links in navbar. (Default: true)
-    'navbar_sidebarrel': False,
+    "navbar_sidebarrel": False,
     # Render the current pages TOC in the navbar. (Default: true)
-    'navbar_pagenav': False,
+    "navbar_pagenav": True,
     # Tab name for the current pages TOC. (Default: "Page")
-    'navbar_pagenav_name': "Current Page",
+    "navbar_pagenav_name": "On This Page",
 
     # Global TOC depth for "site" navbar tab. (Default: 1)
     # Switching to -1 shows all levels.
-    'globaltoc_depth': 2,
-    'bootswatch_theme': "lumen",
-    'navbar_links': [
+    "globaltoc_depth": 2,
+    "bootswatch_theme": "lumen",
+    "navbar_links": [
         ("GitHub", "https://www.github.com/esi-neuroscience/acme", True),
     ],
 }
@@ -120,13 +134,13 @@ def setup(app):
 html_static_path = ['_static']
 
 # If true, links to the reST sources are added to the pages.
-html_show_sourcelink = True
+html_show_sourcelink = False
 
 # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-html_show_sphinx = True
+html_show_sphinx = False
 
 # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-html_show_copyright = False
+html_show_copyright = True
 
 # -- Options for intersphinx extension ---------------------------------------
 
diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst
new file mode 100644
index 0000000..159fc75
--- /dev/null
+++ b/doc/source/contributing.rst
@@ -0,0 +1,150 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
+Contributing to ACME
+====================
+
+We always welcome contributions to ACME! If you want to extend or modify
+ACME, please don't hesitate to get in touch via our
+`GitHub Issue Tracker <https://github.com/esi-neuroscience/acme/issues>`_.
+We're looking forward to hearing from you!
+
+.. contents:: Quick Links
+    :depth: 3
+
+Development Setup
+-----------------
+
+You want to work on ACME? Great! Simply follow the steps below to get up
+and running (the instructions assume you are working on Linux/macOS - if
+you want to set up a Windows environment )
+
+1. Fork the ACME repo on GitHub as explained in `Fork a repo <https://docs.github.com/en/get-started/quickstart/fork-a-repo>`_
+2. Clone your shiny new fork on your local machine (or cluster node):
+
+   .. code-block:: shell
+
+       git clone git://github.com/<your-github-profile>/acme.git
+       cd acme
+
+3. Create a fresh conda environment for ACME:
+
+   .. code-block:: shell
+
+       conda env create -f acme.yml
+
+4. Activate the environment and run ACME's test-suite to ensure your system
+   is set up correctly
+
+   .. tabs::
+
+       .. tab:: Linux/macOS
+
+           .. code-block:: shell
+
+               cd tests/
+               ./run_tests.sh pytest
+
+       .. tab:: Windows
+
+           .. code-block:: shell
+
+               cd tests/
+               run_tests.cmd pytest
+
+5. If everything looks fine, install ACME in "editable" mode. This creates
+   a symlink to your local git repo in your environment's ``site-packages``
+   directory (so you can import acme is if it was statically installed via
+   pip)
+
+   .. code-block:: shell
+
+       cd ../
+       pip install -e .
+
+Congratulations, you're now ready to start tweaking ACME!
+
+Development Pipeline
+--------------------
+
+We use feature branches for development with a dedicated development branch
+``[dev]`` used to collect all "production-ready" features ahead of an upcoming
+release.
+To get your feature included in ACME's upstream code, please create a new
+branch from ``[dev]`` in your fork and push your work to this branch. Once
+you're done with your changes, please open a
+`pull request <https://github.com/esi-neuroscience/acme/pulls>`_
+for merging in the ``[dev]`` branch of ACME's
+`main repository <https://github.com/esi-neuroscience/acme>`_.
+
+Coding Style
+-------------
+
+ACME generally follows the `PEP 8 Style Guide <https://peps.python.org/pep-0008/>`_
+and uses the `NumPy-Doc <https://numpydoc.readthedocs.io/en/latest/format.html>`_
+conventions to format docstrings.
+
+To keep commit logs transparent, we are using three-letter tags to
+classify commit messages:
+
+* **FIX** bugfix commit
+* **CHG** change(s) in functionality
+* **NEW** feature addition(s)
+* **REM** feature/functionality removed
+* **WIP** work-in-progress commit, code not yet functional
+* **DOC** modifications in documentation
+
+Each commit should ideally only use a single tag, specifically, please
+try to not mix feature additions (NEW) and bugfixes (FIX). To add context
+to a commit message, we use git's command line interface for committing
+and simply un-comment the auto-generated "Changes to be committed" section.
+To further help others retrace our steps, we usually refer to GitHub issues
+by their number prepended with the "#" symbol. Here's an example:
+
+.. code-block:: bash
+
+    FIX: Addresses bug in this_function
+
+    - the problem was fixed by doing something incredibly smart
+      in `this_function`
+    - in the process another bug was fixed too
+    - closes #4
+
+    Changes to be committed:
+        modified:   path/to/file1
+        modified:   path/to/file2
+
+Changelog
+---------
+
+We keep track of changes in ACME by keeping a global changelog inspired
+by `Keep a Changelog <https://keepachangelog.com>`_. The structure of
+ACME's changelog follows the exemplary layout below:
+
+.. code-block:: bash
+
+    # Changelog of ACME
+    All notable changes to this project will be documented in this file.
+    The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
+
+    ## [Unreleased]
+    ### NEW
+    ### CHANGED
+    - Updated documentation
+
+    ### REMOVED
+    ### DEPRECATED
+    ### FIXED
+    - repaired bug in `this_function` (closes #4)
+
+
+    ## [0.0.1] - 2023-03-01
+    Bugfix release.
+
+    ### CHANGED
+    - Updated `this_function`
+
+Feel free to add a short summary of your changes in the appropriate sections
+under `[Unreleased]`.
diff --git a/doc/source/index.rst b/doc/source/index.rst
index d7643f9..1f46d2e 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -3,15 +3,39 @@
    You can adapt this file completely to your liking, but it should at least
    contain the root `toctree` directive.
 
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
 .. title:: ACME Documentation
 
 .. image:: https://raw.githubusercontent.com/esi-neuroscience/acme/master/doc/source/_static/acme_logo.png
    :alt: ACME-Logo
 
+|
+
+.. image:: https://img.shields.io/conda/vn/conda-forge/esi-acme.svg
+   :target: https://anaconda.org/conda-forge/esi-acme
+   :alt: conda-version
+
+.. image:: https://badge.fury.io/py/esi-acme.svg
+   :target: https://badge.fury.io/py/esi-acme
+   :alt: pypi-version
+
+.. image:: https://img.shields.io/github/license/esi-neuroscience/acme
+   :target: https://github.com/esi-neuroscience/acme/blob/main/LICENSE
+   :alt: license
+
+.. image:: https://bestpractices.coreinfrastructure.org/projects/7144/badge
+   :target: https://bestpractices.coreinfrastructure.org/projects/7144
+   :alt: openssf
+
+
 Welcome to the Documentation of ACME
 ====================================
-ACME (**A**\synchronous **C**\omputing **M**\ade **E**\asy, pronounced *"ak-mee"*)
-is a framework for calling Python functions in parallel, primarily intended for
+ACME (**A**\synchronous **C**\omputing **M**\ade **E**\SI, pronounced *"ak-mee"*)
+is a framework for calling Python functions in parallel, originally intended for
 using SLURM on the ESI HPC cluster (local multi-processor hardware is supported as well).
 ACME is built on top of the parallel computing library `dask <https://www.dask.org/>`_
 and is used by `SyNCoPy <http://www.syncopy.org/>`_.
@@ -22,19 +46,25 @@ Please have a look at the :doc:`Quickstart Guide <quickstart>`.
 
 Examples
 --------
-Simple to advanced usage examples our collected on our `GitHub Page <https://github.com/esi-neuroscience/acme#usage>`_.
+Simple to advanced usage examples are collected on our `GitHub Page <https://github.com/esi-neuroscience/acme#usage>`_.
 
 User Guide
 ----------
-A more elaborate tour illustrating how to use ACME in various scenarios is
-provided in the :doc:`User Walkthrough <userguide>`. A short intro for how to use
-ACME in your processing pipeline is provided in :doc:`Best Practices <best_practices>`.
-Things don't go as planned? Take a look at :doc:`Troubleshooting + FAQ <troubleshooting_faq>`.
+Looking for a more elaborate tour illustrating how to use ACME in various scenarios? ⟶ :doc:`User Walkthrough <userguide>`
+
+Want to include ACME in your processing pipeline? ⟶ :doc:`Best Practices <best_practices>`
+
+Things don't go as planned? ⟶ :doc:`Troubleshooting + FAQ <troubleshooting_faq>`.
 
 API Documentation
 -----------------
 The full source code documentation can be found in the :doc:`API Docs <api>`.
 
+Contributing
+------------
+Contributions to ACME are always welcome! Please see :doc:`Contributing to ACME <contributing>`
+for details.
+
 Contact
 -------
 To report bugs or ask questions please use our `GitHub Issue Tracker <https://github.com/esi-neuroscience/acme/issues>`_.
@@ -55,4 +85,5 @@ Navigation
    best_practices
    troubleshooting_faq
    advanced_usage
+   contributing
    api
diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst
index bbc3e87..913d096 100644
--- a/doc/source/quickstart.rst
+++ b/doc/source/quickstart.rst
@@ -1,3 +1,8 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
 Quickstart
 ==========
 
@@ -7,17 +12,21 @@ Installing ACME
 For Users
 ^^^^^^^^^
 
-ACME can be installed using `pip <https://pypi.org/project/pip/>`_:
+ACME can be installed using ``pip`` or ``conda``:
 
-.. code-block:: bash
+.. tabs::
 
-    pip install esi-acme
+    .. tab:: conda
 
-or via `conda <https://www.anaconda.com/products/individual>`_
+        .. code-block:: shell
 
-.. code-block:: bash
+            conda install -c conda-forge esi-acme
+
+    .. tab:: pip
+
+        .. code-block:: shell
 
-    conda install -c conda-forge esi-acme
+            pip install esi-acme
 
 ACME is the parallelization engine of `Syncopy <https://pypi.org/project/esi-syncopy/>`_.
 If you're working on the ESI HPC cluster installing ACME and/or SyNCoPy is only necessary if
@@ -39,7 +48,8 @@ To install the latest development version of ACME in a fresh conda environment
     conda env create -f acme.yml
     pip install -e .
 
-(note the dot "." at the end of the ``pip install`` command).
+(note the dot "." at the end of the ``pip install`` command). More details
+are provided in :doc:`Contributing to ACME <contributing>`.
 
 Using ACME
 ----------
diff --git a/doc/source/troubleshooting_faq.rst b/doc/source/troubleshooting_faq.rst
index d568dde..b23d0d9 100644
--- a/doc/source/troubleshooting_faq.rst
+++ b/doc/source/troubleshooting_faq.rst
@@ -1,3 +1,8 @@
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
 Troubleshooting + FAQ
 ======================
 ACME is solely intended for executing user-provided functions multiple
@@ -54,6 +59,19 @@ errors. The root problem is of course completely unrelated to actual parallel
 execution of `f` but is instead actually caused by using the wrong input type for `x`.
 *Nail It Before You Scale It*.
 
+Once you have ensured that your function works fine in a sequential setting,
+you can try increasing ACME's logging verbosity to get a better understanding
+of what's happening under the hood:
+
+.. code-block:: python
+
+    with ParallelMap(myfunc, ..., logfile=True, verbose=True) as pmap:
+        results = pmap.compute()
+
+If your function works fine, but you think something's wrong with ACME,
+please let us know by opening a bug report in our
+`GitHub Issue Tracker <https://github.com/esi-neuroscience/acme/issues>`_.
+
 FAQ
 ^^^
 
diff --git a/doc/source/userguide.rst b/doc/source/userguide.rst
index 6197cb1..228a10d 100644
--- a/doc/source/userguide.rst
+++ b/doc/source/userguide.rst
@@ -1,5 +1,10 @@
-User Guide
-==========
+.. Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+.. in Cooperation with Max Planck Society
+
+.. SPDX-License-Identifier: CC-BY-NC-SA-1.0
+
+Walkthrough
+===========
 Learn how to get the most out of ACME for your own work by running through a
 quick but (hopefully) illustrative example that starts simple and subsequently
 turns on ACME's bells and whistles.
@@ -7,8 +12,8 @@ turns on ACME's bells and whistles.
 .. contents:: Quick Links
     :depth: 3
 
-Walkthrough
------------
+Setup
+-----
 Assume the function defined below is supposed to be run multiple times
 in parallel for different values of ``x``, ``y`` and ``z``
 
@@ -37,7 +42,7 @@ concurrently:
 * **Computation #3**: 36 = (8 + 4) * 3 (``x = 8``, ``y = 4``, ``z = 3``)
 
 Where Are My Results?
-^^^^^^^^^^^^^^^^^^^^^
+---------------------
 By default results are saved to disk in HDF5 format and ``results`` is a list
 of the corresponding filenames:
 
@@ -161,7 +166,7 @@ Now ``results`` is a list of integers:
     [18, 24, 30, 36]
 
 Override Automatic Input Argument Distribution
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+----------------------------------------------
 Next, suppose ``f`` has to be evaluated for the same values of ``x`` (again
 2, 4, 6 and 8), but ``y`` is not a number but a NumPy array:
 
@@ -217,7 +222,7 @@ This functionality is sometimes useful for routines that randomize their
 in- and/or outputs. An example and more information is provided in :ref:`taskIDex`
 
 Collect Results in Single Dataset
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+---------------------------------
 When evaluating functions that return a NumPy array (like in the example above),
 it is sometimes beneficial to aggregate results in a single dataset. Assume
 the four 1d-arrays of the above parallel evaluation of ``f`` are to be
@@ -277,7 +282,7 @@ contain four (3,)-arrays, but one (4, 3)-array. More information and additional
 control options are discussed in :doc:`Advanced Usage and Customization <advanced_usage>`.
 
 Reuse Worker Clients
-^^^^^^^^^^^^^^^^^^^^^
+--------------------
 Instead of letting ACME automatically start and stop parallel worker clients
 witch each invocation of :class:`~acme.ParallelMap`, a dask :class:`distributed.Client`
 can be customized and set up manually **before** launching the actual concurrent
@@ -341,7 +346,7 @@ Note the info message:
     <ParallelMap> INFO: Attaching to global parallel computing client <Client: 'tcp://10.100.32.5:39747' processes=50 threads=50, memory=400.00 GB>
 
 Debugging And Estimating Resource Consumption
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+---------------------------------------------
 Debugging programs running in parallel can be quite tricky.
 For instance, assume the function ``f`` is (erroneously) called with ``z``
 set to ``None``. In a regular sequential setting, identifying the problem
@@ -411,7 +416,7 @@ to :class:`~acme.ParallelMap` works as intended in a sequential setting prior to
 it in parallel.
 
 Interactive Monitoring
-^^^^^^^^^^^^^^^^^^^^^^
+----------------------
 When ACME starts a :class:`distributed.Client`, dask automatically sets up
 a `diagnostic dashboard <https://docs.dask.org/en/stable/dashboard.html>`_
 for the client. The dashboard is a web interface that allows live monitoring
@@ -432,6 +437,14 @@ of the global status of the concurrent processing task started by :class:`~acme.
 .. image:: _static/dashboard.gif
    :alt: dask-dashboard
 
+Non-Interactive Logging
+-----------------------
+In addition, if ACME's automatic result management is used (i.e., if
+``write_worker_results`` is ``True``), a log-file is created alongside
+the files generated on disk. Depending on the chosen verbosity level of
+ACME's messaging system (i.e., if ``verbose`` is either ``False``, ``None`` or
+``True``), the generated log-file contains (very) detailed runtime
+information of the performed computation.
 
 Wait, There's More...
 ---------------------
diff --git a/pyproject.toml b/pyproject.toml
index b5850fb..f3d5c39 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,10 @@
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
 [build-system]
 requires = ["pyyaml", "setuptools", "setuptools_scm", "toml", "wheel"]
 build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
index fa33747..c75df1f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,14 @@
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
 [metadata]
 name = esi-acme
 author = Ernst Strüngmann Institute (ESI) for Neuroscience in Cooperation with Max Planck Society
-author_email = acme@esi-frankfurt.de
+author_email = it@esi-frankfurt.de
 description = Asynchronous Computing Made ESI
 long_description = file: README.md
 long_description_content_type = text/markdown; charset=UTF-8
@@ -23,17 +30,17 @@ classifier =
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
     Programming Language :: Python :: Implementation :: CPython
 
 [options]
 packages = find:
 include_package_data = True
 zip_safe = False
-python_requires = >= 3.7, < 3.11
+python_requires = >= 3.7, < 3.12
 install_requires =
-    click < 8.1
     colorama
-    dask < 2022.11
+    dask >= 2021.09.0, < 2023.4
     dask-jobqueue >= 0.8, < 0.9
     h5py < 4
     numpy >= 1.0, < 2.0
@@ -46,8 +53,10 @@ dev=
     scipy >= 1.5, < 2.0
     numpydoc
     pytest-cov
+    sphinx < 6
     sphinx_automodapi
     sphinx_bootstrap_theme
+    sphinx-tabs
     tox
 
 [options.data_files]
diff --git a/setup.py b/setup.py
index 75f003b..a40ca83 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,10 @@
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
 # Builtins
 import datetime
 from setuptools import setup
@@ -10,14 +17,14 @@
 from setuptools_scm import get_version
 
 # Set release version by hand
-releaseVersion = "2022.12"
+releaseVersion = "2023.4"
 
 # Read dependencies from setup.cfg + pyproject.toml and create conda environment file
 envFile = "acme.yml"
 setupOpts = read_configuration("setup.cfg")["options"]
 tomlPkgs = toml.load("pyproject.toml")["build-system"]["requires"]
 allPkgs = setupOpts["install_requires"] + setupOpts["extras_require"]["dev"] + tomlPkgs
-pipPkgs = ["dask-jobqueue", "sphinx_automodapi"]
+pipPkgs = ["sphinx_automodapi"]
 for k in range(len(pipPkgs)):
     pkg = pipPkgs[k]
     pipPkgs[k] = allPkgs.pop([allPkgs.index(dep) for dep in allPkgs if pkg in dep][0])
@@ -26,11 +33,16 @@
 ymlDict = {"name" : "acme",
            "channels" : ["defaults", "conda-forge"],
            "dependencies" : allPkgs}
-msg = "# This file was auto-generated by setup.py on {}. \n" +\
+header = "#\n" +\
+    "# Copyright © {} Ernst Strüngmann Institute (ESI) for Neuroscience " +\
+    "# in Cooperation with Max Planck Society\n#\n" +\
+    "# SPDX-License-Identifier: CC0-1.0\n#\n" +\
+    "# This file was auto-generated by setup.py on {}. \n" +\
     "# Do not edit, all of your changes will be overwritten. \n"
-msg = msg.format(datetime.datetime.now().strftime("%d/%m/%Y at %H:%M:%S"))
-with open(envFile, 'w') as ymlFile:
-    ymlFile.write(msg)
+header = header.format(datetime.datetime.now().strftime("%Y"),
+                       datetime.datetime.now().strftime("%d/%m/%Y at %H:%M:%S"))
+with open(envFile, "w") as ymlFile:
+    ymlFile.write(header)
     yaml.dump(ymlDict, ymlFile, default_flow_style=False)
 
 # If code has not been obtained via `git` or we're inside the main branch,
diff --git a/tox.ini b/tox.ini
index 91339b0..646ef74 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,3 +1,10 @@
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
 [tox]
 envlist = {py38,py39,py310}
 requires = tox-conda
@@ -14,10 +21,10 @@ conda_deps=
     ipython
     tqdm >= 4.31
     numpy >= 1.0, < 2.0
-    scipy
+    scipy >= 1.5, < 2.0
     h5py < 4
-    dask >= 2021.09.0, < 2022.11
-    dask-jobqueue >= 0.7.1, < 0.9
+    dask >= 2021.09.0, < 2023.4
+    dask-jobqueue >= 0.8, < 0.9
     bokeh
     pytest
 conda_channels=


Test patch:
diff --git a/.github/workflows/tests_workflow.yml b/.github/workflows/tests_workflow.yml
index 4541843..69acae4 100644
--- a/.github/workflows/tests_workflow.yml
+++ b/.github/workflows/tests_workflow.yml
@@ -1,3 +1,10 @@
+#
+# Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
+
 name: tests
 
 on:
diff --git a/acme/tests/conftest.py b/acme/tests/conftest.py
index b8b5f79..c20bf78 100644
--- a/acme/tests/conftest.py
+++ b/acme/tests/conftest.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Central pytest configuration
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import sys
diff --git a/acme/tests/local_acme.py b/acme/tests/local_acme.py
index 08f698a..f4a7037 100644
--- a/acme/tests/local_acme.py
+++ b/acme/tests/local_acme.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Simple script for testing acme w/o pip-installing it
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import numpy as np
@@ -38,9 +42,11 @@ def arr_test(x, y):
     # Test stuff within here...
     # pass
 
-    with ParallelMap(arr_test, [np.ones((20,)), 2 * np.ones((20,)), 3 * np.ones((20,))], 4, result_shape=(None, 20)) as pmap:
+    with ParallelMap(arr_test, [np.ones((20,)), 2 * np.ones((20,)), 3 * np.ones((20,))], 4, result_shape=(None, 20), verbose=True, single_file=True) as pmap:
         results = pmap.compute()
 
+    1/0
+
     # pmap = ParallelMap(g, np.arange(100), 2)
     # pmap.daemon.estimate_memuse()
 
diff --git a/acme/tests/no_slurm.sh b/acme/tests/no_slurm.sh
index 2c789a1..470de44 100755
--- a/acme/tests/no_slurm.sh
+++ b/acme/tests/no_slurm.sh
@@ -1,4 +1,10 @@
 #!/bin/bash
+
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+
 # Sabotage local tox environment so that `sinfo` is not working any more
 if [ -n "$NO_SLURM" ]; then
     echo "ofnis" >| $VIRTUAL_ENV/bin/sinfo && chmod a+x $VIRTUAL_ENV/bin/sinfo
diff --git a/acme/tests/run_tests.cmd b/acme/tests/run_tests.cmd
index 614ca73..f4bc83e 100644
--- a/acme/tests/run_tests.cmd
+++ b/acme/tests/run_tests.cmd
@@ -1,3 +1,10 @@
+::
+:: Copyright (c) 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+:: in Cooperation with Max Planck Society
+::
+:: SPDX-License-Identifier: BSD-3-Clause
+::
+
 @echo off
 for %%I in ("%cd%\..\..") do set "PYTHONPATH=%%~fI"
 
diff --git a/acme/tests/run_tests.sh b/acme/tests/run_tests.sh
index 1b027a9..72b99e4 100755
--- a/acme/tests/run_tests.sh
+++ b/acme/tests/run_tests.sh
@@ -1,5 +1,12 @@
 #!/bin/bash
+#
 # Some quick shortcuts to quickly test ACME's proper functionality
+#
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # First and foremost, check if `srun` is available
 _useSLURM=$(command -v srun)
diff --git a/acme/tests/test_dask.py b/acme/tests/test_dask.py
index f159631..d1e87f7 100644
--- a/acme/tests/test_dask.py
+++ b/acme/tests/test_dask.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Testing module for ACME's dask components
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import pytest
@@ -9,7 +13,6 @@
 import numpy as np
 
 # Import main actors here
-from acme.dask_helpers import customIOError
 from acme import cluster_cleanup, esi_cluster_setup, slurm_cluster_setup, local_cluster_setup
 from conftest import useSLURM, onESI, defaultQ
 
@@ -91,7 +94,7 @@ def test_cluster_setup():
     else:
 
         # Trigger an exception by invoking `slurm_cluster_setup`` on non-SLURM node
-        with pytest.raises(customIOError) as err:
+        with pytest.raises(IOError) as err:
             slurm_cluster_setup()
             assert "Cannot access SLURM queuing system" in str(err)
 
diff --git a/acme/tests/test_pmap.py b/acme/tests/test_pmap.py
index 1644234..b11281b 100644
--- a/acme/tests/test_pmap.py
+++ b/acme/tests/test_pmap.py
@@ -1,7 +1,11 @@
-# -*- coding: utf-8 -*-
 #
 # Testing module for ACME's `ParallelMap` interface
 #
+# Copyright © 2023 Ernst Strüngmann Institute (ESI) for Neuroscience
+# in Cooperation with Max Planck Society
+#
+# SPDX-License-Identifier: BSD-3-Clause
+#
 
 # Builtin/3rd party package imports
 import os
@@ -20,12 +24,12 @@
 import signal as sys_signal
 import numpy as np
 import dask.distributed as dd
+from logging import handlers
 from glob import glob
 from scipy import signal
 
 # Import main actors here
 from acme import ParallelMap, cluster_cleanup, esi_cluster_setup
-from acme.dask_helpers import customIOError
 from conftest import skip_if_not_linux, useSLURM, onESI, defaultQ
 
 # Functions that act as stand-ins for user-funcs
@@ -226,6 +230,11 @@ def test_simple_filter(self, testclient=None):
         assert len(resFiles) == pmap.n_calls
         assert all(fle in resFiles for fle in resOnDisk)
         assert all(os.path.isfile(fle) for fle in resOnDisk)
+        log = logging.getLogger("ACME")
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
+        assert len(logFileList) == 1
+        assert logFileList[0] in outDirContents
+        assert len(outDirContents) == 3 # results container, payload dir and log
 
         # Compare computed single-channel results to expected low-freq signal
         # and ensure collection container was assembled correctly
@@ -252,7 +261,7 @@ def test_simple_filter(self, testclient=None):
 
         # Ensure only one file was generated
         assert len(singleResOnDisk) == 1
-        outDirContents = glob(os.path.join(pmap.out_dir, "*"))
+        outDirContents = glob(os.path.join(pmap.out_dir, "*.h5"))
         assert outDirContents == singleResOnDisk
         assert pmap.results_container in outDirContents[0]
         assert os.path.isfile(singleResOnDisk[0])
@@ -362,9 +371,10 @@ def test_simple_filter(self, testclient=None):
         # Bonus: leave computing client alive and vet default SLURM settings
         if testclient is None:
             cluster_cleanup(pmap.client)
-        for handler in pmap.log.handlers:
+        log = logging.getLogger("ACME")
+        for handler in log.handlers:
             if isinstance(handler, logging.FileHandler):
-                pmap.log.handlers.remove(handler)
+                log.handlers.remove(handler)
         with ParallelMap(lowpass_simple,
                          sigName,
                          range(self.nChannels),
@@ -374,10 +384,14 @@ def test_simple_filter(self, testclient=None):
                          setup_interactive=False) as pmap:
             pmap.compute()
         outDirs.append(pmap.out_dir)
-        logFileList = [handler.baseFilename for handler in pmap.log.handlers if isinstance(handler, logging.FileHandler)]
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
         assert len(logFileList) == 1
         logFile = logFileList[0]
-        assert os.path.dirname(os.path.realpath(__file__)) in logFile
+        # If running on the ESI cluster, account for /cs/home
+        if onESI and useSLURM:
+            assert "/cs/home/" in logFile
+        else:
+            assert os.path.dirname(os.path.realpath(__file__)) in logFile
         with open(logFile, "r") as fl:
             assert len(fl.readlines()) > 1
 
@@ -414,7 +428,9 @@ def test_simple_filter(self, testclient=None):
             assert len(fl.readlines()) > 1
 
         # Ensure only single log file `customLog` is used
-        assert len([h for h in pmap.log.handlers if isinstance(h, logging.FileHandler)]) == 1
+        logFileList = [handler.target.baseFilename for handler in log.handlers if isinstance(handler, handlers.MemoryHandler)]
+        assert len(logFileList) == 1
+        assert logFileList[0] == customLog
 
         # Ensure client has been stopped
         if testclient is None:
@@ -549,8 +565,8 @@ def test_medium_filter(self, testclient=None):
             pmap.compute()
         outDirs.append(pmap.out_dir)
 
-        # Ensure only one file was generated
-        assert glob(os.path.join(pmap.out_dir, "*")) == [pmap.results_container]
+        # Ensure only one (data) file was generated
+        assert glob(os.path.join(pmap.out_dir, "*.h5")) == [pmap.results_container]
 
         # Compare single file to link collection computed above
         with h5py.File(colRes, "r") as h5col:
@@ -638,8 +654,8 @@ def test_outshape(self, testclient=None):
             pmap.compute()
         outDirs.append(pmap.out_dir)
 
-        # Ensure only one file was generated
-        assert glob(os.path.join(pmap.out_dir, "*")) == [pmap.results_container]
+        # Ensure only one (data) file was generated
+        assert glob(os.path.join(pmap.out_dir, "*.h5")) == [pmap.results_container]
 
         # Compare single file to virtual dataset
         with h5py.File(colRes, "r") as h5col:
@@ -658,7 +674,7 @@ def test_outshape(self, testclient=None):
                          setup_interactive=False) as pmap:
             resInMem = pmap.compute()
         with h5py.File(colRes, "r") as h5col:
-            assert np.array_equal(h5col["result_0"][()], resInMem[0])
+            assert np.array_equal(h5col["result_0"][()], resInMem)
 
         # Ensure dtype is respected
         with ParallelMap(lowpass_simple,
@@ -1046,9 +1062,9 @@ def test_cancel(self):
 
             # Wait for ACME to start up (as soon as logging info is shown, `pmap.compute()` is running)
             # However: don't wait indefinitely - if `pmap.compute` is not started within 30s, abort
-            logStr = "<ParallelMap> INFO: Log information available at"
+            logStr = "Preparing 2 parallel calls"
             buffer = bytearray()
-            timeout = 30
+            timeout = 90
             t0 = time.time()
             for line in itertools.takewhile(lambda x: time.time() - t0 < timeout, iter(proc.stdout.readline, b"")):
                 buffer.extend(line)
@@ -1064,7 +1080,7 @@ def test_cancel(self):
             time.sleep(1)
             out = proc.stdout.read().decode()
             assert "ALL DONE" not in out
-            assert "INFO: <cluster_cleanup> Successfully shut down" in out
+            assert "Successfully shut down" in out
 
         # Almost identical script, this time use an externally started client
         scriptName = os.path.join(tempDir, "dummy2.py")
@@ -1076,7 +1092,7 @@ def test_cancel(self):
             "   return\n" +\
             "if __name__ == '__main__':\n" +\
             "   client = esi_cluster_setup(partition='8GBDEV',n_workers=1, interactive=False)\n" +\
-            "   with ParallelMap(long_running, [None]*2, setup_interactive=False, write_worker_results=False) as pmap: \n" +\
+            "   with ParallelMap(long_running, [None]*2, setup_interactive=False, write_worker_results=False, verbose=True) as pmap: \n" +\
             "       pmap.compute()\n" +\
             "   print('ALL DONE')\n"
         with open(scriptName, "w") as f:
@@ -1087,7 +1103,7 @@ def test_cancel(self):
             proc = subprocess.Popen("stdbuf -o0 " + sys.executable + " " + scriptName,
                                     shell=True, start_new_session=True,
                                     stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0)
-            logStr = "<ParallelMap> INFO: Log information available at"
+            logStr = "This is ACME"
             buffer = bytearray()
             timeout = 60
             t0 = time.time()
@@ -1101,7 +1117,7 @@ def test_cancel(self):
             time.sleep(2)
             out = proc.stdout.read().decode()
             assert "ALL DONE" not in out
-            assert "<ParallelMap> INFO: <ACME> CTRL + C acknowledged, client and workers successfully killed" in out
+            assert "CTRL + C acknowledged, client and workers successfully killed" in out
 
         # Ensure random exception does not immediately kill an active client
         scriptName = os.path.join(tempDir, "dummy3.py")
@@ -1155,7 +1171,7 @@ def test_memest(self):
         # Create tmp directory for logfile
         tempDir = os.path.join(os.path.abspath(os.path.expanduser("~")), "acme_tmp")
         os.makedirs(tempDir, exist_ok=True)
-        customLog = os.path.join(tempDir, "acme_log.txt")
+        customLog = os.path.join(tempDir, "mem_log.txt")
         outDirs = []
 
         # Set `arrsize` depending on available runner hardware and prepare expected
@@ -1196,6 +1212,12 @@ def test_memest(self):
             assert toc - tic < 30
 
         # Check that for 2 parallel calls only 2 workers were memory-profiled
+        # (we need to flush `MemoryHandler` manually so that its contents
+        # is actually written to `customLog`)
+        log = logging.getLogger("ACME")
+        for h in log.handlers:
+            if hasattr(h, "flush"):
+                h.flush()
         with open(customLog, "r", encoding="utf8") as f:
             logTxt = f.read()
         assert "Estimated memory consumption across 2 runs" in logTxt
@@ -1255,6 +1277,12 @@ def test_memest(self):
             assert 140 < toc - tic < 200
 
         # Check that for max 5 workers were memory-profiled
+        # (we need to flush `MemoryHandler` manually so that its contents
+        # is actually written to `customLog`)
+        log = logging.getLogger("ACME")
+        for h in log.handlers:
+            if hasattr(h, "flush"):
+                h.flush()
         with open(customLog2, "r", encoding="utf8") as f:
             logTxt = f.read()
         assert "Estimated memory consumption across 5 runs" in logTxt
@@ -1289,7 +1317,7 @@ def test_memest(self):
         else:
 
             # Simulate call of ParallelMap(partition="auto",...) but w/wrong mem_per_worker!
-            with pytest.raises(customIOError):
+            with pytest.raises(IOError):
                 esi_cluster_setup(partition="auto", mem_per_worker="invalid")
 
             # Simulate `ParallelMap(partition="auto",...)` call by invoking `esi_cluster_setup`
@@ -1348,7 +1376,8 @@ def test_backcompat(self):
         outDirs.append(pmap.out_dir)
 
         # Ensure a deprecation warning was issued
-        for handler in pmap.log.handlers:
+        log = logging.getLogger("ACME")
+        for handler in log.handlers:
             if isinstance(handler, logging.FileHandler):
                 with open(handler.baseFilename, "r") as fl:
                     logTxt = fl.read()