merge default ==> pilfer
M .hgtags +2 -0
@@ 1341,3 1341,5 @@ 8c0b3a5ee50a4ea14c9598442883843191b32ebc
 5e2dad99624454df502d77d5d3a1bed20887d209 cs.djutils-20250113.2
 afb3a471221e72416f08b81b73507d643b886546 cs.fsm-20250120
 8d2f3ed2cef226ffcfe1ed17242686d5ccc5bf8b cs.taskqueue-20250120
+d7680b5a801ead0698a1ecbe9ed7aa916d368132 cs.djutils-20250213
+2bc7c8ecc8ef239d35f23fc3c5804b2b9b91c385 cs.djutils-20250219

          
M TODO.txt +9 -2
@@ 1,8 1,15 @@ 
-default: wpr RENEW to scan the spaces configs for the random directories?
+default: sqlite worker - use the queue get_batch mode to collect requests, open sqlite db in bursts
+default: generic sqlite dbm with thread worker to be pulled from ContentCache, into a dbmutils or mappings class
+cs.urlutils: URL: use cs.rfc2616.content_type, content_length
+default: cs.debug.stack_dump vs cs.py/stack.stack_dump
+ContextManagerMixin: document robust recipe for classws which subclass 2 or more classes which subclass ContextManagerMixin, see cs.Later.Later for example
+cs.rfc2616: use cornucopybuffer, update read_chunked
+spaces: try using tilde in spaces_pathfor (tried it, does not work with ~/ or /~/)
+spaces: wpr RENEW to scan the spaces configs for the random directories?
 PipeLineSpec.make_stage_funcs: each input (item,P) should become (item,P=P.copy_with_vars(_=item)) so as to have its own set of variables
 default: wpr RENEW, fast mode
 RunState.__enter__exit__: also set runstate.cancel to the asyncio event loop SIGINT handler?
-cs.binary: genberate .5 manpage entries from classes? maybe just those built from struct formats initially
+cs.binary: generate .5 manpage entries from classes? maybe just those built from struct formats initially
 setvar: not expanding leading $MANPATH etc
 bin-cs/brew: pass all HOMEBREW_* envvars through to brewexe
 cs.jsonutils: merge the various JSON utility functions?

          
M bin-cs/wpr +7 -7
@@ 23,12 23,11 @@ from icontract import require
 from typeguard import typechecked
 
 from cs.app.osx.spaces import Spaces
-from cs.cmdutils import BaseCommand
 from cs.fs import needdir, scandirtree, shortpath
 from cs.lastvalue import LastValue
 from cs.lex import cutprefix
 from cs.mappings import missingdict
-from cs.pfx import Pfx, pfx_call
+from cs.pfx import pfx_call
 
 # cached autopopulating mapping of  to list of re.Pattern
 word_re_map = missingdict(partial(re.compile, flags=re.I))

          
@@ 103,13 102,13 @@ def main(argv=None):
     for wpn, word_list in sorted(last_wp_values(lv).items()):
       word_tuple = tuple(word_list)
       if word_tuple not in seen_word_tuples:
-        wpdir,_=update_random_wpdir(wplinkdirpath, word_tuple, wppath, nwp)
+        wpdir, _ = update_random_wpdir(wplinkdirpath, word_tuple, wppath, nwp)
         seen_word_tuples.add(word_tuple)
-      spaces.set_wp_dirpath(wpn-1,wpdir)
+      spaces.set_wp_fspath(wpn - 1, wpdir)
   else:
-    wpdir,_=update_random_wpdir(wplinkdirpath, word_tuple, wppath, nwp)
+    wpdir, _ = update_random_wpdir(wplinkdirpath, word_tuple, wppath, nwp)
     for wpn in space_list:
-      spaces.set_wp_dirpath(wpn-1,wpdir)
+      spaces.set_wp_fspath(wpn - 1, wpdir)
 
 def last_wp_values(lv) -> dict[int, list[str]]:
   ''' Return a `dict` mapping space number to a list of words.

          
@@ 133,7 132,7 @@ def update_random_wpdir(
   ''' Randomly `count` image paths found in `wppath` matching `words`.
       Choose a subdirectory of `wplinkdirpath` based on `words`, making it at need.
       Update its contents with symlinks to the chosen paths.
