Skip to main content

simple_module_news

A self-contained news archive for SimpleModule hosts.

An article is a document this module owns outright: its title, slug, body, approval workflow, revisions, SEO and public URL are all columns and routes here. Install it, migrate it, and a host has a working archive — no other content module required.

It did not start that way. An article used to be a simple_module_pagebuilder page, with this module contributing only a category and a display date beside it. That made news uninstallable without its neighbour, made every listing a cross-module join, and left article rows that could be orphaned by a deletion news never saw. See Design note: what the split changed below.

Articles serve at /news/{slug} (the public_route_prefix setting), so an article is distinguishable from a contact page in a URL, a log line and an analytics report. The admin console is at /admin/news, not /news: one prefix cannot be both a reader-facing URL and a permission-gated console.

Full documentation lives in docs/: user guide (the admin screens and what readers see), API reference (every route, permission and error), operations (install, settings, scheduler, tenancy, troubleshooting) and architecture.

Installation

pip install simple_module_news

Nothing else is required. For an in-repo checkout, resolve it from the workspace:

dependencies = ["simple_module_news"]

[tool.uv.sources.simple_module_news]
workspace = true

Then run make migrate — the module's first revision is labelled news, so it can be removed on its own with alembic downgrade news@base.

Optional: pagebuilder

pip install "simple_module_news[pagebuilder]"

Where a host runs simple_module_pagebuilder too, two conveniences light up:

  • the News feed block joins that module's palette, so a page can list live articles;
  • the admin search screen gains Pages and Media sections alongside articles, with "see all" links into that module's screens.

Neither is required, and nothing outside news/integrations/pagebuilder.py imports the package — every import there is deferred and guarded, so on a host without it available() is False and the extra sections are simply absent. test_module.py spawns a subprocess to prove that importing news never imports pagebuilder.

One caveat, on the frontend. A bundler resolves imports statically, and that includes dynamic ones — Rollup errors on an unresolvable import() exactly as it does on a static import, so the try/catch around it never runs and no amount of guarding inside this module makes the dependency conditional. So news/puck-blocks.ts — and the NewsFeed component it pulls in — genuinely do import @simple-module-py/pagebuilder at build time. They are the only two files in this module's frontend that do, and @simple-module-py/pagebuilder is declared an optional peer dependency so npm does not demand it. A host that installs news without pagebuilder should exclude puck-blocks.ts from its block glob (see host/client_app/blocks.ts); there is no feed block to register without the palette it registers into. Every other screen — the list, both editors, the public viewer — builds and runs with the package absent.

The real fix belongs to the framework rather than here: gen-pages already emits the page globs, and could emit the block imports too, skipping a module whose optional peers do not resolve — once, for every host and every module.

Usage

Go to News in the sidebar. "New article" creates the article and drops you into its body canvas — this module's own block editor — to write it. Headline, URL, category, tags, byline, display date and feed behaviour are edited on the article screen; the list edits category and date inline. A live article whose draft has moved on since it was published is marked Unpublished edits in the list, because "Published" on its own is a true sentence about a document nobody is looking at.

The article screen also carries Review (submit, approve, send back with a note), Schedule, Search & sharing and History. Binned articles are under News → Trash, where they can be restored or removed for good.

The two screens split by posture, not by importance: the canvas wants the whole viewport and autosaves on a timer, and the article screen is a column of short fields saved by one button. The headline lives on the article screen rather than on the canvas because moving it also moves the URL, and a rename is a decision — not something that should happen on a debounce.

The reader's side

/news/ is the archive: published articles, newest first, twelve to a page, with /news/category/{slug}, /news/tag/{slug} and /news/author/{slug} narrowing it, ?q= searching within whichever of those you are on, and /news/feed.xml carrying the recent run as RSS.

Search narrows the page it was typed on rather than replacing it, which is why there is no /news/search: a reader on a category page is standing somewhere that already promises a scope, and answering a question they did not ask by silently widening to the whole archive is worse than answering the one they did. Results are noindex, follow — the input space is unbounded, so one indexed ?q= invites a crawler to enumerate query strings forever, while the links out of a result page are the real documents and worth following.

It searches headline, slug, excerpt and tags, and deliberately not the body. Not for cost: the body column the admin's search scans is draft_data, so a public search over it would answer for text nobody has published — a phrase living only in an unpublished edit would surface the article and tell an outsider that the edit exists.

