Compare commits

..

3 Commits

Author SHA1 Message Date
d99b61d8c0 Release 2.20.1 2026-03-13 17:06:27 +02:00
34d0b8cf73 fix: fallback to timestamp in case if crypto context is not available
Previous tests have been done in either secure (HTTPS) or loopback
(localhost) environments, so I missed the case, that crypto methods
might not be available

Fixes #159
2026-03-13 17:05:48 +02:00
cc082dbd6f feat: add dark theme support and increase contrast 2026-03-13 17:05:04 +02:00
119 changed files with 668 additions and 2461 deletions

View File

@@ -12,14 +12,6 @@ ahriman.application.handlers.add module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.archives module
--------------------------------------------
.. automodule:: ahriman.application.handlers.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.backup module ahriman.application.handlers.backup module
------------------------------------------ ------------------------------------------
@@ -84,14 +76,6 @@ ahriman.application.handlers.help module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.hold module
----------------------------------------
.. automodule:: ahriman.application.handlers.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.key\_import module ahriman.application.handlers.key\_import module
----------------------------------------------- -----------------------------------------------
@@ -108,14 +92,6 @@ ahriman.application.handlers.patch module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module ahriman.application.handlers.rebuild module
------------------------------------------- -------------------------------------------

View File

@@ -28,14 +28,6 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.alpm.pacman\_handle module
---------------------------------------
.. automodule:: ahriman.core.alpm.pacman_handle
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pkgbuild\_parser module ahriman.core.alpm.pkgbuild\_parser module
----------------------------------------- -----------------------------------------

View File

@@ -140,22 +140,6 @@ ahriman.core.database.migrations.m016\_archive module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m017\_pkgbuild module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m018\_package\_hold module
-----------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m018_package_hold
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -76,14 +76,6 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.formatters.pkgbuild\_printer module
------------------------------------------------
.. automodule:: ahriman.core.formatters.pkgbuild_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.printer module ahriman.core.formatters.printer module
-------------------------------------- --------------------------------------

View File

@@ -116,14 +116,6 @@ ahriman.web.schemas.file\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.hold\_schema module
---------------------------------------
.. automodule:: ahriman.web.schemas.hold_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.info\_schema module ahriman.web.schemas.info\_schema module
--------------------------------------- ---------------------------------------

View File

@@ -4,14 +4,6 @@ ahriman.web.views.v1.packages package
Submodules Submodules
---------- ----------
ahriman.web.views.v1.packages.archives module
---------------------------------------------
.. automodule:: ahriman.web.views.v1.packages.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.changes module ahriman.web.views.v1.packages.changes module
-------------------------------------------- --------------------------------------------
@@ -28,14 +20,6 @@ ahriman.web.views.v1.packages.dependencies module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.views.v1.packages.hold module
-----------------------------------------
.. automodule:: ahriman.web.views.v1.packages.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.logs module ahriman.web.views.v1.packages.logs module
----------------------------------------- -----------------------------------------

View File

@@ -2,7 +2,7 @@
"name": "ahriman-frontend", "name": "ahriman-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "2.20.0", "version": "2.20.1",
"scripts": { "scripts": {
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite", "dev": "vite",
@@ -29,13 +29,14 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^8.0.0" "vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
} }
} }

View File

