Compare commits

...

8 Commits

87 changed files with 1383 additions and 448 deletions

View File

@@ -92,6 +92,14 @@ ahriman.application.handlers.patch module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module
-------------------------------------------

View File

@@ -28,6 +28,14 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members:
: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
-----------------------------------------

View File

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

View File

@@ -76,6 +76,14 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members:
: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
--------------------------------------

View File

@@ -425,6 +425,15 @@ The PKGBUILD class also provides some additional functions on top of that:
* Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages.
* Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``).
HTTP client
^^^^^^^^^^^
The ``ahriman.core.http`` package provides a HTTP client built on top of the ``requests`` library.
The base class ``ahriman.core.http.SyncHttpClient`` wraps ``requests.Session`` and provides common features for all HTTP interactions: configurable timeouts, retry policies with exponential backoff (using ``urllib3.util.retry.Retry``), basic authentication, custom User-Agent header, error processing, and ``make_request`` method. The session is lazily created (via ``cached_property``).
On top of that, ``ahriman.core.http.SyncAhrimanClient`` extends the base client for communication with the ahriman web service specifically. It adds automatic login on session creation (using configured credentials), ``X-Request-ID`` header injection and Unix socket transport support (via ``requests-unixsocket2``) if required.
Additional features
^^^^^^^^^^^^^^^^^^^

View File