An author becomes an address through the same slug rule as everything else, so two spellings of one byline (A. Subash, A Subash) share one page rather than one of them disappearing: that is nearly always one person entered inconsistently, and two genuinely different people are an editorial fix that costs nothing. A byline with no ASCII to fold onto has no address at all and renders as plain text — a missing feature, chosen over the slugifier's fallback collapsing unrelated writers onto a single archive claiming to be each of them.

None of that existed until recently, and its absence was the hole in the middle of the split. The public router had exactly one route — /{slug} — so a reader could open an article they already had a link to and nothing else: /news/ answered 404, the bare /news bounced an anonymous visitor to the sign-in screen, and the only browsing surface in the codebase was the News feed block, which registers into pagebuilder's palette. A module that could be installed alone could not be read alone.

Paging is real links rather than "load more", because the pager is how a crawler reaches everything past the first page. A page beyond the end is a 404 rather than an empty document, so a crawler guessing ?page=900 is told there is nothing there instead of being handed something valid-looking to index.

The first page of a narrowing that matches nothing is the opposite case, and the two are easy to confuse. An unknown tag, a byline nobody wrote under, or a search with no hits all render an empty archive with a way back: the address is meaningful and simply holds nothing today — a tag can be removed from the last article carrying it, and a URL published while it existed should say "nothing here now", not "never existed". Page 900 of that same empty tag is still a 404, because there is no reading under which it has one.

Metadata a crawler can read

Articles and archive pages carry a server-rendered <head> — <title>, the description, og:*, twitter:*, the canonical link, robots and JSON-LD — written into the document before it leaves the server.

They have to be, because the host renders one Inertia shell for every screen: a fixed <title> and an {% inertia_head %} slot that only fills under SSR, which nothing configures. PublicArticle.tsx composes all of those tags through Inertia's <Head>, and every one of them existed only after the bundle ran. Google executes JavaScript; Slack, X, LinkedIn, Facebook and most feed tooling do not — so an article link previewed as "SimpleModule" with no description and no image, and an article marked index_in_search=false was indexed anyway because the noindex never reached the crawler either.

endpoints/public/_head.py writes them in on the way out. The tags are duplicated with the client-side ones on purpose: one copy serves the crawler that never runs the script, the other serves the reader who arrived through the SPA and never reloaded, and neither covers the other's case. If the host ever adopts SSR, {% inertia_head %} starts emitting the same tags and that module should be deleted rather than made cleverer.

Permissions

Permission Grants
news.view see the admin list
news.edit create, write and edit articles; submit for review
news.publish publish, unpublish, approve, reject, purge, hard delete

news.publish is separate on purpose. Publishing used to require pagebuilder.publish, because the write landed on one of its pages and news was not entitled to route an author past that module's editor → publisher separation. Owning the content means owning the separation: folding it into news.edit would hand every author a way past a review step a host may want.

API

Route Access
GET /news/ anonymous; the archive, paged
GET /news/category/{slug} anonymous
GET /news/tag/{slug} anonymous
GET /news/{slug} anonymous; published articles only
GET /{locale}/news/{slug} anonymous; one mount per non-default content locale
GET /news/feed.xml anonymous; RSS 2.0, most recent 20
GET /news/sitemap.xml anonymous
GET /api/news/articles?limit&offset&category&q&status&in_feed&locale&translation_group&undated_first anonymous; published only
GET /api/news/categories anonymous; published only
GET /api/news/articles/{id}/tags anonymous; published only
POST /api/news/articles news.edit
POST /api/news/articles/{id}/translations news.edit
PUT /api/news/articles/{id} news.edit
DELETE /api/news/articles/{id} news.edit + news.publish
GET /api/news/articles/{id}/detail news.edit
PUT /api/news/articles/{id}/body news.edit
GET /api/news/articles/{id}/revisions news.edit
POST /api/news/articles/{id}/revisions/{rev}/restore news.edit
POST /api/news/articles/{id}/submit news.edit
POST /api/news/articles/{id}/trash | /restore news.edit
POST /api/news/articles/{id}/publish | /unpublish news.edit + news.publish
POST /api/news/articles/{id}/schedule news.edit + news.publish
POST /api/news/articles/{id}/approve | /reject news.edit + news.publish
DELETE /api/news/articles/{id}/purge news.edit + news.publish

