plain 0.77.0__py3-none-any.whl → 0.78.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of plain might be problematic. Click here for more details.

plain/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.78.1](https://github.com/dropseed/plain/releases/plain@0.78.1) (2025-10-17)
4
+
5
+ ### What's changed
6
+
7
+ - Fixed job worker logging by using `getLogger` instead of directly instantiating `Logger` for the plain logger ([dd675666b9](https://github.com/dropseed/plain/commit/dd675666b9))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
13
+ ## [0.78.0](https://github.com/dropseed/plain/releases/plain@0.78.0) (2025-10-17)
14
+
15
+ ### What's changed
16
+
17
+ - Chores have been refactored to use abstract base classes instead of decorated functions ([c4466d3c60](https://github.com/dropseed/plain/commit/c4466d3c60))
18
+ - Added `SHELL_IMPORT` setting to customize what gets automatically imported in `plain shell` ([9055f59c08](https://github.com/dropseed/plain/commit/9055f59c08))
19
+ - Views that return `None` now raise `Http404` instead of returning `ResponseNotFound` ([5bb60016eb](https://github.com/dropseed/plain/commit/5bb60016eb))
20
+ - The `plain chores list` command output formatting now matches the `plain jobs list` format ([4b6881a49e](https://github.com/dropseed/plain/commit/4b6881a49e))
21
+
22
+ ### Upgrade instructions
23
+
24
+ - Update any chores from decorated functions to class-based chores:
25
+
26
+ ```python
27
+ # Before:
28
+ @register_chore("group")
29
+ def chore_name():
30
+ """Description"""
31
+ return "Done!"
32
+
33
+ # After:
34
+ from plain.chores import Chore, register_chore
35
+
36
+ @register_chore
37
+ class ChoreName(Chore):
38
+ """Description"""
39
+
40
+ def run(self):
41
+ return "Done!"
42
+ ```
43
+
44
+ - Import `Chore` base class from `plain.chores` when creating new chores
45
+
3
46
  ## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
4
47
 
5
48
  ### What's changed
plain/chores/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Overview
10
10
 
11
- Chores are registered functions that can be run at any time to keep an app in a desirable state.
11
+ Chores are registered classes that can be run at any time to keep an app in a desirable state.
12
12
 
13
13
  ![](https://assets.plainframework.com/docs/plain-chores-run.png)
14
14
 
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
16
16
 
17
17
  ```python
18
18
  # plain/sessions/chores.py
19
- from plain.chores import register_chore
19
+ from plain.chores import Chore, register_chore
20
20
  from plain.utils import timezone
21
21
 
22
22
  from .models import Session
23
23
 
24
24
 
25
- @register_chore("sessions")
26
- def clear_expired():
27
- """
28
- Delete sessions that have expired.
29
- """
30
- result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
- return f"{result[0]} expired sessions deleted"
25
+ @register_chore
26
+ class ClearExpired(Chore):
27
+ """Delete sessions that have expired."""
28
+
29
+ def run(self):
30
+ result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
+ return f"{result[0]} expired sessions deleted"
32
32
  ```
33
33
 
34
34
  ## Running chores
@@ -44,27 +44,29 @@ There are several ways you can run chores depending on your needs:
44
44
 
45
45
  ## Writing chores
46
46
 
47
- A chore is a function decorated with `@register_chore(chore_group_name)`. It can write a description as a docstring, and it can return a value that will be printed when the chore is run.
47
+ A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
48
48
 
49
49
  ```python
50
50
  # app/chores.py
51
- from plain.chores import register_chore
51
+ from plain.chores import Chore, register_chore
52
+
52
53
 
54
+ @register_chore
55
+ class ChoreName(Chore):
56
+ """A chore description can go here."""
53
57
 
54
- @register_chore("app")
55
- def chore_name():
56
- """
57
- A chore description can go here
58
- """
59
- # Do a thing!
60
- return "We did it!"
58
+ def run(self):
59
+ # Do a thing!
60
+ return "We did it!"
61
61
  ```
62
62
 
63
+ ### Best practices
64
+
63
65
  A good chore is:
64
66
 
65
- - Fast
66
- - Idempotent
67
- - Recurring
68
- - Stateless
67
+ - **Fast** - Should complete quickly, not block for long periods
68
+ - **Idempotent** - Safe to run multiple times without side effects
69
+ - **Recurring** - Designed to run regularly, not just once
70
+ - **Stateless** - Doesn't rely on external state between runs
69
71
 
70
72
  If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
plain/chores/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .core import Chore
1
2
  from .registry import register_chore
2
3
 
3
- __all__ = ["register_chore"]
4
+ __all__ = ["Chore", "register_chore"]
plain/chores/core.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Chore(ABC):
8
+ """
9
+ Abstract base class for chores.
10
+
11
+ Subclasses must implement:
12
+ - run() method
13
+
14
+ Example:
15
+ @register_chore
16
+ class ClearExpired(Chore):
17
+ '''Delete sessions that have expired.'''
18
+
19
+ def run(self):
20
+ # ... implementation
21
+ return "10 sessions deleted"
22
+ """
23
+
24
+ @abstractmethod
25
+ def run(self) -> Any:
26
+ """Run the chore. Must be implemented by subclasses."""
27
+ pass
plain/chores/registry.py CHANGED
@@ -1,37 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- from types import FunctionType
4
- from typing import Any
5
-
6
3
  from plain.packages import packages_registry
7
4
 
8
-
9
- class Chore:
10
- def __init__(self, *, group: str, func: FunctionType):
11
- self.group = group
12
- self.func = func
13
- self.name = f"{group}.{func.__name__}"
14
- self.description = func.__doc__.strip() if func.__doc__ else ""
15
-
16
- def __str__(self) -> str:
17
- return self.name
18
-
19
- def run(self) -> Any:
20
- """
21
- Run the chore.
22
- """
23
- return self.func()
5
+ from .core import Chore
24
6
 
25
7
 
26
8
  class ChoresRegistry:
27
- def __init__(self):
28
- self._chores: dict[FunctionType, Chore] = {}
9
+ def __init__(self) -> None:
10
+ self._chores: dict[str, type[Chore]] = {}
29
11
 
30
- def register_chore(self, chore: Chore) -> None:
12
+ def register_chore(self, chore_class: type[Chore]) -> None:
31
13
  """
32
- Register a chore with the specified name.
14
+ Register a chore class.
15
+
16
+ Args:
17
+ chore_class: A Chore subclass to register
33
18
  """
34
- self._chores[chore.func] = chore
19
+ name = f"{chore_class.__module__}.{chore_class.__qualname__}"
20
+ self._chores[name] = chore_class
35
21
 
36
22
  def import_modules(self) -> None:
37
23
  """
@@ -39,9 +25,9 @@ class ChoresRegistry:
39
25
  """
40
26
  packages_registry.autodiscover_modules("chores", include_app=True)
41
27
 
42
- def get_chores(self) -> list[Chore]:
28
+ def get_chores(self) -> list[type[Chore]]:
43
29
  """
44
- Get all registered chores.
30
+ Get all registered chore classes.
45
31
  """
46
32
  return list(self._chores.values())
47
33
 
@@ -49,19 +35,15 @@ class ChoresRegistry:
49
35
  chores_registry = ChoresRegistry()
50
36
 
51
37
 
52
- def register_chore(group: str) -> Any:
38
+ def register_chore(cls: type[Chore]) -> type[Chore]:
53
39
  """
54
- Register a chore with a given group.
40
+ Decorator to register a chore class.
55
41
 
56
42
  Usage:
57
- @register_chore("clear_expired")
58
- def clear_expired():
59
- pass
43
+ @register_chore
44
+ class ClearExpired(Chore):
45
+ def run(self):
46
+ return "Done!"
60
47
  """
61
-
62
- def wrapper(func: FunctionType) -> FunctionType:
63
- chore = Chore(group=group, func=func)
64
- chores_registry.register_chore(chore)
65
- return func
66
-
67
- return wrapper
48
+ chores_registry.register_chore(cls)
49
+ return cls
plain/cli/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [Adding commands](#adding-commands)
7
+ - [Shell](#shell)
7
8
 
8
9
  ## Overview
9
10
 
@@ -39,3 +40,28 @@ An example command!
39
40
  ```
40
41
 
41
42
  Technically you can register a CLI from anywhere, but typically you will do it in either `app/cli.py` or a package's `<pkg>/cli.py`, as those modules will be autoloaded by Plain.
43
+
44
+ ## Shell
45
+
46
+ The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
47
+
48
+ ### SHELL_IMPORT
49
+
50
+ You can customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` to a module path in your settings:
51
+
52
+ ```python
53
+ # app/settings.py
54
+ SHELL_IMPORT = "app.shell"
55
+ ```
56
+
57
+ Then create that module with the objects you want available:
58
+
59
+ ```python
60
+ # app/shell.py
61
+ from app.projects.models import Project
62
+ from app.users.models import User
63
+
64
+ __all__ = ["Project", "User"]
65
+ ```
66
+
67
+ Now when you run `plain shell`, those objects will be automatically imported and available.
plain/cli/chores.py CHANGED
@@ -13,11 +13,10 @@ def chores() -> None:
13
13
 
14
14
 
15
15
  @chores.command("list")
16
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
17
16
  @click.option(
18
17
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
19
18
  )
20
- def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
19
+ def list_chores(name: tuple[str, ...]) -> None:
21
20
  """
22
21
  List all registered chores.
23
22
  """
@@ -25,32 +24,33 @@ def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
25
24
 
26
25
  chores_registry.import_modules()
27
26
 
28
- if group or name:
29
- chores = [
30
- chore
31
- for chore in chores_registry.get_chores()
32
- if (chore.group in group or not group) and (chore.name in name or not name)
27
+ chore_classes = chores_registry.get_chores()
28
+
29
+ if name:
30
+ chore_classes = [
31
+ chore_class
32
+ for chore_class in chore_classes
33
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
33
34
  ]
34
- else:
35
- chores = chores_registry.get_chores()
36
35
 
37
- for chore in chores:
38
- click.secho(f"{chore}", bold=True, nl=False)
39
- if chore.description:
40
- click.echo(f": {chore.description}")
36
+ for chore_class in chore_classes:
37
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
38
+ click.secho(f"{chore_name}", bold=True, nl=False)
39
+ description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
40
+ if description:
41
+ click.secho(f": {description}", dim=True)
41
42
  else:
42
43
  click.echo("")
43
44
 
44
45
 
45
46
  @chores.command("run")
46
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
47
47
  @click.option(
48
48
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
49
49
  )
50
50
  @click.option(
51
51
  "--dry-run", is_flag=True, help="Show what would be done without executing"
52
52
  )
53
- def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) -> None:
53
+ def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
54
54
  """
55
55
  Run the specified chores.
56
56
  """
@@ -58,28 +58,30 @@ def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) ->
58
58
 
59
59
  chores_registry.import_modules()
60
60
 
61
- if group or name:
62
- chores = [
63
- chore
64
- for chore in chores_registry.get_chores()
65
- if (chore.group in group or not group) and (chore.name in name or not name)
61
+ chore_classes = chores_registry.get_chores()
62
+
63
+ if name:
64
+ chore_classes = [
65
+ chore_class
66
+ for chore_class in chore_classes
67
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
66
68
  ]
67
- else:
68
- chores = chores_registry.get_chores()
69
69
 
70
70
  chores_failed = []
71
71
 
72
- for chore in chores:
73
- click.echo(f"{chore.name}:", nl=False)
72
+ for chore_class in chore_classes:
73
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
74
+ click.echo(f"{chore_name}:", nl=False)
74
75
  if dry_run:
75
76
  click.secho(" (dry run)", fg="yellow", nl=False)
76
77
  else:
77
78
  try:
79
+ chore = chore_class()
78
80
  result = chore.run()
79
81
  except Exception:
80
82
  click.secho(" Failed", fg="red")
81
- chores_failed.append(chore)
82
- logger.exception(f"Error running chore {chore.name}")
83
+ chores_failed.append(chore_class)
84
+ logger.exception(f"Error running chore {chore_name}")
83
85
  continue
84
86
 
85
87
  if result is None:
plain/logs/configure.py CHANGED
@@ -7,13 +7,12 @@ def configure_logging(
7
7
  *, plain_log_level: int | str, app_log_level: int | str, app_log_format: str
8
8
  ) -> None:
9
9
  # Create and configure the plain logger (uses standard Logger, not AppLogger)
10
- plain_logger = logging.Logger("plain")
10
+ plain_logger = logging.getLogger("plain")
11
11
  plain_logger.setLevel(plain_log_level)
12
12
  plain_handler = logging.StreamHandler()
13
13
  plain_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
14
14
  plain_logger.addHandler(plain_handler)
15
15
  plain_logger.propagate = False
16
- logging.root.manager.loggerDict["plain"] = plain_logger
17
16
 
18
17
  # Configure the existing app_logger
19
18
  from .loggers import app_logger
plain/views/base.py CHANGED
@@ -12,12 +12,12 @@ from opentelemetry.semconv._incubating.attributes.code_attributes import (
12
12
  )
13
13
 
14
14
  from plain.http import (
15
+ Http404,
15
16
  JsonResponse,
16
17
  Request,
17
18
  Response,
18
19
  ResponseBase,
19
20
  ResponseNotAllowed,
20
- ResponseNotFound,
21
21
  )
22
22
  from plain.utils.decorators import classonlymethod
23
23
 
@@ -110,8 +110,7 @@ class View:
110
110
  return Response(status_code=value)
111
111
 
112
112
  if value is None:
113
- # TODO raise 404 instead?
114
- return ResponseNotFound()
113
+ raise Http404
115
114
 
116
115
  status_code = 200
117
116
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.77.0
3
+ Version: 0.78.1
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,5 @@
1
1
  plain/AGENTS.md,sha256=As6EFSWWHJ9lYIxb2LMRqNRteH45SRs7a_VFslzF53M,1046
2
- plain/CHANGELOG.md,sha256=A05-ohzcEeeRrrxOXNt1FLH8F88kgS-_IjmwsHR1g9Y,26688
2
+ plain/CHANGELOG.md,sha256=KM-gzR9XLYBO7517RspHvAJ4TP3izeeIg5N6KDiAoEU,28258
3
3
  plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
@@ -16,14 +16,15 @@ plain/assets/finders.py,sha256=j9sZ2LAJp55hdJ_e1lFevzwicNEMUtv6rLtQgiIkDgY,1589
16
16
  plain/assets/fingerprints.py,sha256=UPJwLmzzqXBp7FPk9XnMkcTps2UW9SnTAv0hGieUnMo,1458
17
17
  plain/assets/urls.py,sha256=Qe0ctXYAQjAlLIKuX6JeLVBOFqzJxggR0RSGkRXmX78,1173
18
18
  plain/assets/views.py,sha256=Zs_wyguh-WljOgW3DiPfpspTVOKC6z3bRSu01-SDcoU,9877
19
- plain/chores/README.md,sha256=ashFQ4kc1h81DqFmOXiGcrmzvJQQMg4ZWdUIFDcJah0,2048
20
- plain/chores/__init__.py,sha256=r9TXtQCH-VbvfnIJ5F8FxgQC35GRWFOfmMZN3q9niLg,67
21
- plain/chores/registry.py,sha256=kKhN7z0XcgGCMnpx9bVWI1klUUl-yAEfcDaLnsSYs6c,1589
22
- plain/cli/README.md,sha256=5C7vsH0ISxu7q5H6buC25MBOILkI_rzdySitswpQgJw,1032
19
+ plain/chores/README.md,sha256=7Dv5MCyqPaohQxAk74HRzKiTvt_yR-IOhbu7R5bdz6M,2437
20
+ plain/chores/__init__.py,sha256=wEdt-oAKS1kz7Ln-puhcCt8XGNdfCP4S3wHESaPU8nI,100
21
+ plain/chores/core.py,sha256=BxsCSJDQvMjYsAH4QhEoW9ZUEAUIwJgTYyHovPGSLjk,578
22
+ plain/chores/registry.py,sha256=IRpx3f6Z1qlqcEpHTe6O6JocNmaplLu7BVOqGrafSXU,1221
23
+ plain/cli/README.md,sha256=Wn6o0fVL-SRMztTTHO71P4QzOvntgO-MqjbRrsk3WAw,1661
23
24
  plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
24
25
  plain/cli/build.py,sha256=Jg5LMbmXHhCXZIYj_Gcjou3yGiEWw8wpbOGGsdk-wZw,3203
25
26
  plain/cli/changelog.py,sha256=yCY887PT_D2viLz9f-uyu07Znqiv2-NEyCBqquNIukw,3590
26
- plain/cli/chores.py,sha256=aaDVTpwBEmyQ4r_YeZ6U7Fw9UOIq5d7okwpq9HdfRbA,2521
27
+ plain/cli/chores.py,sha256=FzQat9ofEZTlCiszGvV3deJwx8G0c-fBhn1mI560Iec,2568
27
28
  plain/cli/core.py,sha256=nL-a7zPtEBIa_XV-VOd9lqEUqmQAhlzsdHNwEmxNkWE,4285
28
29
  plain/cli/docs.py,sha256=PU3v7Z7qgYFG-bClpuDg4JeWwC8uvLYX3ovkQDMseVs,1146
29
30
  plain/cli/formatting.py,sha256=e1doTFalAM11bD_Cvqeu6sTap81JrQcB-4kMjZzAHmY,2737
@@ -83,7 +84,7 @@ plain/internal/middleware/https.py,sha256=mB2fxOisij3HFL-N99Pa3gd3fI7ca49L4fxoQ9
83
84
  plain/internal/middleware/slash.py,sha256=VwiMZ__L8gMT0zrpwCAoLEJy2-GhR8Czc6kQnmu8K3w,3195
84
85
  plain/logs/README.md,sha256=rzOHfngjizLgXL21g0svC1Cdya2s_gBA_E-IljtHpy8,4069
85
86
  plain/logs/__init__.py,sha256=gFVMcNn5D6z0JrvUJgGsOeYj1NKNtEXhw0MvPDtkN6w,58
86
- plain/logs/configure.py,sha256=JWSTkQ1i1yu4sB-cXzxJwx2Pyax7sUEoOsTN_mj4te0,1363
87
+ plain/logs/configure.py,sha256=z2c_wBHPVRSSkKVBUEwYCUvQi-Kf449n-SysC65tjeo,1306
87
88
  plain/logs/debug.py,sha256=x2M4UcVexnn_5G0WCJd5iX6RFAGqEiRjE81dpMOqBBU,1336
88
89
  plain/logs/formatters.py,sha256=1yNeA7RGa9Ao1TZRu20idQtPOIg43PZogFR-B5n7VnA,2503
89
90
  plain/logs/loggers.py,sha256=FcvfVHbSLarY5IVsEoQcvNxeJkkoBz69a2a39rnyQ-4,5249
@@ -181,15 +182,15 @@ plain/utils/timezone.py,sha256=M_I5yvs9NsHbtNBPJgHErvWw9vatzx4M96tRQs5gS3g,6823
181
182
  plain/utils/tree.py,sha256=rj_JpZ2kVD3UExWoKnsRdVCoRjvzkuVOONcHzREjSyw,4766
182
183
  plain/views/README.md,sha256=6mcoSQp60n8qgoIMNDQr29WThpi-NCj8EMqxPNwWpiE,7189
183
184
  plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
184
- plain/views/base.py,sha256=fk9zAY5BMVBeM45dWL7A9BMTdUi6eTFMeVDd5kBVdv8,4478
185
+ plain/views/base.py,sha256=yWh6S68PsYcH1dvRdibQIanBYkjo2iJ8IAbR2PTWQrk,4419
185
186
  plain/views/errors.py,sha256=tHD7MNnZcMyiQ46RMAnX1Ne3Zbbkr1zAiVfJyaaLtSQ,1447
186
187
  plain/views/exceptions.py,sha256=-YKH1Jd9Zm_yXiz797PVjJB6VWaPCTXClHIUkG2fq78,198
187
188
  plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
188
189
  plain/views/objects.py,sha256=5y0PoPPo07dQTTcJ_9kZcx0iI1O7regsooAIK4VqXQ0,5579
189
190
  plain/views/redirect.py,sha256=mIpSAFcaEyeLDyv4Fr6g_ektduG4Wfa6s6L-rkdazmM,2154
190
191
  plain/views/templates.py,sha256=9LgDMCv4C7JzLiyw8jHF-i4350ukwgixC_9y4faEGu0,1885
191
- plain-0.77.0.dist-info/METADATA,sha256=EXhUsvn36qZfmmMwZ42LlEhEeKcH0k5YXo2WmoRCXss,4516
192
- plain-0.77.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
- plain-0.77.0.dist-info/entry_points.txt,sha256=wvMzY-iREvfqRgyLm77htPp4j_8CQslLoZA15_AnNo8,171
194
- plain-0.77.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
- plain-0.77.0.dist-info/RECORD,,
192
+ plain-0.78.1.dist-info/METADATA,sha256=r3Abxj0lYj5yV39kplR3XPl6Dv-V2Xd5H_d5jWEToVw,4516
193
+ plain-0.78.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
194
+ plain-0.78.1.dist-info/entry_points.txt,sha256=wvMzY-iREvfqRgyLm77htPp4j_8CQslLoZA15_AnNo8,171
195
+ plain-0.78.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
196
+ plain-0.78.1.dist-info/RECORD,,
File without changes