-      Return 2-tuple of (wpsublinkdirpath,namemap)` being the
+      Return 2-tuple of `(wpsublinkdirpath,namemap)` being the
       subdirectory path and a mapping of names in `linkdirpath` to
       absolute forms of `paths`.
   '''

          
@@ 170,6 169,7 @@ def update_linkdir(linkdirpath: str, pat
   '''
   # TODO: deal with paths which conflict by basename
   # TODO: hard link mode?
+  # TODO: move to cs.fs
   name_map = {basename(path): abspath(path) for path in paths}
   for name, linkpath in sorted(name_map.items()):
     linkpath_short = shortpath(linkpath)

          
M bin/pf-tshow +5 -2
@@ 9,8 9,11 @@ set -ue
 cmd=$0
 usage="Usage: $cmd [-a|table...]"
 
+trace=
+[ -t 2 ] && trace=set-x
+
 # no table name? recite tables
-[ $# -gt 0 ] || exec pfctl -sT
+[ $# -gt 0 ] || exec $trace pfctl -sT
 
 if [ "x$*" = x-a ]
 then

          
@@ 30,7 33,7 @@ fi
 # must be exactly one table name
 table=$1; shift
 
-pfctl -t "$table" -T show -v \
+$trace pfctl -t "$table" -T show -v \
 | awk '/^ *[1-9]/ { ip=$1 }
        /^\tCleared:/ {
          mon=$3;

          
M bin/pft +17 -9
@@ 1,12 1,12 @@ 
 #!/bin/sh
 #
 # pft - convenience wrapper for "pfctl(8) -t".
-#       - Cameron Simpson <cs@cskk.id.au> 24may2014
+# - Cameron Simpson <cs@cskk.id.au> 24may2014
 #
 
 set -ue
 
-: ${PFT_EXPORT:=$HOME/var/pf/tables}
+: "${PFT_EXPORT:=$HOME/var/pf/tables}"
 
 trace=
 [ -t 2 ] && trace=set-x

          
@@ 14,11 14,19 @@ trace=
 cmd=`basename "$0"`
 usage="Usage:
   $cmd [-a anchor] [op [args...]]
-Operations:
-  $cmd cat table...
-  $cmd export [-d export_base] [table...]
-  $cmd import [-d export_base] [-m {add|replace}] table...
-  $cmd [ls]     List tables."
+  The default op is \"ls\".
+  -a anchor Specify the pf anchor, default \"\" (the root).
+  Operations:
+    $cmd show table...
+      Show table contents.
+    $cmd export [-d export_base] [table...]
+      Export table contents to files in $PFT_EXPORT (from \$PFT_EXPORT).
+      -d export_base    Specify an alterantive export directory.
+    $cmd import [-d export_base] [-m {add|replace}] table...
+      Import table contents from files in $PFT_EXPORT (from \$PFT_EXPORT).
+      -d export_base    Specify an alterantive export directory.
+    $cmd [ls]
+      List tables."
 
 badopts=
 

          
@@ 48,7 56,7 @@ shift
 }
 
 case "$op" in
-  cat)
+  show)
     for t
     do
       $trace pfctl -a "$anchor" -t "$t" -T show

          
@@ 74,7 82,7 @@ case "$op" in
     for t
     do
       tfile=$export_dir/$t.txt
-      _pft cat "$t" >"$tfile"
+      _pft show "$t" >"$tfile"
     done
     ;;
   import)

          
M lib/python/cs/app/osx/spaces.py +93 -63
@@ 9,11 9,13 @@ from getopt import GetoptError
 import os
 from os.path import (
     abspath,
+    dirname,
     exists as existspath,
     isdir as isdirpath,
+    isfile as isfilepath,
     join as joinpath,
 )
-from pprint import pprint
+from pprint import pformat, pprint
 from random import choice as random_choice
 import sys
 from typing import Optional

          
@@ 25,11 27,11 @@ from typeguard import typechecked
 from cs.cmdutils import BaseCommand
 from cs.context import stackattrs
 from cs.delta import monitor
-from cs.logutils import warning
-from cs.pfx import Pfx, pfx_call
+from cs.fs import shortpath
+from cs.lex import tabulate
+from cs.pfx import Pfx, pfx_call, pfx_method
 
 from .misc import macos_version
-
 from .objc import apple, cg
 
 __version__ = '20250108-post'

          
@@ 64,6 66,9 @@ DISTINFO = {
 CG = apple.CoreGraphics
 HI = apple.HIServices
 
+DEFAULT_BACKGROUND_RGB = 0, 0, 0  # black background
+VALID_IMAGE_SUFFIXES = '.jpg', '.png'
+
 def main(argv=None):
   ''' cs.app.osx.spaces command line mode.
   '''

          
@@ 160,7 165,7 @@ class Spaces:
             if space_num < 1:
               raise GetoptError("space# counts from 1")
             if space_num > len(self):
-              raise GetoptError("only %d spaces" % (len(self),))
+              raise GetoptError(f'only {len(self)} spaces')
             space_indices = (space_num - 1,)
     return space_indices
 

          
@@ 197,6 202,7 @@ class Spaces:
         self.display_id, 0, space["uuid"]
     )
 
+  @pfx_method
   @typechecked
   def set_wp_config(self, space_index: int, wp_config: dict):
     ''' Set the desktop picture configuration of the space at

          
@@ 213,67 219,93 @@ class Spaces:
         space["uuid"],
     )
 
-  def set_wp_dirpath(
+  @staticmethod
+  def spaces_pathfor(fspath: str):
+    ''' Return `fspath` adjusted for use in a spaces configuration.
+
+        Prior to MacOS Sonoma (14.5), this just returns the absolute path.
+
+        In MacOS Sonoma there's some hideous bug in the
+        DesktopPictureSetDisplayForSpace library where it seems to
+        see a leading home directory path and replace it with `/~`
+        (instead of something plausible like '~'), perhaps intended
+        for making paths track homedir moves.  It turns out that
+        providing a _relative_ path from '/' does The Right Thing.
+        Ugh.
+    '''
+    fspath = abspath(fspath)
+    if macos_version < (14, 5):
+      spaces_path = fspath
+    else:
+      spaces_path = fspath[1:]
+      # a cut at seeing if the ~ _is_ meaningful, but it doesn't work
+      ##home = os.environ.get('HOME')
+      ##if home and isdirpath(home):
+      ##  home_prefix = f'{home}/'
+      ##  if fspath.startswith(home_prefix):
+      ##    spaces_path = f'~/{cutprefix(fspath,home_prefix)}'
+    return spaces_path
+
+  @pfx_method
+  def set_wp_fspath(
       self,
       space_index: int,
-      dirpath: str,
+      fspath: str,
       *,
+      background_color=DEFAULT_BACKGROUND_RGB,
       random=True,
       change_duration=5.0,
       placement='SizeToFit',
   ):
-    print("spaces set_wp_dirpath", space_index, dirpath)
-    images = [
-        filename for filename in os.listdir(dirpath)
-        if not filename.startswith('.') and '.' in filename
-    ]
-    if not images:
-      warning("no *.* files in %r", dirpath)
-      return 1
-    lastname = random_choice(images)
-    imagepath = abspath(joinpath(dirpath, lastname))
-    if macos_version < (14, 5):
-      # This worked before I upgraded to Sonoma, MacOS 14.5.
+    ''' Set the Space configuration of `space_index` to use images from `fspath`.
+    '''
+    print("spaces set_wp_fspath", space_index, fspath)
+    if isfilepath(fspath):
+      # an image file
+      if not fspath.endswith(VALID_IMAGE_SUFFIXES):
+        raise ValueError(
+            f'invalid image path suffix, expected one of {VALID_IMAGE_SUFFIXES!r}'
+        )
+      space_imagepath = self.spaces_pathfor(fspath)
+      dirpath = dirname(abspath(fspath))
+      spaces_dirpath = self.spaces_pathfor(dirpath)
+      wp_config = dict(
+          BackgroundColor=background_color,
+          ChangePath=spaces_dirpath,
+          NewChangePath=spaces_dirpath,
+          ImageFilePath=space_imagepath,
+          Placement=placement,
+      )
+    elif isdirpath(fspath):
+      spaces_dirpath = self.spaces_pathfor(fspath)
+      images = [
+          filename for filename in os.listdir(fspath)
+          if not filename.startswith('.') and '.' in filename
+          and filename.endswith(VALID_IMAGE_SUFFIXES)
+      ]
+      if not images:
+        raise ValueError(
+            f'no *.{{{",".join(VALID_IMAGE_SUFFIXES)}}} files in {fspath}'
+        )
+      lastname = random_choice(images)
+      spaces_imagepath = self.spaces_pathfor(joinpath(fspath, lastname))
       # pylint: disable=use-dict-literal
       wp_config = dict(
-          BackgroundColor=(0, 0, 0),
+          BackgroundColor=background_color,
           Change='TimeInterval',
-          ChangePath=abspath(dirpath),
-          NewChangePath=abspath(dirpath),
+          ChangeDuration=change_duration,
+          ChangePath=spaces_dirpath,
+          NewChangePath=spaces_dirpath,
           ChangeTime=change_duration,
           DynamicStyle=0,
-          ImageFilePath=imagepath,
-          NewImageFilePath=imagepath,
-          LastName=lastname,
+          ImageFilePath=spaces_imagepath,
+          ##NewImageFilePath=spaces_imagepath,
+          ##LastName=lastname,
           Placement=placement,
           Random=random,
       )  # pylint: disable=use-dict-literal
     else:
-      # MacOS Sonoma onward
-      # There's some hideous bug in the DesktopPictureSetDisplayForSpace
-      # library where it seems to see a leading home directory path
-      # and replace it with '/~' (instead of something plausible like '~'),
-      # perhaps intended for making paths track homedir moves.
-      # It turns out that providing a _relative_ path from '/'
-      # does The Right Thing. Ugh.
-      dirpath = abspath(dirpath)
-      dirpath = dirpath[1:]
-      ##rdirpath = relpath(dirpath, os.environ['HOME'])
-      ##if not rdirpath.startswith('../'):
-      ##  dirpath = rdirpath
-      wp_config = dict(
-          BackgroundColor=(0, 0, 0),
-          Change='TimeInterval',
-          ChangePath=dirpath,
-          NewChangePath=dirpath,
-          ChangeDuration=change_duration,
-          DynamicStyle=0,
-          ##ImageFilePath=imagepath,
-          ##NewImageFilePath=imagepath,
-          LastName=lastname,
-          Placement=placement,
-          Random=random,
-      )  # pylint: disable=use-dict-literal
+      raise ValueError('unsupported fspath, neither image file nor directory')
     self.set_wp_config(space_index, wp_config)
 
   def monitor_current(self, **kw):

          
@@ 317,7 349,7 @@ class SpacesCommand(BaseCommand):
           Print the current space number.
     '''
     if argv:
-      raise GetoptError("extra arguments: %r" % (argv,))
+      raise GetoptError(f'extra arguments: {argv!r}')
     print(self.options.spaces.current_index + 1)
 
   def cmd_monitor(self, argv):

          
@@ 325,7 357,7 @@ class SpacesCommand(BaseCommand):
           Monitor space switches.
     '''
     if argv:
-      raise GetoptError("extra arguments: %r" % (argv,))
+      raise GetoptError(f'extra arguments: {argv!r}')
     spaces = self.options.spaces
     for old, new, changes in monitor(
         lambda: (spaces.forget(), {'index': spaces.current_index})[-1],

          
@@ 349,26 381,24 @@ class SpacesCommand(BaseCommand):
         if not existspath(wp_path):
           raise GetoptError("not a file")
     if argv:
-      raise GetoptError("extra aguments: %r" % (argv,))
+      raise GetoptError(f'extra aguments: {argv!r}')
     if wp_path is None:
       if space_indices is None:
         space_indices = list(range(len(spaces)))
+      report_lines = []
       for space_index in space_indices:
+        report_lines.append([f'Space {space_index + 1}'])
         space_num = space_index + 1
-        print("Space", space_num)
         for k, v in sorted(spaces.get_wp_config(space_index).items()):
-          print(" ", k, "=", str(v).replace("\n", ""))
+          report_lines.append([f'  {k}', pformat(v)])
+      for line in tabulate(*report_lines):
+        print(line)
     else:
       if space_indices is None:
         space_indices = [spaces.current_index]
       for space_index in space_indices:
-        with Pfx("%d <- %r", space_index + 1, wp_path):
-          if isdirpath(wp_path):
-            spaces.set_wp_dir(wp_path)
-          else:
-            # pylint: disable=use-dict-literal
-            wp_config = dict(ImageFilePath=abspath(wp_path),)
-          spaces.set_wp_config(space_index, wp_config)
+        with Pfx("%d <- %s", space_index + 1, shortpath(wp_path)):
+          spaces.set_wp_fspath(space_index, wp_path)
     return 0
 
   def cmd_wpm(self, argv):

          
@@ 386,7 416,7 @@ class SpacesCommand(BaseCommand):
       except ValueError:
         # pylint: disable=raise-missing-from
         raise GetoptError(
-            "expected exactly one space index, got: %r" % (space_indices,)
+            f'expected exactly one space index, got: {space_indices!r}'
         )
     for old, new, changes in spaces.monitor_wp_config(
         space_index=space_index,

          
M lib/python/cs/app/playon.py +12 -12
@@ 105,6 105,15 @@ DEFAULT_FILENAME_FORMAT = (
     '{series_prefix}{series_episode_name}--{resolution}--{playon.ProviderID}--playon--{playon.ID}'
 )
 
+# default "ls" output format
+LS_FORMAT = (
+    '{playon.ID} {playon.HumanSize} {resolution}'
+    ' {nice_name} {playon.ProviderID} {status:upper}'
+)
+
+# default "queue" output format
+QUEUE_FORMAT = '{playon.ID} {playon.Series} {playon.Name} {playon.ProviderID}'
+
 # download parallelism
 DEFAULT_DL_PARALLELISM = 2
 

          
@@ 117,16 126,6 @@ def main(argv=None):
 class PlayOnCommand(BaseCommand):
   ''' Playon command line implementation.
   '''
-
-  # default "ls" output format
-  LS_FORMAT = (
-      '{playon.ID} {playon.HumanSize} {resolution}'
-      ' {nice_name} {playon.ProviderID} {status:upper}'
-  )
-
-  # default "queue" output format
-  QUEUE_FORMAT = '{playon.ID} {playon.Series} {playon.Name} {playon.ProviderID}'
-
   USAGE_KEYWORDS = {
       'DEFAULT_DL_PARALLELISM': DEFAULT_DL_PARALLELISM,
       'DEFAULT_FILENAME_FORMAT': DEFAULT_FILENAME_FORMAT,

          
@@ 291,6 290,7 @@ class PlayOnCommand(BaseCommand):
     '''
     options = self.options
     dl_jobs = options.dl_jobs
+    no_download = options.dry_run
     sqltags = options.sqltags
     if not argv:
       argv = ['pending']

          
@@ 467,7 467,7 @@ class PlayOnCommand(BaseCommand):
           -o format Format string for each entry.
           Default format: {LS_FORMAT}
     '''
-    return self._list(argv, self.options, ['available'], self.LS_FORMAT)
+    return self._list(argv, self.options, ['available'], LS_FORMAT)
 
   def cmd_poll(self, argv):
     if argv:

          
@@ 482,7 482,7 @@ class PlayOnCommand(BaseCommand):
           -o format Format string for each entry.
           Default format: {QUEUE_FORMAT}
     '''
-    return self._list(argv, self.options, ['queued'], self.QUEUE_FORMAT)
+    return self._list(argv, self.options, ['queued'], QUEUE_FORMAT)
 
   cmd_q = cmd_queue
 

          
M lib/python/cs/djutils.py +40 -6
@@ 10,15 10,17 @@ 
 
 from dataclasses import dataclass, field
 from inspect import isclass
+from itertools import chain
 import os
 import sys
-from typing import Iterable, List
+from typing import Iterable, List, Mapping
 
 from django.conf import settings
 from django.core.management.base import (
     BaseCommand as DjangoBaseCommand,
     CommandError as DjangoCommandError,
 )
+from django.db.models import Model
 from django.db.models.query import QuerySet
 from django.utils.functional import empty as djf_empty
 

          
@@ 28,7 30,7 @@ from cs.cmdutils import BaseCommand as C
 from cs.gimmicks import warning
 from cs.lex import cutprefix, stripped_dedent
 
-__version__ = '20250113.2-post'
+__version__ = '20250219-post'
 
 DISTINFO = {
     'keywords': ["python3"],

          
@@ 228,20 230,25 @@ class BaseCommand(CSBaseCommand, DjangoB
     parser.add_argument('argv', nargs='*')
 
 def model_batches_qs(
-    model,
+    model: Model,
     field_name='pk',
     *,
     chunk_size=1024,
     desc=False,
     exclude=None,
     filter=None,
+    only=None,
 ) -> Iterable[QuerySet]:
   ''' A generator yielding `QuerySet`s which produce nonoverlapping
-      batches of model instances.
+      batches of `Model` instances.
 
       Efficient behaviour requires the field to be indexed.
       Correct behaviour requires the field values to be unique.
 
+      See `model_instances` for an iterable of instances wrapper
+      of this function, where you have no need to further amend the
+      `QuerySet`s or to be aware of the batches.
+
       Parameters:
       * `model`: the `Model` to query
       * `field_name`: default `'pk'`, the name of the field on which

          
@@ 251,6 258,7 @@ def model_batches_qs(
         descending order instead of ascending order
       * `exclude`: optional mapping of Django query terms to exclude by
       * `filter`: optional mapping of Django query terms to filter by
+      * `only`: optional sequence of field names for a Django query `.only()`
 
       Example iteration of a `Model` would look like:
 

          
@@ 289,8 297,18 @@ def model_batches_qs(
   qs0 = mgr.all()
   if exclude:
     qs0 = qs0.exclude(**exclude)
-  if filter:
-    qs0 = qs0.filter(**filter)
+  if exclude is not None:
+    if isinstance(exclude, Mapping):
+      qs0 = qs0.exclude(**exclude)
+    else:
+      qs0 = qs0.exclude(exclude)
+  if filter is not None:
+    if isinstance(filter, Mapping):
+      qs0 = qs0.filter(**filter)
+    else:
+      qs0 = qs0.filter(filter)
+  if only is not None:
+    qs0 = qs0.only(*only)
   qs = qs0.order_by(ordering)[:chunk_size]
   while True:
     key_list = list(qs.only(field_name).values_list(field_name, flat=True))

          
@@ 301,3 319,19 @@ def model_batches_qs(
     qs = qs0.filter(**{
         after_condition: end_key
     }).order_by(ordering)[:chunk_size]
+
+def model_instances(
+    model: Model,
+    field_name='pk',
+    only=None,
+    **mbqs_kw,
+) -> Iterable[Model]:
+  ''' A generator yielding Model instances.
+      This is a wrapper for `model_batches_qs` and accepts the same arguments.
+
+      Efficient behaviour requires the field to be indexed.
+      Correct behaviour requires the field values to be unique.
+  '''
+  return chain.from_iterable(
+      model_batches_qs(model, field_name=field_name, **mbqs_kw)
+  )

          
M pkg_tags +2 -1
@@ 34,7 34,7 @@ cs.dateutils ok_revision="8d0cd6ae028e8f
 cs.debug pypi.release="20241005" ok_revision=fb49ab3bf51884d4c570c46375fe17fba3a742e9 features={"20230613":["patch_builtins"],"20241005":["CS_DEBUG_TRACE","abrk","log_via_print"]}
 cs.deco pypi.release="20250103" ok_revision="2eb65f1a9304f2443a2433cb3b376306bb12b695" features={"20220905":["ALL","default_params"],"20221106":["promote"],"20221106.1":["promote_optional"],"20230210":["promote_dot_as_typename"],"20230212":["Promotable"],"20241003":["uses_cmd_option"],"20241109":["uses_doit","uses_force","uses_quiet","uses_verbose"]}
 cs.delta ok_revision=b5ee8df7f2f919ad4881022e26959dfc74653967 pypi.release="20240622"
-cs.djutils ok_revision=b5f554963d8aec8658f6704e0f1c0f9832f3d71f features={"20241110":["DjangoBaseCommand"],"20241119":["DjangoSpecificSubCommand"],"20241222":["options_settings"],"20250111":["model_batches_qs"]} pypi.release="20250113.2"
+cs.djutils ok_revision=a3397b32d6df1d7c07c9530c905da6dfc303b8fa features={"20241110":["DjangoBaseCommand"],"20241119":["DjangoSpecificSubCommand"],"20241222":["options_settings"],"20250111":["model_batches_qs"],"20250213":["model_instances"]} pypi.release="20250219"
 cs.dockerutils ok_revision=f846ece6e9f9353b443df14f81512ae6438681e6 pypi.release="20240519" features={"20240305":["DockerRun__network"]}
 cs.ebooks ok_revision="3379fcbf1eb008df3cd38e9afd40f1d0db9afbd4" pypi.release="20241007" features={"20230110":["dedrm"],"20240201.4":["cbz","kobo","pdf"]}
 cs.edit pypi.release="20220429" ok_revision="515bc888f37576690b3eb672b69e1a573a0d8b65" features={"20220429":["edit_obj"]}

          
@@ 103,6 103,7 @@ cs.threads ok_revision=b3fb539982274055e
 cs.timeseries ok_revision="795e4c4be59054f58d2c7e32e8a24f652c51cfb8" pypi.release="20240316"
 cs.timeutils
 cs.typingutils ok_revision="3654853d2facea604cfef62988be6ba53a262166" features={"20230331":["subtype"]} pypi.release="20230331"
+cs.units
 cs.upd ok_revision="08f7d62b98d3c1b2532c4b8c9199a0c756914c6c" pypi.release="20240630" features={"20230212":["with_upd_proxy"],"20230217":["Upd_MultiOpenMixin"],"20240216":["without"],"20240412":["without"]}
 cs.urlutils ok_revision="7eeaa1d7ef5d4e82b88e9d614e8646d80a5344e7" pypi.release="20231129"
 cs.x ok_revision="68e0c6027afe808d188c53ab5ef6c9285f29067b" pypi.release="20240630" features={"20231129":["via_logger"]}

          
A => release/cs.djutils-20250213/CHANGES.txt +1 -0
@@ 0,0 1,1 @@ 
+lib/python/cs/djutils.py: cs.djutils: new model_instances() wrapper for model_batches_qs() returning an iterable of Model instance

          
A => release/cs.djutils-20250213/SUMMARY.txt +1 -0
@@ 0,0 1,1 @@ 
+New model_instances() wrapper for model_batches_qs() returning an iterable of Model instances.

          
A => release/cs.djutils-20250219/CHANGES.txt +2 -0
@@ 0,0 1,2 @@ 
+lib/python/cs/djutils.py: cs.djutils: model_batches_qs: accept a nonmapping for exclude= or filter= eg a Q() function
+lib/python/cs/djutils.py: cs.djutils: model_batches_qs: new optional only= parameter

          
A => release/cs.djutils-20250219/SUMMARY.txt +2 -0
@@ 0,0 1,2 @@ 
+model_batches_qs: accept a nonmapping for exclude= or filter= eg a Q() function.
+model_batches_qs: new optional only= parameter.