Reads are anonymous because the feed block runs on public pages. Listing is ordered pinned first, then newest, with undated articles last; undated_first=true flips the undated to the front, which is what the admin list uses so work in progress is not buried on the last page. An editor additionally sees drafts. Each item carries status, which the admin list renders as a badge; without news.edit it is always published.

An editor's items also carry has_unpublished_changes — the article is live and its draft has since moved on, so what you are looking at is not what readers are served. Only for a caller who may see drafts: it is a statement about work in progress, and the column behind it is not even selected for anyone else. It is compared, not inferred from a timestamp, and it is false for a draft holding an old snapshot — there is nothing live there to diverge from.

POST /articles creates the article in one insert. Omit slug and the server derives one from the title and takes the first free variant — my-title, then my-title-2; send one and it is used verbatim, with a collision reported as a 409 rather than silently renamed. Trashed articles keep their slugs claimed.

PUT /articles/{id} is a partial update covering the listing metadata, the identity (title, slug) and the SEO fields. A field you omit is left alone. Sending published_at as an explicit null is different from omitting it — that undates the article, which is a real state rather than an error. Changing slug records a redirect, so the old address keeps working.

PUT /articles/{id}/body is the canvas autosave, and deliberately cannot reach the slug or the status: it fires on a timer rather than on a person pressing something.

Scheduling

POST /articles/{id}/schedule sets publish_at and unpublish_at: a draft goes live by itself at the first, a published article comes down by itself at the second. Both are optional and both are three-valued — an omitted field is left alone, an explicit null cancels. Behind news.publish, because a schedule is a publication decision that happens to be about the future.

An in-process loop (scheduler_enabled, every scheduler_interval_seconds) calls ArticlesService.process_due. The query is <= now rather than "since the last tick", so a process that was asleep catches up rather than losing the window; each timestamp is cleared when acted on, so a tick cannot republish the same article forever.

Running more than one of them

The loop starts in every process that boots the module, so uvicorn -w 4, gunicorn with workers and a Deployment with replicas > 1 all run several. That is safe: before touching a due article, process_due claims it with one conditional statement whose own effect takes it out of the due set —

UPDATE news_article SET publish_at = NULL
 WHERE id = :id AND deleted_at IS NULL AND status = 'draft'
   AND publish_at IS NOT NULL AND publish_at <= :now

— and acts only if that updated a row. The check and the claim are one statement, so the database decides the winner: whichever replica runs second meets a row that no longer matches and moves on. One PUBLISH revision row per article, and no flip-flop on the way down. An external worker driving process_due alongside the loop is safe for the same reason.

Row claiming rather than SELECT … FOR UPDATE SKIP LOCKED, which is Postgres-only and would do nothing on SQLite, this repo's local-dev default; and rather than a lease table, which elects one scheduler for the whole app and buys a migration, a clock comparison and a window after a leaseholder dies in which nothing publishes. A claim here lives in the tick's transaction, so a process that dies mid-flip releases it and the article is simply due again. See news/content/_claims.py.

What it does not cover: N replicas still each poll the database every scheduler_interval_seconds, and each still reads its own clock — an article goes live when the first replica to think it due acts, so a badly skewed clock moves that by the skew, exactly as the poll interval already does. Where either matters, set scheduler_enabled false everywhere and drive process_due from one place — a cron job, a k8s CronJob, a dedicated worker. Set it on the Settings screen or with scripts/set_setting.py news scheduler_enabled false, never an environment variable, which this module does not read.

These are deliberately not published_at, which is the display date below and may perfectly reasonably be in the past. The article screen said "a future date lists this as scheduled" for a while, which read as a promise nothing kept.

published_at is a display date, not a timestamp. Whatever instant you send, the day is taken as you wrote it and stored as midnight UTC — sending 2026-02-01T23:00:00-06:00 records the 1st, not the 2nd. It is also independent of status: an article can be published and undated, or dated and still a draft.

Multilingual articles

Every article carries its own language (locale), fixed for its lifetime, and shares a translation_group with its translations. The languages on offer are pagebuilder's content_locales (pages and articles share one list; without pagebuilder the site is monolingual) — see docs/operations.md. Off by default: with one content locale every article URL is exactly what it was.