@@ -2,7 +2,7 @@
"name": "ahriman-frontend",
"private": true,
"type": "module",
"version": "2.20.0-rc7",
"version": "2.20.0",
"scripts": {
"build": "tsc && vite build",
"dev": "vite",

View File

@@ -17,16 +17,14 @@
* 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 CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider";
import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider";
import type React from "react";
import Theme from "theme/Theme";
const queryClient = new QueryClient({
defaultOptions: {
@@ -39,8 +37,7 @@ const queryClient = new QueryClient({
export default function App(): React.JSX.Element {
return <QueryClientProvider client={queryClient}>
<ThemeProvider theme={Theme}>
<CssBaseline />
<ThemeProvider>
<NotificationProvider>
<ClientProvider>
<AuthProvider>

View File

@@ -17,6 +17,7 @@
* 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 { blue } from "@mui/material/colors";
import type { Event } from "models/Event";
import type React from "react";
import { Line } from "react-chartjs-2";
@@ -33,6 +34,8 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
{
label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0),
borderColor: blue[500],
backgroundColor: blue[200],
cubicInterpolationMode: "monotone" as const,
tension: 0.4,
},

View File

@@ -47,6 +47,7 @@ export default function AutoRefreshControl({
<Tooltip title="Auto-refresh">
<IconButton
size="small"
aria-label="Auto-refresh"
onClick={event => setAnchorEl(event.currentTarget)}
color={enabled ? "primary" : "default"}
>

View File

@@ -17,47 +17,57 @@
* 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 { Box } from "@mui/material";
import "components/common/syntaxLanguages";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
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 {
preRef?: RefObject<HTMLElement | null>;
getText: () => string;
content: string;
height?: number | string;
language?: string;
onScroll?: () => void;
wordBreak?: boolean;
preRef?: RefObject<HTMLElement | null>;
}
export default function CodeBlock({
preRef,
getText,
content,
height,
language = "text",
onScroll,
wordBreak,
preRef,
}: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}>
<Box
ref={preRef}
component="pre"
onScroll={onScroll}
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
height,
sx={{ overflow: "auto", height }}
>
<SyntaxHighlighter
language={language}
style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines
customStyle={{
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
margin: 0,
minHeight: "100%",
}}
>
<code>
{getText()}
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
{content}
</SyntaxHighlighter>
</Box>
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>;
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
/*
* 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 {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
enabled: open,
});

View File

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

View File

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

View File

@@ -17,7 +17,9 @@
* 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 { Box, Container } from "@mui/material";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import { Box, Container, IconButton, Tooltip } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import LoginDialog from "components/dialogs/LoginDialog";
import Footer from "components/layout/Footer";
@@ -27,6 +29,7 @@ import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import { useThemeMode } from "hooks/useThemeMode";
import type { InfoResponse } from "models/InfoResponse";
import React, { useEffect, useState } from "react";
@@ -34,6 +37,7 @@ export default function AppLayout(): React.JSX.Element {
const client = useClient();
const { setAuthState } = useAuth();
const { setRepositories } = useRepository();
const { mode, toggleTheme } = useThemeMode();
const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({
@@ -58,6 +62,11 @@ export default function AppLayout(): React.JSX.Element {
<Box sx={{ flex: 1 }}>
<Navbar />
</Box>
<Tooltip title="Toggle theme">
<IconButton aria-label="Toggle theme" onClick={toggleTheme}>
{mode === "dark" ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Tooltip>
</Box>
<PackageTable

View File

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

View File

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

View File

@@ -17,19 +17,10 @@
* 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 { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
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 } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
@@ -37,34 +28,7 @@ interface ChangesTabProps {
}
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const client = useClient();
const data = usePackageChanges(packageBase, repository);
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={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>;
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
}

View File

@@ -55,7 +55,7 @@ export default function PackagePatchesList({
sx={{ flex: 1 }}
/>
{editable &&
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
<IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
}

View File

@@ -0,0 +1,34 @@
/*
* 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

@@ -0,0 +1,27 @@
/*
* 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

@@ -22,9 +22,9 @@ import { createContext } from "react";
export interface RepositoryContextValue {
repositories: RepositoryId[];
current: RepositoryId | null;
currentRepository: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void;
setCurrent: (repository: RepositoryId) => void;
setCurrentRepository: (repository: RepositoryId) => void;
}
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 hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const current = useMemo(() => {
const currentRepository = useMemo(() => {
if (repositories.length === 0) {
return null;
}
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]);
const setCurrent = useCallback((repository: RepositoryId) => {
const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key;
}, []);
const value = useMemo(() => ({
repositories, current, setRepositories, setCurrent,
}), [repositories, current, setCurrent]);
repositories, currentRepository, setRepositories, setCurrentRepository,
}), [repositories, currentRepository, setCurrentRepository]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
}

View File

@@ -0,0 +1,27 @@
/*
* 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 { createContext } from "react";
export interface ThemeContextValue {
mode: "light" | "dark";
toggleTheme: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);

View File

@@ -0,0 +1,56 @@
/*
* 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 CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
import { defaults as chartDefaults } from "chart.js";
import { ThemeContext } from "contexts/ThemeContext";
import { useLocalStorage } from "hooks/useLocalStorage";
import React, { useCallback, useEffect, useMemo } from "react";
import { createAppTheme } from "theme/Theme";
function systemPreference(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [mode, setMode] = useLocalStorage<"light" | "dark">("theme-mode", systemPreference());
const toggleTheme = useCallback(() => {
setMode(prev => prev === "light" ? "dark" : "light");
}, [setMode]);
const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => {
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
chartDefaults.color = textColor;
chartDefaults.borderColor = gridColor;
}, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
return <ThemeContext.Provider value={value}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>;
}

View File

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

View File

@@ -0,0 +1,36 @@
/*
* 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 {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken,
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});
const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});

View File

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

View File

@@ -0,0 +1,25 @@
/*
* 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 { ThemeContext, type ThemeContextValue } from "contexts/ThemeContext";
import { useContextNotNull } from "hooks/useContextNotNull";
export function useThemeMode(): ThemeContextValue {
return useContextNotNull(ThemeContext);
}

View File

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

View File

@@ -21,15 +21,15 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "models/BuildStatus";
const base: Record<BuildStatus, string> = {
unknown: grey[800],
pending: amber[900],
building: orange[900],
failed: red[900],
success: green[800],
unknown: grey[600],
pending: amber[700],
building: orange[800],
failed: red[700],
success: green[700],
};
const headerBase: Record<BuildStatus, string> = {
unknown: grey[800],
unknown: grey[600],
pending: amber[700],
building: orange[600],
failed: red[500],

View File

@@ -17,9 +17,13 @@
* 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 { createTheme } from "@mui/material/styles";
import { createTheme, type Theme } from "@mui/material/styles";
const Theme = createTheme({
export function createAppTheme(mode: "light" | "dark"): Theme {
return createTheme({
palette: {
mode,
},
components: {
MuiDialog: {
defaultProps: {
@@ -28,6 +32,5 @@ const Theme = createTheme({
},
},
},
});
export default Theme;
});
}

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ class ApplicationRepository(ApplicationProperties):
if last_commit_sha is None:
continue # skip check in case if we can't calculate diff
changes = self.repository.package_changes(package, last_commit_sha)
if (changes := self.repository.package_changes(package, last_commit_sha)) is not None:
self.repository.reporter.package_changes_update(package.base, changes)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:

View File

@@ -54,7 +54,7 @@ class Change(Handler):
case Action.List:
changes = client.package_changes_get(args.package)
ChangesPrinter(changes)(verbose=True, separator="")
Change.check_status(args.exit_code, not changes.is_empty)
Change.check_status(args.exit_code, changes.changes is not None)
case Action.Remove:
client.package_changes_update(args.package, Changes())

View File

@@ -0,0 +1,101 @@
#
# 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
"""
application = Application(repository_id, configuration, report=True)
client = application.repository.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

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

View File

@@ -0,0 +1,81 @@
#
# 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,6 +26,7 @@ from typing import ClassVar
from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, utcnow, walk
from ahriman.models.changes import Changes
from ahriman.models.package import Package
from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -51,24 +52,25 @@ class Sources(LazyLogging):
}
@staticmethod
def changes(source_dir: Path, last_commit_sha: str | None) -> str | None:
def changes(source_dir: Path, last_commit_sha: str) -> Changes:
"""
extract changes from the last known commit if available
Args:
source_dir(Path): local path to directory with source files
last_commit_sha(str | None): last known commit hash
last_commit_sha(str): last known commit hash
Returns:
str | None: changes from the last commit if available or ``None`` otherwise
Changes: changes from the last commit if available
"""
if last_commit_sha is None:
return None # no previous reference found
instance = Sources()
diff = None
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
return instance.diff(source_dir, last_commit_sha)
return None
diff = instance.diff(source_dir, last_commit_sha)
pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD"))
return Changes(last_commit_sha, diff, pkgbuild)
@staticmethod
def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]:
@@ -413,3 +415,17 @@ class Sources(LazyLogging):
cwd=sources_dir, input_data=patch.serialize(), logger=self.logger)
else:
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,11 +19,9 @@
#
from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like
from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"]
@@ -61,12 +59,9 @@ def migrate_package_depends(connection: Connection, configuration: Configuration
if not configuration.repository_paths.repository.is_dir():
return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
base = Package.from_archive(full_path, pacman)
base = Package.from_archive(full_path)
for package, description in base.packages.items():
package_list.append({
"make_depends": description.make_depends,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
#
# 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

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

View File

@@ -26,6 +26,7 @@ from ahriman.core.formatters.event_stats_printer import EventStatsPrinter
from ahriman.core.formatters.package_printer import PackagePrinter
from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter
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.repository_printer import RepositoryPrinter
from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter

View File

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

View File

@@ -0,0 +1,62 @@
#
# 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,14 +17,10 @@
# 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 collections.abc import Callable
from functools import cmp_to_key
from ahriman.core import context
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.core.triggers import Trigger
from ahriman.core.utils import package_like
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result
@@ -78,27 +74,20 @@ class ArchiveRotationTrigger(Trigger):
"""
return list(cls.CONFIGURATION_SCHEMA.keys())
def archives_remove(self, package: Package, pacman: Pacman) -> None:
def archives_remove(self, package: Package, repository: Repository) -> None:
"""
remove older versions of the specified package
Args:
package(Package): package which has been updated to check for older versions
pacman(Pacman): alpm wrapper instance
repository(Repository): repository instance
"""
# explicit guard to skip process in case if rotation is disabled
# this guard is supposed to speedup process
if self.keep_built_packages == 0:
return
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))
to_remove = repository.package_archives(package.base)
for single in to_remove[:-self.keep_built_packages]:
self.logger.info("removing version %s of package %s", single.version, single.base)
@@ -115,7 +104,7 @@ class ArchiveRotationTrigger(Trigger):
packages(list[Package]): list of all available packages
"""
ctx = context.get()
pacman = ctx.get(Pacman)
repository = ctx.get(Repository)
for package in result.success:
self.archives_remove(package, pacman)
self.archives_remove(package, repository)

View File

@@ -57,7 +57,7 @@ class Executor(PackageInfo, Cleaner):
for path in filter(package_like, archive.iterdir()):
# check if package version is the same
built = Package.from_archive(path, self.pacman)
built = Package.from_archive(path)
if built.version != package.version:
continue
@@ -117,7 +117,7 @@ class Executor(PackageInfo, Cleaner):
else:
built = task.build(path, PACKAGER=packager)
package.with_packages(built, self.pacman)
package.with_packages(built)
for src in built:
dst = self.paths.packages / src.name
atomic_move(src, dst)
@@ -204,7 +204,8 @@ class Executor(PackageInfo, Cleaner):
# update commit hash for changes keeping current diff if there is any
changes = self.reporter.package_changes_get(single.base)
self.reporter.package_changes_update(single.base, Changes(commit_sha, changes.changes))
self.reporter.package_changes_update(
single.base, Changes(commit_sha, changes.changes, changes.pkgbuild))
# update dependencies list
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)

View File

@@ -19,23 +19,36 @@
#
import copy
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from functools import cmp_to_key
from pathlib import Path
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.sources import Sources
from ahriman.core.repository.repository_properties import RepositoryProperties
from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging
from ahriman.core.status import Client
from ahriman.core.utils import package_like
from ahriman.models.changes import Changes
from ahriman.models.package import Package
class PackageInfo(RepositoryProperties):
class PackageInfo(LazyLogging):
"""
handler for the package information
Attributes:
configuration(Configuration): configuration instance
pacman(Pacman): alpm wrapper instance
reporter(Client): build status reporter instance
"""
configuration: Configuration
pacman: Pacman
reporter: Client
def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]:
"""
generate full dependencies list including transitive dependencies
@@ -86,7 +99,7 @@ class PackageInfo(RepositoryProperties):
# we are iterating over bases, not single packages
for full_path in packages:
try:
local = Package.from_archive(full_path, self.pacman)
local = Package.from_archive(full_path)
if (source := sources.get(local.base)) is not None: # update source with remote
local.remote = source
@@ -102,27 +115,48 @@ class PackageInfo(RepositoryProperties):
self.logger.exception("could not load package from %s", full_path)
return list(result.values())
def package_changes(self, package: Package, last_commit_sha: str | None) -> Changes:
def package_archives(self, package_base: str) -> list[Package]:
"""
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
"""
paths = self.configuration.repository_paths
packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, paths.archive_for(package_base).iterdir()):
local = Package.from_archive(full_path)
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_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
"""
extract package change for the package since last commit if available
Args:
package(Package): package properties
last_commit_sha(str | None): last known commit hash
last_commit_sha(str): last known commit hash
Returns:
Changes: changes if available
Changes | None: changes if available
"""
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
dir_path = Path(dir_name)
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.configuration.repository_paths)
changes: str | None = None
if current_commit_sha != last_commit_sha:
changes = Sources.changes(dir_path, last_commit_sha)
return Changes(last_commit_sha, changes)
return Sources.changes(dir_path, last_commit_sha)
return None
def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]:
"""
@@ -134,7 +168,7 @@ class PackageInfo(RepositoryProperties):
Returns:
list[Package]: list of packages properties
"""
packages = self.load_archives(filter(package_like, self.paths.repository.iterdir()))
packages = self.load_archives(filter(package_like, self.configuration.repository_paths.repository.iterdir()))
if filter_packages:
packages = [package for package in packages if package.base in filter_packages]
@@ -147,7 +181,7 @@ class PackageInfo(RepositoryProperties):
Returns:
list[Path]: list of filenames from the directory
"""
return list(filter(package_like, self.paths.packages.iterdir()))
return list(filter(package_like, self.configuration.repository_paths.packages.iterdir()))
def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]:
"""

View File

@@ -23,6 +23,7 @@ from typing import Any, Self
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.log import LazyLogging
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
@@ -39,15 +40,18 @@ class Watcher(LazyLogging):
Attributes:
client(Client): reporter instance
package_info(PackageInfo): package info instance
status(BuildStatus): daemon status
"""
def __init__(self, client: Client) -> None:
def __init__(self, client: Client, package_info: PackageInfo) -> None:
"""
Args:
client(Client): reporter instance
package_info(PackageInfo): package info instance
"""
self.client = client
self.package_info = package_info
self._lock = Lock()
self._known: dict[str, tuple[Package, BuildStatus]] = {}
@@ -80,6 +84,15 @@ class Watcher(LazyLogging):
logs_rotate: Callable[[int], None]
def package_archives(self, package_base: str) -> list[Package]:
"""
get known package archives
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_update: Callable[[str, Changes], None]

View File

@@ -31,20 +31,12 @@ class Changes:
Attributes:
last_commit_sha(str | None): last commit hash
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
changes: 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
pkgbuild: str | None = None
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:

View File

@@ -26,6 +26,7 @@ from pyalpm import vercmp # type: ignore[import-not-found]
from typing import Any, Self
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.log import LazyLogging
from ahriman.core.utils import dataclass_view, full_version, list_flatmap, parse_version, srcinfo_property_list
@@ -186,18 +187,17 @@ class Package(LazyLogging):
return sorted(packages)
@classmethod
def from_archive(cls, path: Path, pacman: Pacman) -> Self:
def from_archive(cls, path: Path) -> Self:
"""
construct package properties from package archive
Args:
path(Path): path to package archive
pacman(Pacman): alpm wrapper instance
Returns:
Self: package properties
"""
package = pacman.handle.load_pkg(str(path))
package = PacmanHandle.ephemeral().package_load(path)
description = PackageDescription.from_package(package, path)
return cls(
base=package.base or package.name,
@@ -400,17 +400,16 @@ class Package(LazyLogging):
"""
return dataclass_view(self)
def with_packages(self, packages: Iterable[Path], pacman: Pacman) -> None:
def with_packages(self, packages: Iterable[Path]) -> None:
"""
replace packages descriptions with ones from archives
Args:
packages(Iterable[Path]): paths to package archives
pacman(Pacman): alpm wrapper instance
"""
self.packages = {} # reset state
for package in packages:
archive = self.from_archive(package, pacman)
archive = self.from_archive(package)
if archive.base != self.base:
continue

View File

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

View File

@@ -0,0 +1,65 @@
#
# 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 changes
Returns:
Response: 200 with package change on success
Raises:
HTTPNotFound: if package base is unknown
"""
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,10 +92,11 @@ class ChangesView(StatusViewGuard, BaseView):
data = await self.request.json()
last_commit_sha = data.get("last_commit_sha") # empty/null meant removal
change = data.get("changes")
pkgbuild = data.get("pkgbuild")
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
changes = Changes(last_commit_sha, change)
changes = Changes(last_commit_sha, change, pkgbuild)
self.service().package_changes_update(package_base, changes)
raise HTTPNoContent

View File

@@ -23,12 +23,14 @@ import logging
import socket
from aiohttp.web import Application, normalize_path_middleware, run_app
from pathlib import Path
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.distributed import WorkersCache
from ahriman.core.exceptions import InitializeError
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn
from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher
@@ -78,6 +80,33 @@ def _create_socket(configuration: Configuration, application: Application) -> so
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
return Watcher(client, package_info)
async def _on_shutdown(application: Application) -> None:
"""
web application shutdown handler
@@ -168,18 +197,11 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
# package cache
if not repositories:
raise InitializeError("No repositories configured, exiting")
watchers: dict[RepositoryId, Watcher] = {}
configuration_path, _ = configuration.check_loaded()
for repository_id in repositories:
application.logger.info("load repository %s", repository_id)
# 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
application[WatcherKey] = {
repository_id: _create_watcher(configuration_path, repository_id)
for repository_id in repositories
}
# workers cache
application[WorkersKey] = WorkersCache(configuration)
# process spawner

View File

@@ -18,7 +18,7 @@ def test_changes(application_repository: ApplicationRepository, package_ahriman:
"""
must generate changes for the packages
"""
changes = Changes("hash", "change")
changes = Changes("sha", "change")
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)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
@@ -42,6 +42,20 @@ def test_changes_skip(application_repository: ApplicationRepository, package_ahr
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:
"""
must clean cache directory

View File

@@ -0,0 +1,100 @@
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

@@ -309,6 +309,44 @@ def test_subparsers_package_changes_remove_package_changes(parser: argparse.Argu
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:
"""
package-copy command must correctly parse architecture list

View File

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

View File

@@ -0,0 +1,37 @@
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,6 +6,7 @@ from unittest.mock import call as MockCall
from ahriman.core.build_tools.sources import Sources
from ahriman.core.exceptions import CalledProcessError
from ahriman.models.changes import Changes
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -19,35 +20,27 @@ def test_changes(mocker: MockerFixture) -> None:
"""
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")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
local = Path("local")
last_commit_sha = "sha"
assert Sources.changes(local, last_commit_sha) == "diff"
assert Sources.changes(local, last_commit_sha) == Changes(last_commit_sha, "diff", "pkgbuild")
fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha)
diff_mock.assert_called_once_with(local, last_commit_sha)
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()
read_mock.assert_called_once_with(local, "HEAD", Path("PKGBUILD"))
def test_changes_unknown_commit(mocker: MockerFixture) -> None:
"""
must return none in case if commit sha wasn't found at the required depth
must return changes without diff 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)
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") is None
assert Sources.changes(Path("local"), "sha") == Changes("sha", None, "pkgbuild")
diff_mock.assert_not_called()
read_mock.assert_called_once_with(Path("local"), "HEAD", Path("PKGBUILD"))
def test_extend_architectures(mocker: MockerFixture) -> None:
@@ -603,3 +596,12 @@ def test_patch_apply_function(sources: Sources, mocker: MockerFixture) -> None:
sources.patch_apply(local, patch)
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,8 +34,7 @@ def test_migrate_package_depends(connection: Connection, configuration: Configur
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_depends(connection, configuration)
package_mock.assert_called_once_with(
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"make_depends": package_ahriman.packages[package_ahriman.base].make_depends,
"opt_depends": package_ahriman.packages[package_ahriman.base].opt_depends,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,24 +2,26 @@ import pytest
from pathlib import Path
from ahriman.core.formatters import \
AurPrinter, \
ChangesPrinter, \
ConfigurationPathsPrinter, \
ConfigurationPrinter, \
EventStatsPrinter, \
PackagePrinter, \
PackageStatsPrinter, \
PatchPrinter, \
RepositoryPrinter, \
RepositoryStatsPrinter, \
StatusPrinter, \
StringPrinter, \
TreePrinter, \
UpdatePrinter, \
UserPrinter, \
ValidationPrinter, \
from ahriman.core.formatters import (
AurPrinter,
ChangesPrinter,
ConfigurationPathsPrinter,
ConfigurationPrinter,
EventStatsPrinter,
PackagePrinter,
PackageStatsPrinter,
PatchPrinter,
PkgbuildPrinter,
RepositoryPrinter,
RepositoryStatsPrinter,
StatusPrinter,
StringPrinter,
TreePrinter,
UpdatePrinter,
UserPrinter,
ValidationPrinter,
VersionPrinter
)
from ahriman.models.aur_package import AURPackage
from ahriman.models.build_status import BuildStatus
from ahriman.models.changes import Changes
@@ -55,6 +57,17 @@ def changes_printer() -> ChangesPrinter:
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
def configuration_paths_printer() -> ConfigurationPathsPrinter:
"""

View File

@@ -0,0 +1,32 @@
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.changes import Changes
def test_properties(pkgbuild_printer: PkgbuildPrinter) -> None:
"""
must return non-empty properties list
"""
assert pkgbuild_printer.properties()
def test_properties_empty() -> None:
"""
must return empty properties list if pkgbuild is empty
"""
assert not PkgbuildPrinter(Changes()).properties()
assert not PkgbuildPrinter(Changes("sha", "changes")).properties()
def test_title(pkgbuild_printer: PkgbuildPrinter) -> None:
"""
must return non-empty title
"""
assert pkgbuild_printer.title()
def test_title_empty() -> None:
"""
must return empty title if change is empty
"""
assert not PkgbuildPrinter(Changes()).title()
assert not PkgbuildPrinter(Changes("sha")).title()

View File

@@ -3,12 +3,11 @@ import pytest
from dataclasses import replace
from pathlib import Path
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.core.housekeeping import ArchiveRotationTrigger
from ahriman.core.repository import Repository
from ahriman.models.package import Package
from ahriman.models.result import Result
@@ -21,26 +20,24 @@ def test_configuration_sections(configuration: Configuration) -> None:
def test_archives_remove(archive_rotation_trigger: ArchiveRotationTrigger, package_ahriman: Package,
pacman: Pacman, mocker: MockerFixture) -> None:
repository: Repository, mocker: MockerFixture) -> None:
"""
must remove older packages
"""
def package(version: Any, *args: Any, **kwargs: Any) -> Package:
generated = replace(package_ahriman, version=str(version))
packages = []
for i in range(5):
generated = replace(package_ahriman, version=str(i))
generated.packages = {
key: replace(value, filename=str(version))
key: replace(value, filename=str(i))
for key, value in generated.packages.items()
}
return generated
packages.append(generated)
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("ahriman.core.housekeeping.archive_rotation_trigger.package_like", return_value=True)
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=packages)
mocker.patch("pathlib.Path.glob", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("pathlib.Path.iterdir", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=package)
unlink_mock = mocker.patch("pathlib.Path.unlink", autospec=True)
archive_rotation_trigger.archives_remove(package_ahriman, pacman)
archive_rotation_trigger.archives_remove(package_ahriman, repository)
unlink_mock.assert_has_calls([
MockCall(Path("0")),
MockCall(Path("1")),
@@ -48,28 +45,15 @@ def test_archives_remove(archive_rotation_trigger: ArchiveRotationTrigger, packa
def test_archives_remove_keep(archive_rotation_trigger: ArchiveRotationTrigger, package_ahriman: Package,
pacman: Pacman, mocker: MockerFixture) -> None:
repository: Repository, mocker: MockerFixture) -> None:
"""
must keep all packages if set to
"""
def package(version: Any, *args: Any, **kwargs: Any) -> Package:
generated = replace(package_ahriman, version=str(version))
generated.packages = {
key: replace(value, filename=str(version))
for key, value in generated.packages.items()
}
return generated
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("ahriman.core.housekeeping.archive_rotation_trigger.package_like", return_value=True)
mocker.patch("pathlib.Path.glob", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("pathlib.Path.iterdir", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=package)
unlink_mock = mocker.patch("pathlib.Path.unlink", autospec=True)
archives_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives")
archive_rotation_trigger.keep_built_packages = 0
archive_rotation_trigger.archives_remove(package_ahriman, pacman)
unlink_mock.assert_not_called()
archive_rotation_trigger.archives_remove(package_ahriman, repository)
archives_mock.assert_not_called()
def test_on_result(archive_rotation_trigger: ArchiveRotationTrigger, package_ahriman: Package,

View File

@@ -6,7 +6,6 @@ from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.repository.executor import Executor
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.repository.update_handler import UpdateHandler
from ahriman.models.pacman_synchronization import PacmanSynchronization
@@ -50,23 +49,6 @@ def executor(configuration: Configuration, database: SQLite, mocker: MockerFixtu
refresh_pacman_database=PacmanSynchronization.Disabled)
@pytest.fixture
def package_info(configuration: Configuration, database: SQLite) -> PackageInfo:
"""
fixture for package info
Args:
configuration(Configuration): configuration fixture
database(SQLite): database fixture
Returns:
PackageInfo: package info test instance
"""
_, repository_id = configuration.check_loaded()
return PackageInfo(repository_id, configuration, database, report=False,
refresh_pacman_database=PacmanSynchronization.Disabled)
@pytest.fixture
def update_handler(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> UpdateHandler:
"""

View File

@@ -118,7 +118,7 @@ def test_package_build(executor: Executor, package_ahriman: Package, mocker: Moc
init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None)
package_mock.assert_called_once_with(Path("local"), executor.architecture, None)
lookup_mock.assert_called_once_with(package_ahriman)
with_packages_mock.assert_called_once_with([Path(package_ahriman.base)], executor.pacman)
with_packages_mock.assert_called_once_with([Path(package_ahriman.base)])
rename_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base)
@@ -219,7 +219,7 @@ def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any
mocker.patch("ahriman.models.repository_paths.getpwuid", return_value=passwd)
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
changes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("commit", "change"))
return_value=Changes("commit", "change", "pkgbuild"))
commit_sha_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
depends_on_mock = mocker.patch("ahriman.core.build_tools.package_archive.PackageArchive.depends_on",
return_value=Dependencies())
@@ -231,7 +231,7 @@ def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any
build_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(Path, strict=True), None, None)
depends_on_mock.assert_called_once_with()
dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies())
commit_sha_mock.assert_called_once_with(package_ahriman.base, Changes("sha", "change"))
commit_sha_mock.assert_called_once_with(package_ahriman.base, Changes("sha", "change", "pkgbuild"))
def test_process_build_bump_pkgrel(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@@ -4,12 +4,12 @@ from pathlib import Path
from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.repository import Repository
from ahriman.models.changes import Changes
from ahriman.models.package import Package
def test_full_depends(package_info: PackageInfo, package_ahriman: Package, package_python_schedule: Package,
def test_full_depends(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
pyalpm_package_ahriman: MagicMock) -> None:
"""
must extract all dependencies from the package
@@ -18,18 +18,18 @@ def test_full_depends(package_info: PackageInfo, package_ahriman: Package, packa
database_mock = MagicMock()
database_mock.pkgcache = [pyalpm_package_ahriman]
package_info.pacman = MagicMock()
package_info.pacman.handle.get_syncdbs.return_value = [database_mock]
repository.pacman = MagicMock()
repository.pacman.handle.get_syncdbs.return_value = [database_mock]
assert package_info.full_depends(package_ahriman, [package_python_schedule]) == package_ahriman.depends
assert repository.full_depends(package_ahriman, [package_python_schedule]) == package_ahriman.depends
package_python_schedule.packages[package_python_schedule.base].depends = [package_ahriman.base]
expected = sorted(set(package_python_schedule.depends + package_ahriman.depends))
assert package_info.full_depends(package_python_schedule, [package_python_schedule]) == expected
assert repository.full_depends(package_python_schedule, [package_python_schedule]) == expected
def test_load_archives(package_ahriman: Package, package_python_schedule: Package,
package_info: PackageInfo, mocker: MockerFixture) -> None:
def test_load_archives(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must return all packages grouped by package base
"""
@@ -45,7 +45,7 @@ def test_load_archives(package_ahriman: Package, package_python_schedule: Packag
(package_ahriman, None),
])
packages = package_info.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz"), Path("c.pkg.tar.xz")])
packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz"), Path("c.pkg.tar.xz")])
assert len(packages) == 2
assert {package.base for package in packages} == {package_ahriman.base, package_python_schedule.base}
@@ -56,22 +56,22 @@ def test_load_archives(package_ahriman: Package, package_python_schedule: Packag
assert set(archives) == expected
def test_load_archives_failed(package_info: PackageInfo, mocker: MockerFixture) -> None:
def test_load_archives_failed(repository: Repository, mocker: MockerFixture) -> None:
"""
must skip packages which cannot be loaded
"""
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=Exception)
assert not package_info.load_archives([Path("a.pkg.tar.xz")])
assert not repository.load_archives([Path("a.pkg.tar.xz")])
def test_load_archives_not_package(package_info: PackageInfo) -> None:
def test_load_archives_not_package(repository: Repository) -> None:
"""
must skip not packages from iteration
"""
assert not package_info.load_archives([Path("a.tar.xz")])
assert not repository.load_archives([Path("a.tar.xz")])
def test_load_archives_different_version(package_info: PackageInfo, package_python_schedule: Package,
def test_load_archives_different_version(repository: Repository, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must load packages with different versions choosing maximal
@@ -86,37 +86,61 @@ def test_load_archives_different_version(package_info: PackageInfo, package_pyth
single_packages[0].version = "0.0.1-1"
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=single_packages)
packages = package_info.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz")])
packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz")])
assert len(packages) == 1
assert packages[0].version == package_python_schedule.version
def test_package_changes(package_info: PackageInfo, package_ahriman: Package, mocker: MockerFixture) -> None:
def test_package_archives(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must load package archives sorted by version
"""
from dataclasses import replace
from typing import Any
def package(version: Any, *args: Any, **kwargs: Any) -> Package:
generated = replace(package_ahriman, version=str(version))
generated.packages = {
key: replace(value, filename=str(version))
for key, value in generated.packages.items()
}
return generated
mocker.patch("ahriman.core.repository.package_info.package_like", return_value=True)
mocker.patch("pathlib.Path.iterdir", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=package)
result = repository.package_archives(package_ahriman.base)
assert len(result) == 5
assert [p.version for p in result] == [str(i) for i in range(5)]
def test_package_changes(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must load package changes
"""
changes = Changes("sha", "change")
load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha2")
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes", return_value=changes.changes)
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes", return_value=changes)
assert package_info.package_changes(package_ahriman, changes.last_commit_sha) == changes
load_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman, [], package_info.paths)
assert repository.package_changes(package_ahriman, changes.last_commit_sha) == changes
load_mock.assert_called_once_with(
pytest.helpers.anyvar(int), package_ahriman, [], repository.configuration.repository_paths)
changes_mock.assert_called_once_with(pytest.helpers.anyvar(int), changes.last_commit_sha)
def test_package_changes_skip(package_info: PackageInfo, package_ahriman: Package, mocker: MockerFixture) -> None:
def test_package_changes_skip(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must skip loading package changes if no new commits
"""
changes = Changes("sha")
mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value=changes.last_commit_sha)
mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha")
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes")
assert package_info.package_changes(package_ahriman, changes.last_commit_sha) == changes
assert repository.package_changes(package_ahriman, "sha") is None
changes_mock.assert_not_called()
def test_packages(package_info: PackageInfo, package_ahriman: Package, package_python_schedule: Package,
def test_packages(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must return repository packages
@@ -124,12 +148,12 @@ def test_packages(package_info: PackageInfo, package_ahriman: Package, package_p
mocker.patch("pathlib.Path.iterdir")
load_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
return_value=[package_ahriman, package_python_schedule])
assert package_info.packages() == [package_ahriman, package_python_schedule]
assert repository.packages() == [package_ahriman, package_python_schedule]
# it uses filter object, so we cannot verify argument list =/
load_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_packages_filter(package_info: PackageInfo, package_ahriman: Package, package_python_schedule: Package,
def test_packages_filter(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must filter result by bases
@@ -137,33 +161,33 @@ def test_packages_filter(package_info: PackageInfo, package_ahriman: Package, pa
mocker.patch("pathlib.Path.iterdir")
mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
return_value=[package_ahriman, package_python_schedule])
assert package_info.packages([package_ahriman.base]) == [package_ahriman]
assert repository.packages([package_ahriman.base]) == [package_ahriman]
def test_packages_built(package_info: PackageInfo, mocker: MockerFixture) -> None:
def test_packages_built(repository: Repository, mocker: MockerFixture) -> None:
"""
must return build packages
"""
mocker.patch("pathlib.Path.iterdir", return_value=[Path("a.tar.xz"), Path("b.pkg.tar.xz")])
assert package_info.packages_built() == [Path("b.pkg.tar.xz")]
assert repository.packages_built() == [Path("b.pkg.tar.xz")]
def test_packages_depend_on(package_info: PackageInfo, package_ahriman: Package, package_python_schedule: Package,
def test_packages_depend_on(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must filter packages by depends list
"""
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
assert package_info.packages_depend_on([package_ahriman], {"python-srcinfo"}) == [package_ahriman]
assert repository.packages_depend_on([package_ahriman], {"python-srcinfo"}) == [package_ahriman]
def test_packages_depend_on_empty(package_info: PackageInfo, package_ahriman: Package, package_python_schedule: Package,
def test_packages_depend_on_empty(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must return all packages in case if no filter is provided
"""
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
assert package_info.packages_depend_on([package_ahriman, package_python_schedule], None) == \
assert repository.packages_depend_on([package_ahriman, package_python_schedule], None) == \
[package_ahriman, package_python_schedule]

View File

@@ -8,6 +8,16 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package
def test_package_archives(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package archives from package info
"""
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[package_ahriman])
result = watcher.package_archives(package_ahriman.base)
assert result == [package_ahriman]
def test_packages(watcher: Watcher, package_ahriman: Package) -> None:
"""
must return list of available packages

View File

@@ -89,22 +89,6 @@ def pkgbuild_ahriman(resource_path_root: Path) -> Pkgbuild:
return Pkgbuild.from_file(pkgbuild)
@pytest.fixture
def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock:
"""
mock object for pyalpm
Args:
pyalpm_package_ahriman(MagicMock): mock object for pyalpm package
Returns:
MagicMock: pyalpm mock
"""
mock = MagicMock()
mock.handle.load_pkg.return_value = pyalpm_package_ahriman
return mock
@pytest.fixture
def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescription) -> MagicMock:
"""

View File

@@ -1,17 +1,6 @@
from ahriman.models.changes import Changes
def test_is_empty() -> None:
"""
must check if changes are empty
"""
assert Changes().is_empty
assert Changes("sha").is_empty
assert not Changes("sha", "change").is_empty
assert not Changes(None, "change").is_empty # well, ok
def test_changes_from_json_view() -> None:
"""
must construct same object from json

View File

@@ -148,13 +148,14 @@ def test_packages_full(package_ahriman: Package) -> None:
assert package_ahriman.packages_full == [package_ahriman.base, f"{package_ahriman.base}-git"]
def test_from_archive(package_ahriman: Package, pyalpm_handle: MagicMock, mocker: MockerFixture) -> None:
def test_from_archive(package_ahriman: Package, pyalpm_package_ahriman: MagicMock, mocker: MockerFixture) -> None:
"""
must construct package from alpm library
"""
mocker.patch("ahriman.core.alpm.pacman_handle.PacmanHandle.package_load", return_value=pyalpm_package_ahriman)
mocker.patch("ahriman.models.package_description.PackageDescription.from_package",
return_value=package_ahriman.packages[package_ahriman.base])
generated = Package.from_archive(Path("path"), pyalpm_handle)
generated = Package.from_archive(Path("path"))
generated.remote = package_ahriman.remote
assert generated == package_ahriman
@@ -165,13 +166,12 @@ def test_from_archive_empty_base(package_ahriman: Package, pyalpm_package_ahrima
"""
must construct package with empty base from alpm library
"""
pyalpm_handle = MagicMock()
type(pyalpm_package_ahriman).base = PropertyMock(return_value=None)
pyalpm_handle.handle.load_pkg.return_value = pyalpm_package_ahriman
mocker.patch("ahriman.core.alpm.pacman_handle.PacmanHandle.package_load", return_value=pyalpm_package_ahriman)
mocker.patch("ahriman.models.package_description.PackageDescription.from_package",
return_value=package_ahriman.packages[package_ahriman.base])
generated = Package.from_archive(Path("path"), pyalpm_handle)
generated = Package.from_archive(Path("path"))
generated.remote = package_ahriman.remote
assert generated == package_ahriman
@@ -362,7 +362,7 @@ def test_vercmp(package_ahriman: Package, mocker: MockerFixture) -> None:
vercmp_mock.assert_called_once_with(package_ahriman.version, "version")
def test_with_packages(package_ahriman: Package, package_python_schedule: Package, pacman: Pacman,
def test_with_packages(package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must correctly replace packages descriptions
@@ -375,8 +375,8 @@ def test_with_packages(package_ahriman: Package, package_python_schedule: Packag
result = copy.deepcopy(package_ahriman)
package_ahriman.packages[package_ahriman.base].architecture = "i686"
result.with_packages(paths, pacman)
result.with_packages(paths)
from_archive_mock.assert_has_calls([
MockCall(path, pacman) for path in paths
MockCall(path) for path in paths
])
assert result.packages[result.base] == package_ahriman.packages[package_ahriman.base]

View File

@@ -10,7 +10,7 @@ from ahriman.core.exceptions import InitializeError
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
from ahriman.web.keys import ConfigurationKey
from ahriman.web.web import _create_socket, _on_shutdown, _on_startup, run_server, setup_server
from ahriman.web.web import _create_socket, _create_watcher, _on_shutdown, _on_startup, run_server, setup_server
async def test_create_socket(application: Application, mocker: MockerFixture) -> None:
@@ -139,6 +139,20 @@ def test_run_with_socket(application: Application, mocker: MockerFixture) -> Non
)
def test_create_watcher(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must create watcher for repository
"""
database_mock = mocker.patch("ahriman.core.database.SQLite.load")
client_mock = mocker.patch("ahriman.core.status.Client.load")
configuration_path, repository_id = configuration.check_loaded()
result = _create_watcher(configuration_path, repository_id)
assert isinstance(result, Watcher)
database_mock.assert_called_once()
client_mock.assert_called_once()
def test_setup_no_repositories(configuration: Configuration, spawner: Spawn) -> None:
"""
must raise InitializeError if no repositories set

View File

@@ -0,0 +1,52 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.packages.archives import Archives
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await Archives.get_permission(request) == UserAccess.Reporter
def test_routes() -> None:
"""
must return correct routes
"""
assert Archives.ROUTES == ["/api/v1/packages/{package}/archives"]
async def test_get(client: TestClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must get archives for package
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
mocker.patch("ahriman.core.status.watcher.Watcher.package_archives", return_value=[package_ahriman])
response_schema = pytest.helpers.schema_response(Archives.get)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/archives")
assert response.status == 200
archives = await response.json()
assert not response_schema.validate(archives)
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return not found for missing package
"""
response_schema = pytest.helpers.schema_response(Archives.get, code=404)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/archives")
assert response.status == 404
assert not response_schema.validate(await response.json())