@@ -78,14 +78,6 @@ export class ServiceClient {
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data }); return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
} }
async servicePackageHoldUpdate(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
method: "POST",
query: repository.toQuery(),
json: { is_held: isHeld },
});
}
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> { async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
return this.client.request("/api/v1/service/rebuild", { return this.client.request("/api/v1/service/rebuild", {
method: "POST", method: "POST",

View File

@@ -17,57 +17,47 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import "components/common/syntaxLanguages"; import { Box } from "@mui/material";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton"; import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
import React, { type RefObject } from "react"; import React, { type RefObject } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface CodeBlockProps { interface CodeBlockProps {
content: string;
height?: number | string;
language?: string;
onScroll?: () => void;
preRef?: RefObject<HTMLElement | null>; preRef?: RefObject<HTMLElement | null>;
getText: () => string;
height?: number | string;
onScroll?: () => void;
wordBreak?: boolean;
} }
export default function CodeBlock({ export default function CodeBlock({
content,
height,
language = "text",
onScroll,
preRef, preRef,
getText,
height,
onScroll,
wordBreak,
}: CodeBlockProps): React.JSX.Element { }: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}> return <Box sx={{ position: "relative" }}>
<Box <Box
ref={preRef} ref={preRef}
component="pre"
onScroll={onScroll} onScroll={onScroll}
sx={{ overflow: "auto", height }} sx={{
backgroundColor: "action.hover",
p: 2,
borderRadius: 1,
overflow: "auto",
height,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
}}
> >
<SyntaxHighlighter <code>
language={language} {getText()}
style={mode === "dark" ? vs2015 : githubGist} </code>
wrapLongLines </Box>
customStyle={{ <Box sx={{ position: "absolute", top: 8, right: 8 }}>
padding: theme.spacing(2), <CopyButton getText={getText} />
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
minHeight: "100%",
}}
>
{content}
</SyntaxHighlighter>
</Box> </Box>
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>; </Box>;
} }

View File

@@ -23,17 +23,17 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps { interface CopyButtonProps {
text: string; getText: () => string;
} }
export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element { export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []); useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => { const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(getText());
setCopied(true); setCopied(true);
clearTimeout(timer.current); clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000); timer.current = setTimeout(() => setCopied(false), 2000);

View File

@@ -25,12 +25,12 @@ import type React from "react";
export default function RepositorySelect({ export default function RepositorySelect({
repositorySelect, repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element { }: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, currentRepository } = useRepository(); const { repositories, current } = useRepository();
return <FormControl fullWidth margin="normal"> return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel> <InputLabel>repository</InputLabel>
<Select <Select
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")} value={repositorySelect.selectedKey || (current?.key ?? "")}
label="repository" label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)} onChange={event => repositorySelect.setSelectedKey(event.target.value)}
> >

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("text", plaintext);

View File

@@ -36,11 +36,11 @@ interface DashboardDialogProps {
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element { export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { data: status } = useQuery<InternalStatus>({ const { data: status } = useQuery<InternalStatus>({
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"], queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken, queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
enabled: open, enabled: open,
}); });

View File

@@ -105,7 +105,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
/> />
{keyBody && {keyBody &&
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<CodeBlock content={keyBody} height={300} /> <CodeBlock getText={() => keyBody} height={300} />
</Box> </Box>
} }
</DialogContent> </DialogContent>

View File

@@ -27,8 +27,6 @@ import EventsTab from "components/package/EventsTab";
import PackageDetailsGrid from "components/package/PackageDetailsGrid"; import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions"; import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList"; import PackagePatchesList from "components/package/PackagePatchesList";
import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh"; import { useAutoRefresh } from "hooks/useAutoRefresh";
@@ -57,7 +55,7 @@ export default function PackageInfoDialog({
autoRefreshIntervals, autoRefreshIntervals,
}: PackageInfoDialogProps): React.JSX.Element { }: PackageInfoDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -67,11 +65,11 @@ export default function PackageInfoDialog({
setLocalPackageBase(packageBase); setLocalPackageBase(packageBase);
} }
const [activeTab, setActiveTab] = useState<TabKey>("logs"); const [tabIndex, setTabIndex] = useState(0);
const [refreshDatabase, setRefreshDatabase] = useState(true); const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => { const handleClose = (): void => {
setActiveTab("logs"); setTabIndex(0);
setRefreshDatabase(true); setRefreshDatabase(true);
onClose(); onClose();
}; };
@@ -79,17 +77,16 @@ export default function PackageInfoDialog({
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"], queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"],
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken,
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
enabled: open, enabled: open,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"], queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"],
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && current
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken, ? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken,
enabled: open, enabled: open,
}); });
@@ -105,12 +102,11 @@ export default function PackageInfoDialog({
const headerStyle = status ? StatusHeaderStyles[status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate: () => Promise<void> = async () => { const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !current) {
return; return;
} }
try { try {
await client.service.servicePackageAdd( await client.service.servicePackageAdd(current, { packages: [localPackageBase], refresh: refreshDatabase });
currentRepository, { packages: [localPackageBase], refresh: refreshDatabase });
showSuccess("Success", `Run update for packages ${localPackageBase}`); showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) { } catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
@@ -118,11 +114,11 @@ export default function PackageInfoDialog({
}; };
const handleRemove: () => Promise<void> = async () => { const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !current) {
return; return;
} }
try { try {
await client.service.servicePackageRemove(currentRepository, [localPackageBase]); await client.service.servicePackageRemove(current, [localPackageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`); showSuccess("Success", `Packages ${localPackageBase} have been removed`);
onClose(); onClose();
} catch (exception) { } catch (exception) {
@@ -130,19 +126,6 @@ export default function PackageInfoDialog({
} }
}; };
const handleHoldToggle: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) {
return;
}
try {
const newHeldStatus = !(status?.is_held ?? false);
await client.service.servicePackageHoldUpdate(localPackageBase, currentRepository, newHeldStatus);
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
} catch (exception) {
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
}
};
const handleDeletePatch: (key: string) => Promise<void> = async key => { const handleDeletePatch: (key: string) => Promise<void> = async key => {
if (!localPackageBase) { if (!localPackageBase) {
return; return;
@@ -173,26 +156,25 @@ export default function PackageInfoDialog({
/> />
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}> <Tabs value={tabIndex} onChange={(_, index: number) => setTabIndex(index)}>
{tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)} <Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs> </Tabs>
</Box> </Box>
{activeTab === "logs" && localPackageBase && currentRepository && {tabIndex === 0 && localPackageBase && current &&
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={localPackageBase}
repository={currentRepository} repository={current}
refreshInterval={autoRefresh.interval} refreshInterval={autoRefresh.interval}
/> />
} }
{activeTab === "changes" && localPackageBase && currentRepository && {tabIndex === 1 && localPackageBase && current &&
<ChangesTab packageBase={localPackageBase} repository={currentRepository} /> <ChangesTab packageBase={localPackageBase} repository={current} />
} }
{activeTab === "pkgbuild" && localPackageBase && currentRepository && {tabIndex === 2 && localPackageBase && current &&
<PkgbuildTab packageBase={localPackageBase} repository={currentRepository} /> <EventsTab packageBase={localPackageBase} repository={current} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
} }
</> </>
} }
@@ -202,8 +184,6 @@ export default function PackageInfoDialog({
isAuthorized={isAuthorized} isAuthorized={isAuthorized}
refreshDatabase={refreshDatabase} refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase} onRefreshDatabaseChange={setRefreshDatabase}
isHeld={status?.is_held ?? false}
onHoldToggle={() => void handleHoldToggle()}
onUpdate={() => void handleUpdate()} onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()} onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals} autoRefreshIntervals={autoRefreshIntervals}

View File

@@ -22,15 +22,14 @@ import { useRepository } from "hooks/useRepository";
import type React from "react"; import type React from "react";
export default function Navbar(): React.JSX.Element | null { export default function Navbar(): React.JSX.Element | null {
const { repositories, currentRepository, setCurrentRepository } = useRepository(); const { repositories, current, setCurrent } = useRepository();
if (repositories.length === 0 || !currentRepository) { if (repositories.length === 0 || !current) {
return null; return null;
} }
const currentIndex = repositories.findIndex(repository => const currentIndex = repositories.findIndex(repository =>
repository.architecture === currentRepository.architecture && repository.architecture === current.architecture && repository.repository === current.repository,
repository.repository === currentRepository.repository,
); );
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}> return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
@@ -39,7 +38,7 @@ export default function Navbar(): React.JSX.Element | null {
onChange={(_, newValue: number) => { onChange={(_, newValue: number) => {
const repository = repositories[newValue]; const repository = repositories[newValue];
if (repository) { if (repository) {
setCurrentRepository(repository); setCurrent(repository);
} }
}} }}
variant="scrollable" variant="scrollable"

View File

@@ -175,9 +175,10 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<CodeBlock <CodeBlock
preRef={preRef} preRef={preRef}
content={displayedLogs} getText={() => displayedLogs}
height={400} height={400}
onScroll={handleScroll} onScroll={handleScroll}
wordBreak
/> />
</Box> </Box>
</Box>; </Box>;

View File

@@ -17,10 +17,20 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import CodeBlock from "components/common/CodeBlock"; import { Box } from "@mui/material";
import { usePackageChanges } from "hooks/usePackageChanges"; import { useQuery } from "@tanstack/react-query";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import { useThemeMode } from "hooks/useThemeMode";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import React from "react"; import React from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps { interface ChangesTabProps {
packageBase: string; packageBase: string;
@@ -28,7 +38,35 @@ interface ChangesTabProps {
} }
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element { export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository); const client = useClient();
const { mode } = useThemeMode();
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />; const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
return <Box sx={{ position: "relative", mt: 1 }}>
<SyntaxHighlighter
language="diff"
style={mode === "dark" ? vs2015 : githubGist}
customStyle={{
padding: "16px",
borderRadius: "4px",
overflow: "auto",
height: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
}}
>
{changesText}
</SyntaxHighlighter>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>;
} }

View File

@@ -18,9 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material"; import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl"; import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
@@ -28,8 +26,6 @@ import type React from "react";
interface PackageInfoActionsProps { interface PackageInfoActionsProps {
isAuthorized: boolean; isAuthorized: boolean;
isHeld: boolean;
onHoldToggle: () => void;
refreshDatabase: boolean; refreshDatabase: boolean;
onRefreshDatabaseChange: (checked: boolean) => void; onRefreshDatabaseChange: (checked: boolean) => void;
onUpdate: () => void; onUpdate: () => void;
@@ -43,8 +39,6 @@ export default function PackageInfoActions({
isAuthorized, isAuthorized,
refreshDatabase, refreshDatabase,
onRefreshDatabaseChange, onRefreshDatabaseChange,
isHeld,
onHoldToggle,
onUpdate, onUpdate,
onRemove, onRemove,
autoRefreshIntervals, autoRefreshIntervals,
@@ -58,9 +52,6 @@ export default function PackageInfoActions({
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />} control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
label="update pacman databases" label="update pacman databases"
/> />
<Button onClick={onHoldToggle} variant="outlined" color="warning" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} size="small">
{isHeld ? "unhold" : "hold"}
</Button>
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small"> <Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
update update
</Button> </Button>

View File

@@ -1,34 +0,0 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
interface PkgbuildTabProps {
packageBase: string;
repository: RepositoryId;
}
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository);
return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />;
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" },
];

View File

@@ -107,8 +107,7 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
width: 120, width: 120,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
<StatusCell status={params.row.status} isHeld={params.row.isHeld} />,
}, },
], ],
[], [],

View File

@@ -17,7 +17,6 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import { Chip } from "@mui/material"; import { Chip } from "@mui/material";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import type React from "react"; import type React from "react";
@@ -25,12 +24,10 @@ import { StatusColors } from "theme/StatusColors";
interface StatusCellProps { interface StatusCellProps {
status: BuildStatus; status: BuildStatus;
isHeld?: boolean;
} }
export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element { export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
return <Chip return <Chip
icon={isHeld ? <PauseCircleIcon /> : undefined}
label={status} label={status}
size="small" size="small"
sx={{ sx={{

View File

@@ -22,9 +22,9 @@ import { createContext } from "react";
export interface RepositoryContextValue { export interface RepositoryContextValue {
repositories: RepositoryId[]; repositories: RepositoryId[];
currentRepository: RepositoryId | null; current: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void; setRepositories: (repositories: RepositoryId[]) => void;
setCurrentRepository: (repository: RepositoryId) => void; setCurrent: (repository: RepositoryId) => void;
} }
export const RepositoryContext = createContext<RepositoryContextValue | null>(null); export const RepositoryContext = createContext<RepositoryContextValue | null>(null);

View File

@@ -34,20 +34,20 @@ export function RepositoryProvider({ children }: { children: ReactNode }): React
const [repositories, setRepositories] = useState<RepositoryId[]>([]); const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot); const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const currentRepository = useMemo(() => { const current = useMemo(() => {
if (repositories.length === 0) { if (repositories.length === 0) {
return null; return null;
} }
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null; return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]); }, [repositories, hash]);
const setCurrentRepository = useCallback((repository: RepositoryId) => { const setCurrent = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key; window.location.hash = repository.key;
}, []); }, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
repositories, currentRepository, setRepositories, setCurrentRepository, repositories, current, setRepositories, setCurrent,
}), [repositories, currentRepository, setCurrentRepository]); }), [repositories, current, setCurrent]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>; return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
} }

View File

@@ -37,7 +37,7 @@ export function usePackageActions(
setSelectionModel: (model: string[]) => void, setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult { ): UsePackageActionsResult {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -50,13 +50,13 @@ export function usePackageActions(
action: (repository: RepositoryId) => Promise<string>, action: (repository: RepositoryId) => Promise<string>,
errorMessage: string, errorMessage: string,
): Promise<void> => { ): Promise<void> => {
if (!currentRepository) { if (!current) {
return; return;
} }
try { try {
const successMessage = await action(currentRepository); const successMessage = await action(current);
showSuccess("Success", successMessage); showSuccess("Success", successMessage);
invalidate(currentRepository); invalidate(current);
setSelectionModel([]); setSelectionModel([]);
} catch (exception) { } catch (exception) {
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`); showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
@@ -64,8 +64,8 @@ export function usePackageActions(
}; };
const handleReload: () => void = () => { const handleReload: () => void = () => {
if (currentRepository !== null) { if (current !== null) {
invalidate(currentRepository); invalidate(current);
} }
}; };

View File

@@ -1,36 +0,0 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
export function usePackageChanges(packageBase: string, repository: RepositoryId): Changes | undefined {
const client = useClient();
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
return data;
}

View File

@@ -39,20 +39,20 @@ export interface UsePackageDataResult {
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult { export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"], queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken, queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"], queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken, queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });

View File

@@ -29,10 +29,10 @@ export interface SelectedRepositoryResult {
} }
export function useSelectedRepository(): SelectedRepositoryResult { export function useSelectedRepository(): SelectedRepositoryResult {
const { repositories, currentRepository } = useRepository(); const { repositories, current } = useRepository();
const [selectedKey, setSelectedKey] = useState(""); const [selectedKey, setSelectedKey] = useState("");
let selectedRepository: RepositoryId | null = currentRepository; let selectedRepository: RepositoryId | null = current;
if (selectedKey) { if (selectedKey) {
const repository = repositories.find(repository => repository.key === selectedKey); const repository = repositories.find(repository => repository.key === selectedKey);
if (repository) { if (repository) {

View File

@@ -20,5 +20,4 @@
export interface Changes { export interface Changes {
changes?: string; changes?: string;
last_commit_sha?: string; last_commit_sha?: string;
pkgbuild?: string;
} }

View File

@@ -32,7 +32,6 @@ export class PackageRow {
timestamp: string; timestamp: string;
timestampValue: number; timestampValue: number;
status: BuildStatus; status: BuildStatus;
isHeld: boolean;
constructor(descriptor: PackageStatus) { constructor(descriptor: PackageStatus) {
this.id = descriptor.package.base; this.id = descriptor.package.base;
@@ -46,7 +45,6 @@ export class PackageRow {
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort(); this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
this.timestampValue = descriptor.status.timestamp; this.timestampValue = descriptor.status.timestamp;
this.status = descriptor.status.status; this.status = descriptor.status.status;
this.isHeld = descriptor.status.is_held ?? false;
} }
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] { private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {

View File

@@ -22,5 +22,4 @@ import type { BuildStatus } from "models/BuildStatus";
export interface Status { export interface Status {
status: BuildStatus; status: BuildStatus;
timestamp: number; timestamp: number;
is_held?: boolean;
} }

View File

@@ -21,24 +21,16 @@ import { createTheme, type Theme } from "@mui/material/styles";
export function createAppTheme(mode: "light" | "dark"): Theme { export function createAppTheme(mode: "light" | "dark"): Theme {
return createTheme({ return createTheme({
components: {
MuiButton: {
styleOverrides: {
startIcon: {
alignItems: "center",
display: "flex",
},
},
},
MuiDialog: {
defaultProps: {
fullWidth: true,
maxWidth: "lg",
},
},
},
palette: { palette: {
mode, mode,
}, },
components: {
MuiDialog: {
defaultProps: {
maxWidth: "lg",
fullWidth: true,
},
},
},
}); });
} }

View File

@@ -1,6 +1,7 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import { defineConfig, type Plugin } from "vite"; import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function rename(oldName: string, newName: string): Plugin { function rename(oldName: string, newName: string): Plugin {
return { return {
@@ -15,11 +16,8 @@ function rename(oldName: string, newName: string): Plugin {
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), rename("index.html", "build-status.jinja2")], plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")],
base: "/", base: "/",
resolve: {
tsconfigPaths: true,
},
build: { build: {
chunkSizeWarningLimit: 10000, chunkSizeWarningLimit: 10000,
emptyOutDir: false, emptyOutDir: false,

View File

@@ -2,7 +2,7 @@
pkgbase='ahriman' pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web') pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.20.0 pkgver=2.20.1
pkgrel=1 pkgrel=1
pkgdesc="ArcH linux ReposItory MANager" pkgdesc="ArcH linux ReposItory MANager"
arch=('any') arch=('any')

View File

@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-03\-08" "ahriman 2.20.0" "ArcH linux ReposItory MANager" .TH AHRIMAN "1" "2026\-03\-13" "ahriman 2.20.1" "ArcH linux ReposItory MANager"
.SH NAME .SH NAME
ahriman \- ArcH linux ReposItory MANager ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS .SH SYNOPSIS

View File

@@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
__version__ = "2.20.0" __version__ = "2.20.1"

View File

@@ -154,13 +154,13 @@ class Application(ApplicationPackages, ApplicationRepository):
for package_name, packager in missing.items(): for package_name, packager in missing.items():
if (source_dir := self.repository.paths.cache_for(package_name)).is_dir(): if (source_dir := self.repository.paths.cache_for(package_name)).is_dir():
# there is local cache, load package from it # there is local cache, load package from it
leaf = Package.from_build(source_dir, self.repository.repository_id.architecture, packager) leaf = Package.from_build(source_dir, self.repository.architecture, packager)
else: else:
leaf = Package.from_aur(package_name, packager, include_provides=True) leaf = Package.from_aur(package_name, packager, include_provides=True)
portion[leaf.base] = leaf portion[leaf.base] = leaf
# register package in the database # register package in the database
self.reporter.set_unknown(leaf) self.repository.reporter.set_unknown(leaf)
return portion return portion

View File

@@ -45,8 +45,8 @@ class ApplicationRepository(ApplicationProperties):
if last_commit_sha is None: if last_commit_sha is None:
continue # skip check in case if we can't calculate diff continue # skip check in case if we can't calculate diff
if (changes := self.repository.package_changes(package, last_commit_sha)) is not None: changes = self.repository.package_changes(package, last_commit_sha)
self.reporter.package_changes_update(package.base, changes) self.repository.reporter.package_changes_update(package.base, changes)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None: def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:
""" """
@@ -162,10 +162,6 @@ class ApplicationRepository(ApplicationProperties):
self.on_result(build_result) self.on_result(build_result)
result.merge(build_result) result.merge(build_result)
# filter packages which were prebuilt
succeeded = {package.base for package in build_result.success}
updates = filter(lambda package: package.base not in succeeded, updates)
builder = Updater.load(self.repository_id, self.configuration, self.repository) builder = Updater.load(self.repository_id, self.configuration, self.repository)
# ok so for now we split all packages into chunks and process each chunk accordingly # ok so for now we split all packages into chunks and process each chunk accordingly

View File

@@ -21,10 +21,10 @@ import argparse
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.application.handlers.update import Update
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import enum_values, extract_user from ahriman.core.utils import enum_values, extract_user
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.packagers import Packagers
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@@ -48,7 +48,26 @@ class Add(Handler):
""" """
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh) application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
application.on_start() application.on_start()
Add.perform_action(application, args)
application.add(args.package, args.source, args.username)
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
for package in args.package: # for each requested package insert patch
for patch in patches:
application.reporter.package_patches_update(package, patch)
if not args.now:
return
packages = application.updates(args.package, aur=False, local=False, manual=True, vcs=False, check_files=False)
if args.changes: # generate changes if requested
application.changes(packages)
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
application.print_updates(packages, log_fn=application.logger.info)
result = application.update(packages, packagers, bump_pkgrel=args.increment)
Add.check_status(args.exit_code, not result.is_empty)
@staticmethod @staticmethod
def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser: def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
@@ -84,34 +103,14 @@ class Add(Handler):
parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate", parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate",
action=argparse.BooleanOptionalAction, default=True) action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-n", "--now", help="run update function after", action="store_true") parser.add_argument("-n", "--now", help="run update function after", action="store_true")
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=False)
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command", parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto) type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
parser.add_argument("-u", "--username", help="build as user", default=extract_user()) parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build", parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build",
action="append") action="append")
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=False)
parser.set_defaults(aur=False, check_files=False, dry_run=False, local=False, manual=True, vcs=False)
return parser return parser
@staticmethod
def perform_action(application: Application, args: argparse.Namespace) -> None:
"""
perform add action
Args:
application(Application): application instance
args(argparse.Namespace): command line args
"""
application.add(args.package, args.source, args.username)
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
for package in args.package: # for each requested package insert patch
for patch in patches:
application.reporter.package_patches_update(package, patch)
if not args.now:
return
Update.perform_action(application, args)
arguments = [_set_package_add_parser] arguments = [_set_package_add_parser]

View File

@@ -1,81 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PackagePrinter
from ahriman.models.action import Action
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.repository_id import RepositoryId
class Archives(Handler):
"""
package archives handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=True)
match args.action:
case Action.List:
archives = application.repository.package_archives(args.package)
for package in archives:
PackagePrinter(package, BuildStatus(BuildStatusEnum.Success))(verbose=args.info)
Archives.check_status(args.exit_code, bool(archives))
@staticmethod
def _set_package_archives_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package archives subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-archives", help="list package archive versions",
description="list available archive versions for the package")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.add_argument("--info", help="show additional package information",
action=argparse.BooleanOptionalAction, default=False)
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_archives_parser]

View File

@@ -47,13 +47,14 @@ class Change(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
client = Application(repository_id, configuration, report=True).reporter application = Application(repository_id, configuration, report=True)
client = application.repository.reporter
match args.action: match args.action:
case Action.List: case Action.List:
changes = client.package_changes_get(args.package) changes = client.package_changes_get(args.package)
ChangesPrinter(changes)(verbose=True, separator="") ChangesPrinter(changes)(verbose=True, separator="")
Change.check_status(args.exit_code, changes.changes is not None) Change.check_status(args.exit_code, not changes.is_empty)
case Action.Remove: case Action.Remove:
client.package_changes_update(args.package, Changes()) client.package_changes_update(args.package, Changes())

View File

@@ -1,93 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Hold(Handler):
"""
package hold handler
"""
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
client = Application(repository_id, configuration, report=True).reporter
match args.action:
case Action.Remove:
for package in args.package:
client.package_hold_update(package, enabled=False)
case Action.Update:
for package in args.package:
client.package_hold_update(package, enabled=True)
@staticmethod
def _set_package_hold_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for hold package subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-hold", help="hold package",
description="hold package from automatic updates")
parser.add_argument("package", help="package base", nargs="+")
parser.set_defaults(action=Action.Update, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_unhold_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for unhold package subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-unhold", help="unhold package",
description="remove package hold, allowing automatic updates")
parser.add_argument("package", help="package base", nargs="+")
parser.set_defaults(action=Action.Remove, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [
_set_package_hold_parser,
_set_package_unhold_parser,
]

View File

@@ -1,100 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from dataclasses import replace
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Pkgbuild(Handler):
"""
package pkgbuild handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
client = Application(repository_id, configuration, report=True).reporter
match args.action:
case Action.List:
changes = client.package_changes_get(args.package)
PkgbuildPrinter(changes)(verbose=True, separator="")
Pkgbuild.check_status(args.exit_code, changes.pkgbuild is not None)
case Action.Remove:
changes = client.package_changes_get(args.package)
client.package_changes_update(args.package, replace(changes, pkgbuild=None))
@staticmethod
def _set_package_pkgbuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild", help="get package pkgbuild",
description="retrieve package PKGBUILD stored in database",
epilog="This command requests package status from the web interface "
"if it is available.")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_pkgbuild_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild remove subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild-remove", help="remove package pkgbuild",
description="remove the package PKGBUILD stored remotely")
parser.add_argument("package", help="package base")
parser.set_defaults(action=Action.Remove, exit_code=False, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_pkgbuild_parser, _set_package_pkgbuild_remove_parser]

View File

@@ -44,7 +44,8 @@ class Reload(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
client = Application(repository_id, configuration, report=True).reporter application = Application(repository_id, configuration, report=True)
client = application.repository.reporter
client.configuration_reload() client.configuration_reload()
@staticmethod @staticmethod

View File

@@ -1,132 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from pathlib import Path
from ahriman.application.application import Application
from ahriman.application.handlers.add import Add
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.utils import extract_user
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
from ahriman.models.repository_id import RepositoryId
class Rollback(Handler):
"""
package rollback handler
"""
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=report)
application.on_start()
package = Rollback.package_load(application, args.package, args.version)
artifacts = Rollback.package_artifacts(application, package)
args.package = [str(artifact) for artifact in artifacts]
Add.perform_action(application, args)
if args.hold:
application.reporter.package_hold_update(package.base, enabled=True)
@staticmethod
def _set_package_archives_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package archives subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-rollback", help="rollback package",
description="rollback package to specified version from archives")
parser.add_argument("package", help="package base")
parser.add_argument("version", help="package version")
parser.add_argument("--hold", help="hold package afterwards",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False,
exit_code=False, increment=False, now=True, local=False, manual=False, refresh=False,
source=PackageSource.Archive, variable=None, vcs=False)
return parser
@staticmethod
def package_artifacts(application: Application, package: Package) -> list[Path]:
"""
look for package artifacts and returns paths to them if any
Args:
application(Application): application instance
package(Package): package descriptor
Returns:
list[Path]: paths to found artifacts
Raises:
UnknownPackageError: if artifacts do not exist
"""
# lookup for built artifacts
artifacts = application.repository.package_archives_lookup(package)
if not artifacts:
raise UnknownPackageError(package.base) from None
return artifacts
@staticmethod
def package_load(application: Application, package_base: str, version: str) -> Package:
"""
load package from given arguments
Args:
application(Application): application instance
package_base(str): package base
version(str): package version
Returns:
Package: loaded package
Raises:
UnknownPackageError: if package does not exist
"""
try:
package, _ = next(iter(application.reporter.package_get(package_base)))
package.version = version
return package
except StopIteration:
raise UnknownPackageError(package_base) from None
arguments = [_set_package_archives_parser]

View File

@@ -52,7 +52,7 @@ class Status(Handler):
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
# we are using reporter here # we are using reporter here
client = Application(repository_id, configuration, report=True).reporter client = Application(repository_id, configuration, report=True).repository.reporter
if args.ahriman: if args.ahriman:
service_status = client.status_get() service_status = client.status_get()
StatusPrinter(service_status.status)(verbose=args.info) StatusPrinter(service_status.status)(verbose=args.info)

View File

@@ -47,7 +47,8 @@ class StatusUpdate(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
client = Application(repository_id, configuration, report=True).reporter application = Application(repository_id, configuration, report=True)
client = application.repository.reporter
match args.action: match args.action:
case Action.Update if args.package: case Action.Update if args.package:

View File

@@ -48,7 +48,22 @@ class Update(Handler):
""" """
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh) application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
application.on_start() application.on_start()
Update.perform_action(application, args)
packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
check_files=args.check_files)
if args.changes: # generate changes if requested
application.changes(packages)
if args.dry_run: # exit from application if no build requested
Update.check_status(args.exit_code, packages) # status code check
return
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
application.print_updates(packages, log_fn=application.logger.info)
result = application.update(packages, packagers, bump_pkgrel=args.increment)
Update.check_status(args.exit_code, not result.is_empty)
@staticmethod @staticmethod
def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser: def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
@@ -138,31 +153,6 @@ class Update(Handler):
return print(line) if dry_run else application.logger.info(line) # pylint: disable=bad-builtin return print(line) if dry_run else application.logger.info(line) # pylint: disable=bad-builtin
return inner return inner
@staticmethod
def perform_action(application: Application, args: argparse.Namespace) -> None:
"""
perform update action
Args:
application(Application): application instance
args(argparse.Namespace): command line args
"""
packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
check_files=args.check_files)
if args.changes: # generate changes if requested
application.changes(packages)
if args.dry_run: # exit from application if no build requested
Update.check_status(args.exit_code, packages) # status code check
return
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
application.print_updates(packages, log_fn=application.logger.info)
result = application.update(packages, packagers, bump_pkgrel=args.increment)
Update.check_status(args.exit_code, not result.is_empty)
arguments = [ arguments = [
_set_repo_check_parser, _set_repo_check_parser,
_set_repo_update_parser, _set_repo_update_parser,

View File

@@ -24,11 +24,10 @@ import tarfile
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from pyalpm import DB, Package, SIG_DATABASE_OPTIONAL, SIG_PACKAGE_OPTIONAL # type: ignore[import-not-found] from pyalpm import DB, Handle, Package, SIG_DATABASE_OPTIONAL, SIG_PACKAGE_OPTIONAL # type: ignore[import-not-found]
from string import Template from string import Template
from ahriman.core.alpm.pacman_database import PacmanDatabase from ahriman.core.alpm.pacman_database import PacmanDatabase
from ahriman.core.alpm.pacman_handle import PacmanHandle
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import trim_package from ahriman.core.utils import trim_package
@@ -62,16 +61,16 @@ class Pacman(LazyLogging):
self.refresh_database = refresh_database self.refresh_database = refresh_database
@cached_property @cached_property
def handle(self) -> PacmanHandle: def handle(self) -> Handle:
""" """
pyalpm handle pyalpm handle
Returns: Returns:
PacmanHandle: generated pyalpm handle instance Handle: generated pyalpm handle instance
""" """
return self.__create_handle(refresh_database=self.refresh_database) return self.__create_handle(refresh_database=self.refresh_database)
def __create_handle(self, *, refresh_database: PacmanSynchronization) -> PacmanHandle: def __create_handle(self, *, refresh_database: PacmanSynchronization) -> Handle:
""" """
create lazy handle function create lazy handle function
@@ -79,14 +78,14 @@ class Pacman(LazyLogging):
refresh_database(PacmanSynchronization): synchronize local cache to remote refresh_database(PacmanSynchronization): synchronize local cache to remote
Returns: Returns:
PacmanHandle: fully initialized pacman handle Handle: fully initialized pacman handle
""" """
pacman_root = self.configuration.getpath("alpm", "database") pacman_root = self.configuration.getpath("alpm", "database")
use_ahriman_cache = self.configuration.getboolean("alpm", "use_ahriman_cache") use_ahriman_cache = self.configuration.getboolean("alpm", "use_ahriman_cache")
database_path = self.repository_paths.pacman if use_ahriman_cache else pacman_root database_path = self.repository_paths.pacman if use_ahriman_cache else pacman_root
root = self.configuration.getpath("alpm", "root") root = self.configuration.getpath("alpm", "root")
handle = PacmanHandle(str(root), str(database_path)) handle = Handle(str(root), str(database_path))
for repository in self.configuration.getlist("alpm", "repositories"): for repository in self.configuration.getlist("alpm", "repositories"):
database = self.database_init(handle, repository, self.repository_id.architecture) database = self.database_init(handle, repository, self.repository_id.architecture)
@@ -100,12 +99,12 @@ class Pacman(LazyLogging):
return handle return handle
def database_copy(self, handle: PacmanHandle, database: DB, pacman_root: Path, *, use_ahriman_cache: bool) -> None: def database_copy(self, handle: Handle, database: DB, pacman_root: Path, *, use_ahriman_cache: bool) -> None:
""" """
copy database from the operating system root to the ahriman local home copy database from the operating system root to the ahriman local home
Args: Args:
handle(PacmanHandle): pacman handle which will be used for database copying handle(Handle): pacman handle which will be used for database copying
database(DB): pacman database instance to be copied database(DB): pacman database instance to be copied
pacman_root(Path): operating system pacman root pacman_root(Path): operating system pacman root
use_ahriman_cache(bool): use local ahriman cache instead of system one use_ahriman_cache(bool): use local ahriman cache instead of system one
@@ -134,12 +133,12 @@ class Pacman(LazyLogging):
with self.repository_paths.preserve_owner(): with self.repository_paths.preserve_owner():
shutil.copy(src, dst) shutil.copy(src, dst)
def database_init(self, handle: PacmanHandle, repository: str, architecture: str) -> DB: def database_init(self, handle: Handle, repository: str, architecture: str) -> DB:
""" """
create database instance from pacman handler and set its properties create database instance from pacman handler and set its properties
Args: Args:
handle(PacmanHandle): pacman handle which will be used for database initializing handle(Handle): pacman handle which will be used for database initializing
repository(str): pacman repository name (e.g. core) repository(str): pacman repository name (e.g. core)
architecture(str): repository architecture architecture(str): repository architecture
@@ -165,12 +164,12 @@ class Pacman(LazyLogging):
return database return database
def database_sync(self, handle: PacmanHandle, *, force: bool) -> None: def database_sync(self, handle: Handle, *, force: bool) -> None:
""" """
sync local database sync local database
Args: Args:
handle(PacmanHandle): pacman handle which will be used for database sync handle(Handle): pacman handle which will be used for database sync
force(bool): force database synchronization (same as ``pacman -Syy``) force(bool): force database synchronization (same as ``pacman -Syy``)
""" """
self.logger.info("refresh ahriman's home pacman database (force refresh %s)", force) self.logger.info("refresh ahriman's home pacman database (force refresh %s)", force)

View File

@@ -1,81 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from pathlib import Path
from pyalpm import Handle, Package # type: ignore[import-not-found]
from tempfile import TemporaryDirectory
from typing import Any, ClassVar, Self
class PacmanHandle:
"""
lightweight wrapper for pacman handle to be used for direct alpm operations (e.g. package load)
Attributes:
handle(Handle): pyalpm handle instance
"""
_ephemeral: ClassVar[Self | None] = None
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
Args:
*args(Any): positional arguments for :class:`pyalpm.Handle`
**kwargs(Any): keyword arguments for :class:`pyalpm.Handle`
"""
self.handle = Handle(*args, **kwargs)
@classmethod
def ephemeral(cls) -> Self:
"""
create temporary instance with no access to real databases
Returns:
Self: loaded class
"""
if cls._ephemeral is None:
# handle creates alpm version file, but we don't use it
# so it is ok to just remove it
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
cls._ephemeral = cls("/", dir_name)
return cls._ephemeral
def package_load(self, path: Path) -> Package:
"""
load package from path to the archive
Args:
path(Path): path to package archive
Returns:
Package: package instance
"""
return self.handle.load_pkg(str(path))
def __getattr__(self, item: str) -> Any:
"""
proxy methods for :class:`pyalpm.Handle`, because it doesn't allow subclassing
Args:
item(str): property name
Returns:
Any: attribute by its name
"""
return self.handle.__getattribute__(item)

View File

@@ -26,7 +26,6 @@ from typing import ClassVar
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, utcnow, walk from ahriman.core.utils import check_output, utcnow, walk
from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild import Pkgbuild from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -52,25 +51,24 @@ class Sources(LazyLogging):
} }
@staticmethod @staticmethod
def changes(source_dir: Path, last_commit_sha: str) -> Changes: def changes(source_dir: Path, last_commit_sha: str | None) -> str | None:
""" """
extract changes from the last known commit if available extract changes from the last known commit if available
Args: Args:
source_dir(Path): local path to directory with source files source_dir(Path): local path to directory with source files
last_commit_sha(str): last known commit hash last_commit_sha(str | None): last known commit hash
Returns: Returns:
Changes: changes from the last commit if available str | None: changes from the last commit if available or ``None`` otherwise
""" """
if last_commit_sha is None:
return None # no previous reference found
instance = Sources() instance = Sources()
diff = None
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None: if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
diff = instance.diff(source_dir, last_commit_sha) return instance.diff(source_dir, last_commit_sha)
pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD")) return None
return Changes(last_commit_sha, diff, pkgbuild)
@staticmethod @staticmethod
def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]: def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]:
@@ -415,17 +413,3 @@ class Sources(LazyLogging):
cwd=sources_dir, input_data=patch.serialize(), logger=self.logger) cwd=sources_dir, input_data=patch.serialize(), logger=self.logger)
else: else:
patch.write(sources_dir / "PKGBUILD") patch.write(sources_dir / "PKGBUILD")
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
"""
read file content from the specified commit
Args:
sources_dir(Path): local path to git repository
commit_sha(str): commit hash to read from
path(Path): path to file inside the repository
Returns:
str: file content at specified commit
"""
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)

View File

@@ -19,9 +19,11 @@
# #
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"] __all__ = ["migrate_data", "steps"]
@@ -59,9 +61,12 @@ def migrate_package_depends(connection: Connection, configuration: Configuration
if not configuration.repository_paths.repository.is_dir(): if not configuration.repository_paths.repository.is_dir():
return return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = [] package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()): for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
base = Package.from_archive(full_path) base = Package.from_archive(full_path, pacman)
for package, description in base.packages.items(): for package, description in base.packages.items():
package_list.append({ package_list.append({
"make_depends": description.make_depends, "make_depends": description.make_depends,

View File

@@ -19,9 +19,11 @@
# #
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"] __all__ = ["migrate_data", "steps"]
@@ -56,9 +58,12 @@ def migrate_package_check_depends(connection: Connection, configuration: Configu
if not configuration.repository_paths.repository.is_dir(): if not configuration.repository_paths.repository.is_dir():
return return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = [] package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()): for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
base = Package.from_archive(full_path) base = Package.from_archive(full_path, pacman)
for package, description in base.packages.items(): for package, description in base.packages.items():
package_list.append({ package_list.append({
"check_depends": description.check_depends, "check_depends": description.check_depends,

View File

@@ -19,9 +19,11 @@
# #
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"] __all__ = ["migrate_data", "steps"]
@@ -62,9 +64,12 @@ def migrate_package_base_packager(connection: Connection, configuration: Configu
if not configuration.repository_paths.repository.is_dir(): if not configuration.repository_paths.repository.is_dir():
return return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = [] package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()): for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
package = Package.from_archive(full_path) package = Package.from_archive(full_path, pacman)
package_list.append({ package_list.append({
"package_base": package.base, "package_base": package.base,
"packager": package.packager, "packager": package.packager,

View File

@@ -20,11 +20,13 @@
from dataclasses import replace from dataclasses import replace
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.repository import Explorer from ahriman.core.repository import Explorer
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.utils import atomic_move, package_like, symlink_relative from ahriman.core.utils import atomic_move, package_like, symlink_relative
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@@ -43,27 +45,29 @@ def migrate_data(connection: Connection, configuration: Configuration) -> None:
for repository_id in Explorer.repositories_extract(configuration): for repository_id in Explorer.repositories_extract(configuration):
paths = replace(configuration.repository_paths, repository_id=repository_id) paths = replace(configuration.repository_paths, repository_id=repository_id)
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
# create archive directory if required # create archive directory if required
if not paths.archive.is_dir(): if not paths.archive.is_dir():
with paths.preserve_owner(): with paths.preserve_owner():
paths.archive.mkdir(mode=0o755, parents=True) paths.archive.mkdir(mode=0o755, parents=True)
move_packages(paths) move_packages(paths, pacman)
def move_packages(repository_paths: RepositoryPaths) -> None: def move_packages(repository_paths: RepositoryPaths, pacman: Pacman) -> None:
""" """
move packages from repository to archive and create symbolic links move packages from repository to archive and create symbolic links
Args: Args:
repository_paths(RepositoryPaths): repository paths instance repository_paths(RepositoryPaths): repository paths instance
pacman(Pacman): alpm wrapper instance
""" """
for archive in filter(package_like, repository_paths.repository.iterdir()): for archive in filter(package_like, repository_paths.repository.iterdir()):
if not archive.is_file(follow_symlinks=False): if not archive.is_file(follow_symlinks=False):
continue # skip symbolic links if any continue # skip symbolic links if any
package = Package.from_archive(archive) package = Package.from_archive(archive, pacman)
artifacts = [archive] artifacts = [archive]
# check if there are signatures for this package and append it here too # check if there are signatures for this package and append it here too
if (signature := GPG.signature(archive)).exists(): if (signature := GPG.signature(archive)).exists():

View File

@@ -1,25 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__all__ = ["steps"]
steps = [
"""alter table package_changes add column pkgbuild text""",
]

View File

@@ -1,25 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__all__ = ["steps"]
steps = [
"""alter table package_statuses add column is_held integer not null default 0""",
]

View File

@@ -45,10 +45,10 @@ class ChangesOperations(Operations):
def run(connection: Connection) -> Changes: def run(connection: Connection) -> Changes:
return next( return next(
( (
Changes(row["last_commit_sha"], row["changes"] or None, row["pkgbuild"] or None) Changes(row["last_commit_sha"], row["changes"] or None)
for row in connection.execute( for row in connection.execute(
""" """
select last_commit_sha, changes, pkgbuild from package_changes select last_commit_sha, changes from package_changes
where package_base = :package_base and repository = :repository where package_base = :package_base and repository = :repository
""", """,
{ {
@@ -77,17 +77,16 @@ class ChangesOperations(Operations):
connection.execute( connection.execute(
""" """
insert into package_changes insert into package_changes
(package_base, last_commit_sha, changes, pkgbuild, repository) (package_base, last_commit_sha, changes, repository)
values values
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository) (:package_base, :last_commit_sha, :changes ,:repository)
on conflict (package_base, repository) do update set on conflict (package_base, repository) do update set
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = :pkgbuild last_commit_sha = :last_commit_sha, changes = :changes
""", """,
{ {
"package_base": package_base, "package_base": package_base,
"last_commit_sha": changes.last_commit_sha, "last_commit_sha": changes.last_commit_sha,
"changes": changes.changes, "changes": changes.changes,
"pkgbuild": changes.pkgbuild,
"repository": repository_id.id, "repository": repository_id.id,
}) })

View File

@@ -211,37 +211,13 @@ class PackageOperations(Operations):
dict[str, BuildStatus]: map of the package base to its status dict[str, BuildStatus]: map of the package base to its status
""" """
return { return {
row["package_base"]: BuildStatus(row["status"], row["last_updated"], is_held=bool(row["is_held"])) row["package_base"]: BuildStatus.from_json({"status": row["status"], "timestamp": row["last_updated"]})
for row in connection.execute( for row in connection.execute(
"""select * from package_statuses where repository = :repository""", """select * from package_statuses where repository = :repository""",
{"repository": repository_id.id} {"repository": repository_id.id}
) )
} }
def package_hold_update(self, package_base: str, repository_id: RepositoryId | None = None, *,
enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
enabled(bool): new hold status
"""
repository_id = repository_id or self._repository_id
def run(connection: Connection) -> None:
connection.execute(
"""update package_statuses set is_held = :is_held
where package_base = :package_base and repository = :repository""",
{
"is_held": int(enabled),
"package_base": package_base,
"repository": repository_id.id,
})
return self.with_connection(run, commit=True)
def package_remove(self, package_base: str, repository_id: RepositoryId | None = None) -> None: def package_remove(self, package_base: str, repository_id: RepositoryId | None = None) -> None:
""" """
remove package from database remove package from database

View File

@@ -26,7 +26,6 @@ from ahriman.core.formatters.event_stats_printer import EventStatsPrinter
from ahriman.core.formatters.package_printer import PackagePrinter from ahriman.core.formatters.package_printer import PackagePrinter
from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter
from ahriman.core.formatters.patch_printer import PatchPrinter from ahriman.core.formatters.patch_printer import PatchPrinter
from ahriman.core.formatters.pkgbuild_printer import PkgbuildPrinter
from ahriman.core.formatters.printer import Printer from ahriman.core.formatters.printer import Printer
from ahriman.core.formatters.repository_printer import RepositoryPrinter from ahriman.core.formatters.repository_printer import RepositoryPrinter
from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter

View File

@@ -45,7 +45,7 @@ class ChangesPrinter(Printer):
Returns: Returns:
list[Property]: list of content properties list[Property]: list of content properties
""" """
if self.changes.changes is None: if self.changes.is_empty:
return [] return []
return [Property("", self.changes.changes, is_required=True, indent=0)] return [Property("", self.changes.changes, is_required=True, indent=0)]
@@ -57,6 +57,6 @@ class ChangesPrinter(Printer):
Returns: Returns:
str | None: content title if it can be generated and ``None`` otherwise str | None: content title if it can be generated and ``None`` otherwise
""" """
if self.changes.changes is None: if self.changes.is_empty:
return None return None
return self.changes.last_commit_sha return self.changes.last_commit_sha

View File

@@ -1,62 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core.formatters.printer import Printer
from ahriman.models.changes import Changes
from ahriman.models.property import Property
class PkgbuildPrinter(Printer):
"""
print content of the pkgbuild stored in changes
Attributes:
changes(Changes): package changes
"""
def __init__(self, changes: Changes) -> None:
"""
Args:
changes(Changes): package changes
"""
Printer.__init__(self)
self.changes = changes
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
if self.changes.pkgbuild is None:
return []
return [Property("", self.changes.pkgbuild, is_required=True, indent=0)]
# pylint: disable=redundant-returns-doc
def title(self) -> str | None:
"""
generate entry title from content
Returns:
str | None: content title if it can be generated and ``None`` otherwise
"""
if self.changes.pkgbuild is None:
return None
return self.changes.last_commit_sha

View File

@@ -17,10 +17,14 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from collections.abc import Callable
from functools import cmp_to_key
from ahriman.core import context from ahriman.core import context
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.core.triggers import Trigger from ahriman.core.triggers import Trigger
from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result from ahriman.models.result import Result
@@ -74,20 +78,27 @@ class ArchiveRotationTrigger(Trigger):
""" """
return list(cls.CONFIGURATION_SCHEMA.keys()) return list(cls.CONFIGURATION_SCHEMA.keys())
def archives_remove(self, package: Package, repository: Repository) -> None: def archives_remove(self, package: Package, pacman: Pacman) -> None:
""" """
remove older versions of the specified package remove older versions of the specified package
Args: Args:
package(Package): package which has been updated to check for older versions package(Package): package which has been updated to check for older versions
repository(Repository): repository instance pacman(Pacman): alpm wrapper instance
""" """
# explicit guard to skip process in case if rotation is disabled # explicit guard to skip process in case if rotation is disabled
# this guard is supposed to speedup process # this guard is supposed to speedup process
if self.keep_built_packages == 0: if self.keep_built_packages == 0:
return return
to_remove = repository.package_archives(package.base) packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()):
local = Package.from_archive(full_path, pacman)
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
to_remove = sorted(packages.values(), key=cmp_to_key(comparator))
for single in to_remove[:-self.keep_built_packages]: for single in to_remove[:-self.keep_built_packages]:
self.logger.info("removing version %s of package %s", single.version, single.base) self.logger.info("removing version %s of package %s", single.version, single.base)
@@ -104,7 +115,7 @@ class ArchiveRotationTrigger(Trigger):
packages(list[Package]): list of all available packages packages(list[Package]): list of all available packages
""" """
ctx = context.get() ctx = context.get()
repository = ctx.get(Repository) pacman = ctx.get(Pacman)
for package in result.success: for package in result.success:
self.archives_remove(package, repository) self.archives_remove(package, pacman)

View File

@@ -27,7 +27,7 @@ from ahriman.core.build_tools.package_archive import PackageArchive
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.repository.package_info import PackageInfo from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.utils import atomic_move, filelock, safe_filename, symlink_relative from ahriman.core.utils import atomic_move, filelock, list_flatmap, package_like, safe_filename, symlink_relative
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.event import EventType from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
@@ -41,6 +41,35 @@ class Executor(PackageInfo, Cleaner):
trait for common repository update processes trait for common repository update processes
""" """
def _archive_lookup(self, package: Package) -> list[Path]:
"""
check if there is a rebuilt package already
Args:
package(Package): package to check
Returns:
list[Path]: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
if not archive.is_dir():
return []
for path in filter(package_like, archive.iterdir()):
# check if package version is the same
built = Package.from_archive(path, self.pacman)
if built.version != package.version:
continue
packages = built.packages.values()
# all packages must be either any or same architecture
if not all(single.architecture in ("any", self.architecture) for single in packages):
continue
return list_flatmap(packages, lambda single: archive.glob(f"{single.filename}*"))
return []
def _archive_rename(self, description: PackageDescription, package_base: str) -> None: def _archive_rename(self, description: PackageDescription, package_base: str) -> None:
""" """
rename package archive removing special symbols rename package archive removing special symbols
@@ -73,12 +102,12 @@ class Executor(PackageInfo, Cleaner):
""" """
self.reporter.set_building(package.base) self.reporter.set_building(package.base)
task = Task(package, self.configuration, self.repository_id.architecture, self.paths) task = Task(package, self.configuration, self.architecture, self.paths)
patches = self.reporter.package_patches_get(package.base, None) patches = self.reporter.package_patches_get(package.base, None)
commit_sha = task.init(path, patches, local_version) commit_sha = task.init(path, patches, local_version)
loaded_package = Package.from_build(path, self.repository_id.architecture, None) loaded_package = Package.from_build(path, self.architecture, None)
if prebuilt := self.package_archives_lookup(loaded_package): if prebuilt := list(self._archive_lookup(loaded_package)):
self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version) self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version)
built = [] built = []
for artifact in prebuilt: for artifact in prebuilt:
@@ -88,7 +117,7 @@ class Executor(PackageInfo, Cleaner):
else: else:
built = task.build(path, PACKAGER=packager) built = task.build(path, PACKAGER=packager)
package.with_packages(built) package.with_packages(built, self.pacman)
for src in built: for src in built:
dst = self.paths.packages / src.name dst = self.paths.packages / src.name
atomic_move(src, dst) atomic_move(src, dst)
@@ -175,8 +204,7 @@ class Executor(PackageInfo, Cleaner):
# update commit hash for changes keeping current diff if there is any # update commit hash for changes keeping current diff if there is any
changes = self.reporter.package_changes_get(single.base) changes = self.reporter.package_changes_get(single.base)
self.reporter.package_changes_update( self.reporter.package_changes_update(single.base, Changes(commit_sha, changes.changes))
single.base, Changes(commit_sha, changes.changes, changes.pkgbuild))
# update dependencies list # update dependencies list
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths) package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)
@@ -189,7 +217,7 @@ class Executor(PackageInfo, Cleaner):
except Exception: except Exception:
self.reporter.set_failed(single.base) self.reporter.set_failed(single.base)
result.add_failed(single) result.add_failed(single)
self.logger.exception("%s (%s) build exception", single.base, self.repository_id.architecture) self.logger.exception("%s (%s) build exception", single.base, self.architecture)
return result return result

View File

@@ -19,42 +19,23 @@
# #
import copy import copy
from collections.abc import Callable, Iterable from collections.abc import Iterable
from functools import cmp_to_key
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.build_tools.package_version import PackageVersion from ahriman.core.build_tools.package_version import PackageVersion
from ahriman.core.build_tools.sources import Sources from ahriman.core.build_tools.sources import Sources
from ahriman.core.configuration import Configuration from ahriman.core.repository.repository_properties import RepositoryProperties
from ahriman.core.log import LazyLogging from ahriman.core.utils import package_like
from ahriman.core.status import Client
from ahriman.core.utils import list_flatmap, package_like
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_paths import RepositoryPaths
class PackageInfo(LazyLogging): class PackageInfo(RepositoryProperties):
""" """
handler for the package information handler for the package information
Attributes:
configuration(Configuration): configuration instance
pacman(Pacman): alpm wrapper instance
paths(RepositoryPaths): repository paths instance
reporter(Client): build status reporter instance
repository_id(RepositoryId): repository unique identifier
""" """
configuration: Configuration
pacman: Pacman
paths: RepositoryPaths
reporter: Client
repository_id: RepositoryId
def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]: def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]:
""" """
generate full dependencies list including transitive dependencies generate full dependencies list including transitive dependencies
@@ -105,7 +86,7 @@ class PackageInfo(LazyLogging):
# we are iterating over bases, not single packages # we are iterating over bases, not single packages
for full_path in packages: for full_path in packages:
try: try:
local = Package.from_archive(full_path) local = Package.from_archive(full_path, self.pacman)
if (source := sources.get(local.base)) is not None: # update source with remote if (source := sources.get(local.base)) is not None: # update source with remote
local.remote = source local.remote = source
@@ -121,76 +102,27 @@ class PackageInfo(LazyLogging):
self.logger.exception("could not load package from %s", full_path) self.logger.exception("could not load package from %s", full_path)
return list(result.values()) return list(result.values())
def package_archives(self, package_base: str) -> list[Package]: def package_changes(self, package: Package, last_commit_sha: str | None) -> Changes:
"""
load list of packages known for this package base. This method unlike
:func:`ahriman.core.repository.package_info.PackageInfo.load_archives` scans archive directory and loads all
versions available for the ``package_base``
Args:
package_base(str): package base
Returns:
list[Package]: list of packages belonging to this base, sorted by version by ascension
"""
packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, self.paths.archive_for(package_base).iterdir()):
local = Package.from_archive(full_path)
if not local.supports_architecture(self.repository_id.architecture):
continue
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
return sorted(packages.values(), key=cmp_to_key(comparator))
def package_archives_lookup(self, package: Package) -> list[Path]:
"""
check if there is a rebuilt package already
Args:
package(Package): package to check
Returns:
list[Path]: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
if not archive.is_dir():
return []
for path in filter(package_like, archive.iterdir()):
# check if package version is the same
built = Package.from_archive(path)
if built.version != package.version:
continue
# all packages must be either any or same architecture
if not built.supports_architecture(self.repository_id.architecture):
continue
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
return []
def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
""" """
extract package change for the package since last commit if available extract package change for the package since last commit if available
Args: Args:
package(Package): package properties package(Package): package properties
last_commit_sha(str): last known commit hash last_commit_sha(str | None): last known commit hash
Returns: Returns:
Changes | None: changes if available Changes: changes if available
""" """
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name: with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
dir_path = Path(dir_name) dir_path = Path(dir_name)
patches = self.reporter.package_patches_get(package.base, None) patches = self.reporter.package_patches_get(package.base, None)
current_commit_sha = Sources.load(dir_path, package, patches, self.paths) current_commit_sha = Sources.load(dir_path, package, patches, self.paths)
changes: str | None = None
if current_commit_sha != last_commit_sha: if current_commit_sha != last_commit_sha:
return Sources.changes(dir_path, last_commit_sha) changes = Sources.changes(dir_path, last_commit_sha)
return None
return Changes(last_commit_sha, changes)
def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]: def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]:
""" """

View File

@@ -42,6 +42,7 @@ class RepositoryProperties(EventLogger, LazyLogging):
Attributes: Attributes:
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
database(SQLite): database instance database(SQLite): database instance
ignore_list(list[str]): package bases which will be ignored during auto updates
pacman(Pacman): alpm wrapper instance pacman(Pacman): alpm wrapper instance
paths(RepositoryPaths): repository paths instance paths(RepositoryPaths): repository paths instance
repo(Repo): repo commands wrapper instance repo(Repo): repo commands wrapper instance
@@ -68,15 +69,35 @@ class RepositoryProperties(EventLogger, LazyLogging):
self.paths: RepositoryPaths = configuration.repository_paths # additional workaround for pycharm typing self.paths: RepositoryPaths = configuration.repository_paths # additional workaround for pycharm typing
self._ignore_list = configuration.getlist("build", "ignore_packages", fallback=[]) self.ignore_list = configuration.getlist("build", "ignore_packages", fallback=[])
self.pacman = Pacman(repository_id, configuration, refresh_database=refresh_pacman_database) self.pacman = Pacman(repository_id, configuration, refresh_database=refresh_pacman_database)
self.sign = GPG(configuration) self.sign = GPG(configuration)
self.repo = Repo(self.repository_id.name, self.paths, self.sign.repository_sign_args) self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args)
self.reporter = Client.load(repository_id, configuration, database, report=report) self.reporter = Client.load(repository_id, configuration, database, report=report)
self.triggers = TriggerLoader.load(repository_id, configuration) self.triggers = TriggerLoader.load(repository_id, configuration)
self.scan_paths = ScanPaths(configuration.getlist("build", "scan_paths", fallback=[])) self.scan_paths = ScanPaths(configuration.getlist("build", "scan_paths", fallback=[]))
@property
def architecture(self) -> str:
"""
repository architecture for backward compatibility
Returns:
str: repository architecture
"""
return self.repository_id.architecture
@property
def name(self) -> str:
"""
repository name for backward compatibility
Returns:
str: repository name
"""
return self.repository_id.name
def packager(self, packagers: Packagers, package_base: str) -> User: def packager(self, packagers: Packagers, package_base: str) -> User:
""" """
extract packager from configuration having username extract packager from configuration having username

View File

@@ -58,17 +58,12 @@ class UpdateHandler(PackageInfo, Cleaner):
continue continue
raise UnknownPackageError(package.base) raise UnknownPackageError(package.base)
ignore_list = self._ignore_list + [
package.base for package, status in self.reporter.package_get(None) if status.is_held
]
result: list[Package] = [] result: list[Package] = []
for local in self.packages(filter_packages): for local in self.packages(filter_packages):
with self.in_package_context(local.base, local.version): with self.in_package_context(local.base, local.version):
if not local.remote.is_remote: if not local.remote.is_remote:
continue # avoid checking local packages continue # avoid checking local packages
if local.base in ignore_list: if local.base in self.ignore_list:
self.logger.info("package %s is held, skip update check", local.base)
continue continue
try: try:
@@ -155,7 +150,7 @@ class UpdateHandler(PackageInfo, Cleaner):
) )
Sources.fetch(cache_dir, source) Sources.fetch(cache_dir, source)
remote = Package.from_build(cache_dir, self.repository_id.architecture, None) remote = Package.from_build(cache_dir, self.architecture, None)
local = packages.get(remote.base) local = packages.get(remote.base)
if local is None: if local is None:

View File

@@ -199,19 +199,6 @@ class Client:
""" """
raise NotImplementedError raise NotImplementedError
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
Raises:
NotImplementedError: not implemented method
"""
raise NotImplementedError
def package_logs_add(self, log_record: LogRecord) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record

View File

@@ -42,7 +42,7 @@ class LocalClient(Client):
""" """
Args: Args:
repository_id(RepositoryId): repository unique identifier repository_id(RepositoryId): repository unique identifier
database(SQLite): database instance database(SQLite): database instance:
""" """
self.database = database self.database = database
self.repository_id = repository_id self.repository_id = repository_id
@@ -143,16 +143,6 @@ class LocalClient(Client):
return packages return packages
return [(package, status) for package, status in packages if package.base == package_base] return [(package, status) for package, status in packages if package.base == package_base]
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
self.database.package_hold_update(package_base, self.repository_id, enabled=enabled)
def package_logs_add(self, log_record: LogRecord) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record

View File

@@ -18,13 +18,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from threading import Lock from threading import Lock
from typing import Any, Self from typing import Any, Self
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -41,18 +39,15 @@ class Watcher(LazyLogging):
Attributes: Attributes:
client(Client): reporter instance client(Client): reporter instance
package_info(PackageInfo): package info instance
status(BuildStatus): daemon status status(BuildStatus): daemon status
""" """
def __init__(self, client: Client, package_info: PackageInfo) -> None: def __init__(self, client: Client) -> None:
""" """
Args: Args:
client(Client): reporter instance client(Client): reporter instance
package_info(PackageInfo): package info instance
""" """
self.client = client self.client = client
self.package_info = package_info
self._lock = Lock() self._lock = Lock()
self._known: dict[str, tuple[Package, BuildStatus]] = {} self._known: dict[str, tuple[Package, BuildStatus]] = {}
@@ -85,18 +80,6 @@ class Watcher(LazyLogging):
logs_rotate: Callable[[int], None] logs_rotate: Callable[[int], None]
def package_archives(self, package_base: str) -> list[Package]:
"""
get known package archives
Args:
package_base(str): package base
Returns:
list[Package]: list of built package for this package base
"""
return self.package_info.package_archives(package_base)
package_changes_get: Callable[[str], Changes] package_changes_get: Callable[[str], Changes]
package_changes_update: Callable[[str, Changes], None] package_changes_update: Callable[[str, Changes], None]
@@ -130,19 +113,6 @@ class Watcher(LazyLogging):
package_logs_remove: Callable[[str, str | None], None] package_logs_remove: Callable[[str, str | None], None]
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
package, status = self.package_get(package_base)
with self._lock:
self._known[package_base] = (package, replace(status, is_held=enabled))
self.client.package_hold_update(package_base, enabled=enabled)
package_patches_get: Callable[[str, str | None], list[PkgbuildPatch]] package_patches_get: Callable[[str, str | None], list[PkgbuildPatch]]
package_patches_remove: Callable[[str, str], None] package_patches_remove: Callable[[str, str], None]
@@ -168,9 +138,9 @@ class Watcher(LazyLogging):
package_base(str): package base to update package_base(str): package base to update
status(BuildStatusEnum): new build status status(BuildStatusEnum): new build status
""" """
package, current_status = self.package_get(package_base) package, _ = self.package_get(package_base)
with self._lock: with self._lock:
self._known[package_base] = (package, BuildStatus(status, is_held=current_status.is_held)) self._known[package_base] = (package, BuildStatus(status))
self.client.package_status_update(package_base, status) self.client.package_status_update(package_base, status)
def package_update(self, package: Package, status: BuildStatusEnum) -> None: def package_update(self, package: Package, status: BuildStatusEnum) -> None:
@@ -182,8 +152,7 @@ class Watcher(LazyLogging):
status(BuildStatusEnum): new build status status(BuildStatusEnum): new build status
""" """
with self._lock: with self._lock:
_, current_status = self._known.get(package.base, (package, BuildStatus())) self._known[package.base] = (package, BuildStatus(status))
self._known[package.base] = (package, BuildStatus(status, is_held=current_status.is_held))
self.client.package_update(package, status) self.client.package_update(package, status)
def status_update(self, status: BuildStatusEnum) -> None: def status_update(self, status: BuildStatusEnum) -> None:

View File

@@ -314,18 +314,6 @@ class WebClient(Client, SyncAhrimanClient):
return [] return []
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
with contextlib.suppress(Exception):
self.make_request("POST", f"{self.address}/api/v1/packages/{url_encode(package_base)}/hold",
params=self.repository_id.query(), json={"is_held": enabled})
def package_logs_add(self, log_record: LogRecord) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record

View File

@@ -51,12 +51,10 @@ class BuildStatus:
Attributes: Attributes:
status(BuildStatusEnum): build status status(BuildStatusEnum): build status
timestamp(int): build status update time timestamp(int): build status update time
is_held(bool | None): whether package held or not
""" """
status: BuildStatusEnum = BuildStatusEnum.Unknown status: BuildStatusEnum = BuildStatusEnum.Unknown
timestamp: int = field(default_factory=lambda: int(utcnow().timestamp())) timestamp: int = field(default_factory=lambda: int(utcnow().timestamp()))
is_held: bool | None = field(default=None, kw_only=True)
def __post_init__(self) -> None: def __post_init__(self) -> None:
""" """
@@ -85,7 +83,7 @@ class BuildStatus:
Returns: Returns:
str: print-friendly string str: print-friendly string
""" """
return f"{self.status.value} ({pretty_datetime(self.timestamp)}){" (held)" if self.is_held else ""}" return f"{self.status.value} ({pretty_datetime(self.timestamp)})"
def view(self) -> dict[str, Any]: def view(self) -> dict[str, Any]:
""" """
@@ -96,6 +94,5 @@ class BuildStatus:
""" """
return { return {
"status": self.status.value, "status": self.status.value,
"timestamp": self.timestamp, "timestamp": self.timestamp
"is_held": self.is_held,
} }

View File

@@ -31,12 +31,20 @@ class Changes:
Attributes: Attributes:
last_commit_sha(str | None): last commit hash last_commit_sha(str | None): last commit hash
changes(str | None): package change since the last commit if available changes(str | None): package change since the last commit if available
pkgbuild(str | None): original PKGBUILD content if available
""" """
last_commit_sha: str | None = None last_commit_sha: str | None = None
changes: str | None = None changes: str | None = None
pkgbuild: str | None = None
@property
def is_empty(self) -> bool:
"""
validate that changes are not empty
Returns:
bool: ``True`` in case if changes are not set and ``False`` otherwise
"""
return self.changes is None
@classmethod @classmethod
def from_json(cls, dump: dict[str, Any]) -> Self: def from_json(cls, dump: dict[str, Any]) -> Self:

View File

@@ -26,7 +26,6 @@ from pyalpm import vercmp # type: ignore[import-not-found]
from typing import Any, Self from typing import Any, Self
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.pacman_handle import PacmanHandle
from ahriman.core.alpm.remote import AUR, Official, OfficialSyncdb from ahriman.core.alpm.remote import AUR, Official, OfficialSyncdb
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import dataclass_view, full_version, list_flatmap, parse_version, srcinfo_property_list from ahriman.core.utils import dataclass_view, full_version, list_flatmap, parse_version, srcinfo_property_list
@@ -137,6 +136,16 @@ class Package(LazyLogging):
""" """
return list_flatmap(self.packages.values(), lambda package: package.groups) return list_flatmap(self.packages.values(), lambda package: package.groups)
@property
def is_single_package(self) -> bool:
"""
is it possible to transform package base to single package or not
Returns:
bool: true in case if this base has only one package with the same name
"""
return self.base in self.packages and len(self.packages) == 1
@property @property
def is_vcs(self) -> bool: def is_vcs(self) -> bool:
""" """
@@ -177,17 +186,18 @@ class Package(LazyLogging):
return sorted(packages) return sorted(packages)
@classmethod @classmethod
def from_archive(cls, path: Path) -> Self: def from_archive(cls, path: Path, pacman: Pacman) -> Self:
""" """
construct package properties from package archive construct package properties from package archive
Args: Args:
path(Path): path to package archive path(Path): path to package archive
pacman(Pacman): alpm wrapper instance
Returns: Returns:
Self: package properties Self: package properties
""" """
package = PacmanHandle.ephemeral().package_load(path) package = pacman.handle.load_pkg(str(path))
description = PackageDescription.from_package(package, path) description = PackageDescription.from_package(package, path)
return cls( return cls(
base=package.base or package.name, base=package.base or package.name,
@@ -365,22 +375,9 @@ class Package(LazyLogging):
Returns: Returns:
str: print-friendly string str: print-friendly string
""" """
is_single_package = self.base in self.packages and len(self.packages) == 1 details = "" if self.is_single_package else f" ({" ".join(sorted(self.packages.keys()))})"
details = "" if is_single_package else f" ({" ".join(sorted(self.packages.keys()))})"
return f"{self.base}{details}" return f"{self.base}{details}"
def supports_architecture(self, architecture: str) -> bool:
"""
helper to check if the package belongs to the specified architecture
Args:
architecture(str): probe repository architecture
Returns:
bool: ``True`` if all packages are same architecture or any
"""
return all(single.architecture in ("any", architecture) for single in self.packages.values())
def vercmp(self, version: str) -> int: def vercmp(self, version: str) -> int:
""" """
typed wrapper around :func:`pyalpm.vercmp()` typed wrapper around :func:`pyalpm.vercmp()`
@@ -403,16 +400,17 @@ class Package(LazyLogging):
""" """
return dataclass_view(self) return dataclass_view(self)
def with_packages(self, packages: Iterable[Path]) -> None: def with_packages(self, packages: Iterable[Path], pacman: Pacman) -> None:
""" """
replace packages descriptions with ones from archives replace packages descriptions with ones from archives
Args: Args:
packages(Iterable[Path]): paths to package archives packages(Iterable[Path]): paths to package archives
pacman(Pacman): alpm wrapper instance
""" """
self.packages = {} # reset state self.packages = {} # reset state
for package in packages: for package in packages:
archive = self.from_archive(package) archive = self.from_archive(package, pacman)
if archive.base != self.base: if archive.base != self.base:
continue continue

View File

@@ -31,7 +31,6 @@ from ahriman.web.schemas.error_schema import ErrorSchema
from ahriman.web.schemas.event_schema import EventSchema from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.hold_schema import HoldSchema
from ahriman.web.schemas.info_schema import InfoSchema from ahriman.web.schemas.info_schema import InfoSchema
from ahriman.web.schemas.info_v2_schema import InfoV2Schema from ahriman.web.schemas.info_v2_schema import InfoV2Schema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema

View File

@@ -32,6 +32,3 @@ class ChangesSchema(Schema):
"description": "Last recorded commit hash", "description": "Last recorded commit hash",
"example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6", "example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6",
}) })
pkgbuild = fields.String(metadata={
"description": "Original PKGBUILD content",
})

View File

@@ -1,30 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.web.apispec import Schema, fields
class HoldSchema(Schema):
"""
request hold schema
"""
is_held = fields.Boolean(required=True, metadata={
"description": "Package hold status",
})

View File

@@ -33,6 +33,3 @@ class StatusSchema(Schema):
"description": "Last update timestamp", "description": "Last update timestamp",
"example": 1680537091, "example": 1680537091,
}) })
is_held = fields.Boolean(metadata={
"description": "Package hold status",
})

View File

@@ -1,65 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import PackageNameSchema, PackageSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard
class Archives(StatusViewGuard, BaseView):
"""
package archives web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
"""
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/archives"]
@apidocs(
tags=["Packages"],
summary="Get package archives",
description="Retrieve built package archives for the base",
permission=GET_PERMISSION,
error_404_description="Package base and/or repository are unknown",
schema=PackageSchema(many=True),
match_schema=PackageNameSchema,
query_schema=RepositoryIdSchema,
)
async def get(self) -> Response:
"""
get package archives
Returns:
Response: 200 with package archives on success
Raises:
HTTPNotFound: if no package was found
"""
package_base = self.request.match_info["package"]
archives = self.service(package_base=package_base).package_archives(package_base)
return self.json_response([archive.view() for archive in archives])

View File

@@ -92,11 +92,10 @@ class ChangesView(StatusViewGuard, BaseView):
data = await self.request.json() data = await self.request.json()
last_commit_sha = data.get("last_commit_sha") # empty/null meant removal last_commit_sha = data.get("last_commit_sha") # empty/null meant removal
change = data.get("changes") change = data.get("changes")
pkgbuild = data.get("pkgbuild")
except Exception as ex: except Exception as ex:
raise HTTPBadRequest(reason=str(ex)) raise HTTPBadRequest(reason=str(ex))
changes = Changes(last_commit_sha, change, pkgbuild) changes = Changes(last_commit_sha, change)
self.service().package_changes_update(package_base, changes) self.service().package_changes_update(package_base, changes)
raise HTTPNoContent raise HTTPNoContent

View File

@@ -1,75 +0,0 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import HoldSchema, PackageNameSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard
class HoldView(StatusViewGuard, BaseView):
"""
package hold web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/hold"]
@apidocs(
tags=["Packages"],
summary="Update package hold status",
description="Set package hold status",
permission=POST_PERMISSION,
error_400_enabled=True,
error_404_description="Package base and/or repository are unknown",
match_schema=PackageNameSchema,
query_schema=RepositoryIdSchema,
body_schema=HoldSchema,
)
async def post(self) -> None:
"""
update package hold status
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
HTTPNotFound: if no package was found
"""
package_base = self.request.match_info["package"]
try:
data = await self.request.json()
is_held = data["is_held"]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
try:
self.service().package_hold_update(package_base, enabled=is_held)
except UnknownPackageError:
raise HTTPNotFound(reason=f"Package {package_base} is unknown")
raise HTTPNoContent

View File

@@ -23,14 +23,12 @@ import logging
import socket import socket
from aiohttp.web import Application, normalize_path_middleware, run_app from aiohttp.web import Application, normalize_path_middleware, run_app
from pathlib import Path
from ahriman.core.auth import Auth from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.distributed import WorkersCache from ahriman.core.distributed import WorkersCache
from ahriman.core.exceptions import InitializeError from ahriman.core.exceptions import InitializeError
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@@ -80,35 +78,6 @@ def _create_socket(configuration: Configuration, application: Application) -> so
return sock return sock
def _create_watcher(path: Path, repository_id: RepositoryId) -> Watcher:
"""
build watcher for selected repository
Args:
path(Path): path to configuration file
repository_id(RepositoryId): repository unique identifier
Returns:
Watcher: watcher instance
"""
logging.getLogger(__name__).info("load repository %s", repository_id)
# load settings explicitly for architecture if any
configuration = Configuration.from_path(path, repository_id)
# load database instance, because it holds identifier
database = SQLite.load(configuration)
# explicitly load local client
client = Client.load(repository_id, configuration, database, report=False)
# load package info wrapper
package_info = PackageInfo()
package_info.configuration = configuration
package_info.paths = configuration.repository_paths
package_info.repository_id = repository_id
return Watcher(client, package_info)
async def _on_shutdown(application: Application) -> None: async def _on_shutdown(application: Application) -> None:
""" """
web application shutdown handler web application shutdown handler
@@ -199,11 +168,18 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
# package cache # package cache
if not repositories: if not repositories:
raise InitializeError("No repositories configured, exiting") raise InitializeError("No repositories configured, exiting")
watchers: dict[RepositoryId, Watcher] = {}
configuration_path, _ = configuration.check_loaded() configuration_path, _ = configuration.check_loaded()
application[WatcherKey] = { for repository_id in repositories:
repository_id: _create_watcher(configuration_path, repository_id) application.logger.info("load repository %s", repository_id)
for repository_id in repositories # load settings explicitly for architecture if any
} repository_configuration = Configuration.from_path(configuration_path, repository_id)
# load database instance, because it holds identifier
database = SQLite.load(repository_configuration)
# explicitly load local client
client = Client.load(repository_id, repository_configuration, database, report=False)
watchers[repository_id] = Watcher(client)
application[WatcherKey] = watchers
# workers cache # workers cache
application[WorkersKey] = WorkersCache(configuration) application[WorkersKey] = WorkersCache(configuration)
# process spawner # process spawner

View File

@@ -18,7 +18,7 @@ def test_changes(application_repository: ApplicationRepository, package_ahriman:
""" """
must generate changes for the packages must generate changes for the packages
""" """
changes = Changes("sha", "change") changes = Changes("hash", "change")
hashes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes) hashes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
changes_mock = mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=changes) changes_mock = mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=changes)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update") report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
@@ -42,20 +42,6 @@ def test_changes_skip(application_repository: ApplicationRepository, package_ahr
report_mock.assert_not_called() report_mock.assert_not_called()
def test_changes_no_update(application_repository: ApplicationRepository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must skip update if package_changes returns None (no new commits)
"""
changes = Changes("sha", "change")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=None)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
application_repository.changes([package_ahriman])
report_mock.assert_not_called()
def test_clean_cache(application_repository: ApplicationRepository, mocker: MockerFixture) -> None: def test_clean_cache(application_repository: ApplicationRepository, mocker: MockerFixture) -> None:
""" """
must clean cache directory must clean cache directory

View File

@@ -1,84 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.archives import Archives
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.action = Action.List
args.exit_code = False
args.info = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives",
return_value=[package_ahriman])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=False, log_fn=pytest.helpers.anyvar(int), separator=": ")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty archives result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Archives.ALLOW_MULTI_ARCHITECTURE_RUN

View File

@@ -1,53 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.hold import Hold
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.models.action import Action
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.package = ["ahriman"]
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
args.action = Action.Update
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Hold.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with("ahriman", enabled=True)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove held status
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Hold.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with("ahriman", enabled=False)

View File

@@ -1,100 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.pkgbuild import Pkgbuild
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.changes import Changes
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.action = Action.List
args.exit_code = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("sha", "change", "pkgbuild content"))
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=True, log_fn=pytest.helpers.anyvar(int), separator="")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty pkgbuild result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=Changes())
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove package pkgbuild
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
changes = Changes("sha", "change", "pkgbuild content")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
update_mock.assert_called_once_with(args.package, Changes("sha", "change", None))
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Pkgbuild.ALLOW_MULTI_ARCHITECTURE_RUN

View File

@@ -271,22 +271,6 @@ def test_subparsers_package_add_option_variable_multiple(parser: argparse.Argume
assert args.variable == ["var1", "var2"] assert args.variable == ["var1", "var2"]
def test_subparsers_package_archives(parser: argparse.ArgumentParser) -> None:
"""
package-archives command must imply action, exit code, info, lock, quiet, report and unsafe
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-archives", "ahriman"])
assert args.action == Action.List
assert args.architecture == "x86_64"
assert not args.exit_code
assert not args.info
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == "repo"
assert args.unsafe
def test_subparsers_package_changes(parser: argparse.ArgumentParser) -> None: def test_subparsers_package_changes(parser: argparse.ArgumentParser) -> None:
""" """
package-changes command must imply action, exit code, lock, quiet, report and unsafe package-changes command must imply action, exit code, lock, quiet, report and unsafe
@@ -325,44 +309,6 @@ def test_subparsers_package_changes_remove_package_changes(parser: argparse.Argu
assert dir(args) == dir(reference_args) assert dir(args) == dir(reference_args)
def test_subparsers_package_pkgbuild(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild command must imply action, exit code, lock, quiet, report and unsafe
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild", "ahriman"])
assert args.action == Action.List
assert args.architecture == "x86_64"
assert not args.exit_code
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == "repo"
assert args.unsafe
def test_subparsers_package_pkgbuild_remove(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild-remove command must imply action, lock, quiet, report and unsafe
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild-remove", "ahriman"])
assert args.action == Action.Remove
assert args.architecture == "x86_64"
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == "repo"
assert args.unsafe
def test_subparsers_package_pkgbuild_remove_package_pkgbuild(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild-remove must have same keys as package-pkgbuild
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild-remove", "ahriman"])
reference_args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild", "ahriman"])
assert dir(args) == dir(reference_args)
def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None: def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None:
""" """
package-copy command must correctly parse architecture list package-copy command must correctly parse architecture list
@@ -464,30 +410,6 @@ def test_subparsers_package_status_update_package_status_remove(parser: argparse
assert dir(args) == dir(reference_args) assert dir(args) == dir(reference_args)
def test_subparsers_package_hold(parser: argparse.ArgumentParser) -> None:
"""
package-hold command must imply action, lock, quiet, report and unsafe
"""
args = parser.parse_args(["package-hold", "ahriman"])
assert args.action == Action.Update
assert args.lock is None
assert args.quiet
assert not args.report
assert args.unsafe
def test_subparsers_package_unhold(parser: argparse.ArgumentParser) -> None:
"""
package-unhold command must imply action, lock, quiet, report and unsafe
"""
args = parser.parse_args(["package-unhold", "ahriman"])
assert args.action == Action.Remove
assert args.lock is None
assert args.quiet
assert not args.report
assert args.unsafe
def test_subparsers_patch_add(parser: argparse.ArgumentParser) -> None: def test_subparsers_patch_add(parser: argparse.ArgumentParser) -> None:
""" """
patch-add command must imply action, architecture list, exit code, lock, report and repository patch-add command must imply action, architecture list, exit code, lock, report and repository

View File

@@ -16,7 +16,6 @@ from ahriman.core.database import SQLite
from ahriman.core.database.migrations import Migrations from ahriman.core.database.migrations import Migrations
from ahriman.core.log.log_loader import LogLoader from ahriman.core.log.log_loader import LogLoader
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@@ -689,5 +688,4 @@ def watcher(local_client: Client) -> Watcher:
Returns: Returns:
Watcher: package status watcher test instance Watcher: package status watcher test instance
""" """
package_info = PackageInfo() return Watcher(local_client)
return Watcher(local_client, package_info)

View File

@@ -1,37 +0,0 @@
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from ahriman.core.alpm.pacman_handle import PacmanHandle
def test_package_load() -> None:
"""
must load package from archive path
"""
local = Path("local")
instance = PacmanHandle.ephemeral()
handle_mock = instance.handle = MagicMock()
instance.package_load(local)
handle_mock.load_pkg.assert_called_once_with(str(local))
PacmanHandle._ephemeral = None
def test_getattr() -> None:
"""
must proxy attribute access to underlying handle
"""
instance = PacmanHandle.ephemeral()
assert instance.dbpath
def test_getattr_not_found() -> None:
"""
must raise AttributeError for missing handle attributes
"""
instance = PacmanHandle.ephemeral()
with pytest.raises(AttributeError):
assert instance.random_attribute

View File

@@ -6,7 +6,6 @@ from unittest.mock import call as MockCall
from ahriman.core.build_tools.sources import Sources from ahriman.core.build_tools.sources import Sources
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -20,27 +19,35 @@ def test_changes(mocker: MockerFixture) -> None:
""" """
fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until") fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until")
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
local = Path("local") local = Path("local")
last_commit_sha = "sha" last_commit_sha = "sha"
assert Sources.changes(local, last_commit_sha) == Changes(last_commit_sha, "diff", "pkgbuild") assert Sources.changes(local, last_commit_sha) == "diff"
fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha) fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha)
diff_mock.assert_called_once_with(local, last_commit_sha) diff_mock.assert_called_once_with(local, last_commit_sha)
read_mock.assert_called_once_with(local, "HEAD", Path("PKGBUILD"))
def test_changes_skip(mocker: MockerFixture) -> None:
"""
must return none in case if commit sha is not available
"""
fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until")
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff")
assert Sources.changes(Path("local"), None) is None
fetch_mock.assert_not_called()
diff_mock.assert_not_called()
def test_changes_unknown_commit(mocker: MockerFixture) -> None: def test_changes_unknown_commit(mocker: MockerFixture) -> None:
""" """
must return changes without diff in case if commit sha wasn't found at the required depth must return none in case if commit sha wasn't found at the required depth
""" """
mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until", return_value=None) mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until", return_value=None)
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
assert Sources.changes(Path("local"), "sha") == Changes("sha", None, "pkgbuild") assert Sources.changes(Path("local"), "sha") is None
diff_mock.assert_not_called() diff_mock.assert_not_called()
read_mock.assert_called_once_with(Path("local"), "HEAD", Path("PKGBUILD"))
def test_extend_architectures(mocker: MockerFixture) -> None: def test_extend_architectures(mocker: MockerFixture) -> None:
@@ -596,12 +603,3 @@ def test_patch_apply_function(sources: Sources, mocker: MockerFixture) -> None:
sources.patch_apply(local, patch) sources.patch_apply(local, patch)
write_mock.assert_called_once_with(local / "PKGBUILD") write_mock.assert_called_once_with(local / "PKGBUILD")
def test_read(sources: Sources, mocker: MockerFixture) -> None:
"""
must read file from commit
"""
check_output_mock = mocker.patch("ahriman.core.build_tools.sources.check_output", return_value="content")
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) == "content"
check_output_mock.assert_called_once()

View File

@@ -34,7 +34,8 @@ def test_migrate_package_depends(connection: Connection, configuration: Configur
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_depends(connection, configuration) migrate_package_depends(connection, configuration)
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath) package_mock.assert_called_once_with(
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{ connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"make_depends": package_ahriman.packages[package_ahriman.base].make_depends, "make_depends": package_ahriman.packages[package_ahriman.base].make_depends,
"opt_depends": package_ahriman.packages[package_ahriman.base].opt_depends, "opt_depends": package_ahriman.packages[package_ahriman.base].opt_depends,

View File

@@ -34,7 +34,8 @@ def test_migrate_package_depends(connection: Connection, configuration: Configur
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_check_depends(connection, configuration) migrate_package_check_depends(connection, configuration)
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath) package_mock.assert_called_once_with(
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{ connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"check_depends": package_ahriman.packages[package_ahriman.base].check_depends, "check_depends": package_ahriman.packages[package_ahriman.base].check_depends,
"package": package_ahriman.base, "package": package_ahriman.base,

View File

@@ -34,7 +34,8 @@ def test_migrate_package_base_packager(connection: Connection, configuration: Co
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_base_packager(connection, configuration) migrate_package_base_packager(connection, configuration)
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath) package_mock.assert_called_once_with(
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{ connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"package_base": package_ahriman.base, "package_base": package_ahriman.base,
"packager": package_ahriman.packager, "packager": package_ahriman.packager,

View File

@@ -7,6 +7,7 @@ from sqlite3 import Connection
from typing import Any from typing import Any
from unittest.mock import call as MockCall from unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations.m016_archive import migrate_data, move_packages from ahriman.core.database.migrations.m016_archive import migrate_data, move_packages
from ahriman.models.package import Package from ahriman.models.package import Package
@@ -27,12 +28,12 @@ def test_migrate_data(connection: Connection, configuration: Configuration, mock
migrate_data(connection, configuration) migrate_data(connection, configuration)
migration_mock.assert_has_calls([ migration_mock.assert_has_calls([
MockCall(replace(configuration.repository_paths, repository_id=repository)) MockCall(replace(configuration.repository_paths, repository_id=repository), pytest.helpers.anyvar(int))
for repository in repositories for repository in repositories
]) ])
def test_move_packages(repository_paths: RepositoryPaths, package_ahriman: Package, def test_move_packages(repository_paths: RepositoryPaths, pacman: Pacman, package_ahriman: Package,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """
must move packages to the archive directory must move packages to the archive directory
@@ -56,9 +57,9 @@ def test_move_packages(repository_paths: RepositoryPaths, package_ahriman: Packa
move_mock = mocker.patch("ahriman.core.database.migrations.m016_archive.atomic_move") move_mock = mocker.patch("ahriman.core.database.migrations.m016_archive.atomic_move")
symlink_mock = mocker.patch("pathlib.Path.symlink_to") symlink_mock = mocker.patch("pathlib.Path.symlink_to")
move_packages(repository_paths) move_packages(repository_paths, pacman)
archive_mock.assert_has_calls([ archive_mock.assert_has_calls([
MockCall(repository_paths.repository / filename) MockCall(repository_paths.repository / filename, pacman)
for filename in ("file.pkg.tar.xz", "file2.pkg.tar.xz") for filename in ("file.pkg.tar.xz", "file2.pkg.tar.xz")
]) ])
move_mock.assert_has_calls([ move_mock.assert_has_calls([

View File

@@ -1,8 +0,0 @@
from ahriman.core.database.migrations.m017_pkgbuild import steps
def test_migration_pkgbuild() -> None:
"""
migration must not be empty
"""
assert steps

View File

@@ -1,8 +0,0 @@
from ahriman.core.database.migrations.m018_package_hold import steps
def test_migration_package_hold() -> None:
"""
migration must not be empty
"""
assert steps

View File

@@ -8,35 +8,34 @@ def test_changes_insert_get(database: SQLite, package_ahriman: Package) -> None:
""" """
must insert and get changes must insert and get changes
""" """
changes1 = Changes("sha1", "change1", "pkgbuild1") database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
database.changes_insert(package_ahriman.base, changes1) assert database.changes_get(package_ahriman.base).changes == "change1"
assert database.changes_get(package_ahriman.base) == changes1
changes2 = Changes("sha2", "change2", "pkgbuild2")
database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name)) database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
assert database.changes_get(package_ahriman.base) == changes1 RepositoryId("i686", database._repository_id.name))
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2 assert database.changes_get(package_ahriman.base).changes == "change1"
assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None:
""" """
must remove changes for the package must remove changes for the package
""" """
changes3 = Changes("sha3", "change3", "pkgbuild3") database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1")) database.changes_insert(package_python_schedule.base, Changes("sha3", "change3"))
database.changes_insert(package_python_schedule.base, changes3) database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
database.changes_insert(package_ahriman.base, Changes("sha2", "change2", "pkgbuild2"),
RepositoryId("i686", database._repository_id.name)) RepositoryId("i686", database._repository_id.name))
database.changes_remove(package_ahriman.base) database.changes_remove(package_ahriman.base)
assert database.changes_get(package_ahriman.base).changes is None assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base) == changes3 assert database.changes_get(package_python_schedule.base).changes == "change3"
# insert null # insert null
database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name)) database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name))
assert database.changes_get( assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes is None package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes is None
assert database.changes_get(package_python_schedule.base) == changes3 assert database.changes_get(package_python_schedule.base).changes == "change3"
def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package, def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
@@ -44,12 +43,13 @@ def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
""" """
must remove all changes for the repository must remove all changes for the repository
""" """
changes2 = Changes("sha2", "change2", "pkgbuild2") database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1")) database.changes_insert(package_python_schedule.base, Changes("sha3", "change3"))
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3", "pkgbuild3")) database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name)) RepositoryId("i686", database._repository_id.name))
database.changes_remove(None) database.changes_remove(None)
assert database.changes_get(package_ahriman.base).changes is None assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base).changes is None assert database.changes_get(package_python_schedule.base).changes is None
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2 assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"

View File

@@ -103,33 +103,6 @@ def test_packages_get_select_statuses(database: SQLite, connection: Connection)
connection.execute(pytest.helpers.anyvar(str, strict=True)) connection.execute(pytest.helpers.anyvar(str, strict=True))
def test_package_hold_update(database: SQLite, package_ahriman: Package) -> None:
"""
must update package hold status
"""
database.package_update(package_ahriman)
database.status_update(package_ahriman.base, BuildStatus())
database.package_hold_update(package_ahriman.base, enabled=True)
assert next(status.is_held for _, status in database.packages_get())
database.package_hold_update(package_ahriman.base, enabled=False)
assert not next(status.is_held for _, status in database.packages_get())
def test_package_hold_update_preserves_on_package_update(database: SQLite, package_ahriman: Package) -> None:
"""
must preserve hold status on regular package update
"""
database.package_update(package_ahriman)
database.status_update(package_ahriman.base, BuildStatus())
database.package_hold_update(package_ahriman.base, enabled=True)
package_ahriman.version = "1.0.0"
database.package_update(package_ahriman)
assert next(status.is_held for _, status in database.packages_get())
def test_package_remove(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_remove(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must totally remove package from the database must totally remove package from the database
@@ -183,9 +156,8 @@ def test_package_update_get(database: SQLite, package_ahriman: Package) -> None:
status = BuildStatus() status = BuildStatus()
database.package_update(package_ahriman) database.package_update(package_ahriman)
database.status_update(package_ahriman.base, status) database.status_update(package_ahriman.base, status)
expected = BuildStatus(status.status, status.timestamp, is_held=False)
assert next((db_package, db_status) assert next((db_package, db_status)
for db_package, db_status in database.packages_get()) == (package_ahriman, expected) for db_package, db_status in database.packages_get()) == (package_ahriman, status)
def test_package_update_remove_get(database: SQLite, package_ahriman: Package) -> None: def test_package_update_remove_get(database: SQLite, package_ahriman: Package) -> None:
@@ -217,8 +189,7 @@ def test_status_update(database: SQLite, package_ahriman: Package) -> None:
database.package_update(package_ahriman) database.package_update(package_ahriman)
database.status_update(package_ahriman.base, status) database.status_update(package_ahriman.base, status)
expected = BuildStatus(status.status, status.timestamp, is_held=False) assert database.packages_get() == [(package_ahriman, status)]
assert database.packages_get() == [(package_ahriman, expected)]
def test_status_update_skip_same_status(database: SQLite, package_ahriman: Package) -> None: def test_status_update_skip_same_status(database: SQLite, package_ahriman: Package) -> None:

View File

@@ -2,26 +2,24 @@ import pytest
from pathlib import Path from pathlib import Path
from ahriman.core.formatters import ( from ahriman.core.formatters import \
AurPrinter, AurPrinter, \
ChangesPrinter, ChangesPrinter, \
ConfigurationPathsPrinter, ConfigurationPathsPrinter, \
ConfigurationPrinter, ConfigurationPrinter, \
EventStatsPrinter, EventStatsPrinter, \
PackagePrinter, PackagePrinter, \
PackageStatsPrinter, PackageStatsPrinter, \
PatchPrinter, PatchPrinter, \
PkgbuildPrinter, RepositoryPrinter, \
RepositoryPrinter, RepositoryStatsPrinter, \
RepositoryStatsPrinter, StatusPrinter, \
StatusPrinter, StringPrinter, \
StringPrinter, TreePrinter, \
TreePrinter, UpdatePrinter, \
UpdatePrinter, UserPrinter, \
UserPrinter, ValidationPrinter, \
ValidationPrinter,
VersionPrinter VersionPrinter
)
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -57,17 +55,6 @@ def changes_printer() -> ChangesPrinter:
return ChangesPrinter(Changes("sha", "changes")) return ChangesPrinter(Changes("sha", "changes"))
@pytest.fixture
def pkgbuild_printer() -> PkgbuildPrinter:
"""
fixture for pkgbuild printer
Returns:
PkgbuildPrinter: pkgbuild printer test instance
"""
return PkgbuildPrinter(Changes("sha", "changes", "pkgbuild content"))
@pytest.fixture @pytest.fixture
def configuration_paths_printer() -> ConfigurationPathsPrinter: def configuration_paths_printer() -> ConfigurationPathsPrinter:
""" """

Some files were not shown because too many files have changed in this diff Show More