Articles in the default language keep /news/{slug}; every other language is prefixed, /de/news/{slug}. ArticleRead.url carries the prefix, so the admin list, the feed block and the slug news claims from pagebuilder all agree on one address. /{default}/news/{slug} permanently redirects to the bare form.

?locale=de narrows a listing to one language — what a feed block on a German page passes, so a German list never shows an English card. The block reads the surrounding page's language rather than offering it as a field: a feed set to one language on a page written in another is a mistake nothing would catch. The admin list leaves it unset and shows every language, badged per row.

POST /articles/{id}/translations starts the same story in another language: one request, one transaction, creating both the translated page and the sidecar row. Category, byline, date, pin and feed membership are copied from the source rather than asked for again — they are facts about the story, not about the language it is told in. The translation starts as a draft.

?translation_group=… lists one article and its counterparts, which is what the editor's language switcher shows. It goes through the ordinary listing rather than a route of its own, so it inherits the same visibility rule: a reader without news.edit sees the published translations only.

Anonymous listings carry Cache-Control: public, max-age=60, because the feed block runs on every public page that holds one. An editor's listing includes drafts and so is private, no-store. The article viewer sends an ETag, Cache-Control: public, max-age=300, stale-while-revalidate=60, and Content-Security-Policy when public_csp is set.

The console's language

Separate from the article's. The section above is about what the site publishes; this is about what the console speaks, and the two are configured in different places — content_locales for one, the host's SM_I18N_SUPPORTED_LOCALES for the other.

Every console string lives in news/locales/en.json, registered by NewsModule.locale_dirs() under the news namespace, so a key reads news.<section>.<name> in the merged catalogue the host ships to the browser. Add a language by adding news/locales/<tag>.json with the same shape; the framework's SM013–SM016 diagnostics report a file that has drifted from the default.

The frontend derives its key tree from that JSON at compile time — news/utils/i18n.ts — rather than from the framework's generated keys, which only carries the namespaces of modules that live in the framework's own monorepo. The upshot is that tsc rejects a key the catalogue does not define, and modules/news/news/utils/i18n.test.ts rejects an entry nothing references, a plural missing a form, and a placeholder nothing fills.

Two things stay literal on purpose. A block's defaultProps are the seed content a writer is given and are then saved into the document, so translating them at render time would rewrite what a reader is served. And Puck's block labels are catalogue keys in the config constant, resolved by localizeConfig inside the screen that mounts the editor — a module-scope config cannot call a hook, and puck-blocks.ts is imported before the app has rendered anything.

The feed block is localized by its neighbour. NewsFeed lands in pagebuilder's palette, not this module's, so the resolver is pagebuilder's localizeConfig when PageEditor mounts Puck — it walks the assembled config, registered blocks included, and resolves whatever keys it finds there. That works across the module boundary because the host merges every catalogue into one flat table and i18next runs with keySeparator: false, so news.feed.… is a whole key rather than a path into a namespace pagebuilder would have to know about. The pagebuilder extra carries a >=0.0.8 floor for exactly this: an older neighbour does not crash, it renders each label as its own dotted key.

Settings

Stored in the database and edited under Settings → News. There is no SM_NEWS_* environment variable: the settings class drops every env source, so a value can only come from the store or from the default below. Headless deployments write them with scripts/set_setting.py.

Fields marked ● are read once while the app boots — they decide which routes are mounted and what the auth layer exempts — so changing one needs a restart. The Settings screen says so next to the input.

Setting Default What it does
public_route_prefix ● /news where articles serve
public_base_url (derived from the request) origin for canonical URLs and the sitemap
site_name (unset) og:site_name
twitter_handle (unset) twitter:site
public_cache_max_age 300 shared-cache lifetime of an article
public_cache_swr 60 stale-while-revalidate seconds; 0 omits it
public_csp (unset) Content-Security-Policy on the article page
scheduler_enabled ● true run the in-process publish/unpublish loop — safe under replicas, which claim each due article; see Scheduling
scheduler_interval_seconds ● 30 how often it looks for due articles

The languages articles may be written in are not here. They are pagebuilder's content_locales, borrowed through news.integrations.locales so one site does not keep two lists that can disagree. Without pagebuilder news publishes in one language, at the unprefixed URL — the behaviour every monolingual site already has.

Design note: what the split changed

The sidecar was a real design with real reasons — it kept pagebuilder a generic CMS that knew nothing about news, and it meant articles inherited a mature editor, viewer and workflow for free. What it cost was independence, and three specific hazards that are now structurally impossible:

  • No orphans. news_articles.page_id was an unenforceable pointer into another module's table: the framework gives every module its own MetaData, so a cross-module ForeignKey could not resolve. A deleted page left a row behind, and SQLite reuses ids, so that row would re-attach to whatever page was created next and list one article's metadata against another's document. A PageDeleted subscription and a startup sweep existed only to contain this. Both are gone; an article's body is its own row.
  • No cross-module join. Every listing joined pagebuilder_pages and had to remember load_only to avoid dragging both block-JSON columns through the ORM per row. The listing is now a single-table scan over named columns.
  • A real permission boundary. Publishing borrowed pagebuilder.publish; it is news.publish now, on this module's own routes.

Two things news gained that it previously borrowed, and had to grow itself:

  • Its own viewer, carrying the ETag, cache, CSP, canonical tag and old-slug redirects the hand-off used to provide. test_public_address.py asserts each one, precisely because a second viewer is what the hand-off existed to avoid.
  • Its own sitemap, at {prefix}/sitemap.xml. Articles used to reach a crawler through pagebuilder's, via a claim news registered with it; without one of its own the whole archive would silently drop out of every index. It lists the archive index and every category and tag page as well as the articles — a sitemap of leaves says the articles exist but not that anything links them — and a taxonomy page only while at least one published, indexable, listed article in that language fills it. An empty one is a thin page, and a sitemap is a request to come and index.

One thing it deliberately did not grow: a clone of pagebuilder's widget catalogue. That catalogue builds pages — heroes, feature grids, site headers — and almost none of it belongs in a news story. The body canvas ships a prose palette instead; see The block palette below. A host that wants the full page-building set on an article can install pagebuilder, build the page there, and link to it.

The block palette

Grouped in the editor by what a writer is reaching for, which is not always what the block renders — Key points and Sources are both lists, and neither sits beside List, because someone wanting a summary box is not shopping for a list.

Group Blocks
Text Heading, Paragraph, List, Pull quote, Q&A
Set apart Key points, Callout, Definitions, Sources, Read next
Media Image, Gallery, Before / after, Video file, Audio clip, Embed
Data Table, Key figures, Code, Timeline
Layout Divider

The bar for adding one is that a newsroom already does this thing in a story and currently has to fake it with a paragraph — a summary box, a correction, a table of figures, a dated sequence, a list of sources. A block that only changes how something looks is a page-building widget wearing a different hat, and belongs in the other module.

A few consequences of that bar worth knowing:

  • Callout has a correction kind. A correction is an obligation a publication owes its readers, and one written as an ordinary paragraph is indistinguishable from the reporting it corrects.
  • Image carries a credit separately from its caption, because they are different obligations — a caption explains the picture, a credit says whose it is, and a publication that runs the second inside the first eventually runs a picture with neither.
  • Timeline takes its dates as free text. Reporting deals in "March 2024", "the following morning" and "some time before 2019"; a calendar control would force a writer to invent precision the reporting does not have.
  • Code is not syntax-highlighted and its language is a label for the reader, not a hint to a parser. Highlighting means shipping a highlighter, and these render on a host that may have installed nothing else.
  • Gallery is a grid, not a carousel — a carousel hides all but one image behind an interaction, costing a reader who is scrolling the pictures they were shown.
  • Divider offers an asterism (* * *) as well as a rule. They mean different things: a rule separates the article from something appended to it, an asterism marks a change of scene within one continuous piece.
  • Q&A and Definitions render as <dl> rather than as alternating paragraphs, because that is what they are — each question introduces the answer that follows it, and a screen reader announcing the pairing gives a listener the structure a sighted reader gets from the indent.
  • Before / after stays two frames side by side on a phone rather than stacking, and is not a drag-the-handle slider. A comparison a reader has to scroll between is one they have to hold in their head; a slider hides half of each frame behind an interaction the reader may never make.
  • Video file and Audio clip are for media the publication hosts itself. Embed is an <iframe> and so only speaks to services that publish a player; a reporter's own mp4 had nowhere to go before these existed. Both preload="metadata", because an article can carry several and a reader who scrolls past one should not have paid for it.

Every other block renders exactly what a writer typed into it. Read next queries the listing API instead, because a related-articles list hand-typed at publication is stale the moment the next article goes up and nobody returns to a three-month-old story to refresh it.

That makes it the one block that has to know which article it is inside — a "read next" offering the article you are reading is visibly broken. The viewer passes the current slug to <Render> as Puck metadata, and the block reads it from puck.metadata.currentSlug. That seam is deliberately general: it is where any future block that needs the article rather than its own props should look.

It renders its own list rather than reusing the card grid the NewsFeed block draws with, because that grid belongs to pagebuilder and this has to work without it.

Blocks whose content is a list — List, Key points, Q&A, Definitions, Sources, Timeline, Key figures, Gallery, Table — are edited as a textarea, one item per line, rather than through Puck's array field, so a writer pasting a list out of a document gets it in one action instead of clicking "add item" nine times. blocks/lines.ts is that parsing, in one place, so no two blocks can disagree about what a line means.

The palette also carries a Chart and a Contents (table of contents) block; docs/user-guide.md lists every block by group.

Known gaps

  • The article body still needs JavaScript. The metadata does not — see The reader's side — but the document itself is a block tree rendered by React, so the raw HTML carries the article's text only inside the Inertia data blob. A crawler that executes scripts reads it; one that does not sees the head and nothing else. Server-rendering the body would mean a Python implementation of all twenty-one blocks, or SSR in the host. Both are real projects; neither is this module's to start.

  • Images take a URL, not a picker. The media library belongs to pagebuilder, and the Image and Gallery blocks have to work without it. The single-URL fields now show what the address points at as it is typed, so a wrong one is caught in the panel rather than on the published page — but that is a preview, not a library, and the extra step remains.

    The obvious alternative does not work. This repo's host also runs file_storage, but every one of its routes sits behind RequiresPermission, there is no static mount over uploads/, and StoredFileOut carries no url at all. An article image served from it renders in the admin preview and 401s for every logged-out reader — which is exactly why pagebuilder mounts MediaFiles(StaticFiles) and adds an auth-exempt prefix for its own media, saying so in boot.py: "without this the page renders for an anonymous visitor but every image 302s to login." Proxying it through a news route would be worse than the gap, not a shortcut: the store is shared by every module with no notion of "public", so a GET /media/{file_id} would make any file anyone ever uploaded anonymously readable to whoever holds the UUID. The fix is a public-read capability in file_storage, which is the framework's to add.

  • The scheduler still polls per process. Publishing at the right moment is now safe with several replicas — each due article is taken with one conditional UPDATE, so two ticks landing together flip it once — but every replica still wakes on its own interval. Where that traffic is unwanted, turning scheduler_enabled off and driving process_due from one external worker remains the answer. That is now a preference rather than a safety requirement.

Development

uv sync --extra dev
uv run pytest

API-version contract

ModuleMeta.requires_framework declares which simple_module_core versions this module supports. Update the spec on each framework major bump after verifying compatibility.

Metadata

Release files for simple-module-news 0.0.11

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for simple-module-news 0.0.11
File Size Uploaded
simple_module_news-0.0.11.tar.gz 370.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for simple-module-news 0.0.11
File Interpreter ABI Platform
simple_module_news-0.0.11-py3-none-any.whl Python 3 none any Details

Total release size: 687.9 kB

Release files / simple_module_news-0.0.11.tar.gz

Download URL simple_module_news-0.0.11.tar.gz
Size 370.5 kB
Tags Source
SHA-256 checksum
How to use checksums
d897d6aa73fa466f297cbb26ab87b3cd7889c86118ee9976a6c4b2feebcc4a5e
BLAKE2b-256 checksum
How to use checksums
08a95c2cfd2879f7c148f64feba99564ebd774aa5d524d3b40f0a1e69c9c7ab2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / simple_module_news-0.0.11-py3-none-any.whl

Download URL simple_module_news-0.0.11-py3-none-any.whl
Size 317.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e64c0902192ed9aeaf1aa40d4d2362bfcf62b84a9404fb611fa8325bd577ca1a
BLAKE2b-256 checksum
How to use checksums
f663aaf00f375de2b85a19a4af4187ebbb49f862a9e95add95113ebcd5917544
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.0.11 This release

2 release files

0.0.10

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page