Skip to content

Commit 2e0de38

Browse files
committed
feat: WIP pip resource
1 parent c13df5f commit 2e0de38

File tree

3 files changed

+240
-0
lines changed

3 files changed

+240
-0
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { JenvResource } from './resources/java/jenv/jenv.js';
1717
import { NvmResource } from './resources/node/nvm/nvm.js';
1818
import { Pnpm } from './resources/node/pnpm/pnpm.js';
1919
import { PgcliResource } from './resources/pgcli/pgcli.js';
20+
import { PipResource } from './resources/python/pip/pip.js';
2021
import { PyenvResource } from './resources/python/pyenv/pyenv.js';
2122
import { VenvProject } from './resources/python/venv/venv-project.js';
2223
import { Virtualenv } from './resources/python/virtualenv/virtualenv.js';
@@ -66,5 +67,6 @@ runPlugin(Plugin.create(
6667
new Pnpm(),
6768
new WaitGithubSshKey(),
6869
new VenvProject(),
70+
new PipResource(),
6971
])
7072
)

src/resources/python/pip/pip.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan,
4+
ModifyPlan,
5+
ParameterChange,
6+
RefreshContext,
7+
Resource,
8+
ResourceSettings,
9+
getPty
10+
} from 'codify-plugin-lib';
11+
import { ResourceConfig } from 'codify-schemas';
12+
13+
import { codifySpawn } from '../../../utils/codify-spawn.js';
14+
15+
interface PipListResult {
16+
name: string;
17+
version?: string;
18+
}
19+
20+
export interface PipResourceConfig extends ResourceConfig {
21+
install: Array<PipListResult | string>,
22+
virtualEnv?: string,
23+
}
24+
25+
export class PipResource extends Resource<PipResourceConfig> {
26+
27+
getSettings(): ResourceSettings<PipResourceConfig> {
28+
return {
29+
id: 'pip',
30+
parameterSettings: {
31+
install: {
32+
type: 'array',
33+
itemType: 'object',
34+
canModify: true,
35+
isElementEqual(desired: PipListResult | string, current: PipListResult | string) {
36+
if (typeof desired === 'string' && typeof current === 'string') {
37+
return desired === current;
38+
}
39+
40+
// We can do this check because of the pre-filtering we are doing in refresh. It converts the current to match the desired if it is defined.
41+
return (desired as PipListResult).name === (current as PipListResult).name;
42+
}
43+
},
44+
virtualEnv: { type: 'directory' }
45+
},
46+
allowMultiple: {
47+
identifyingParameters: ['virtualEnv']
48+
}
49+
}
50+
}
51+
52+
async refresh(parameters: Partial<PipResourceConfig>, context: RefreshContext<PipResourceConfig>): Promise<Partial<PipResourceConfig> | Partial<PipResourceConfig>[] | null> {
53+
const pty = getPty()
54+
55+
const { status: pipStatus } = await pty.spawnSafe('which pip');
56+
if (pipStatus === 'error') {
57+
return null;
58+
}
59+
60+
const { status: pipListStatus, data: installedPackages } = await pty.spawnSafe(
61+
(parameters.virtualEnv ? `source ${parameters.virtualEnv}/bin/activate; ` : '')
62+
+ 'pip list --format=json --disable-pip-version-check'
63+
+ (parameters.virtualEnv ? '; deactivate' : ''))
64+
65+
if (pipListStatus === 'error') {
66+
return null;
67+
}
68+
69+
// With the way that Codify is currently setup, we must transform the current parameters returned to match the desired if they are the same beforehand.
70+
// The diffing algo is not smart enough to differentiate between same two items but different (modify) and same two items but same (keep).
71+
const parsedInstalledPackages = JSON.parse(installedPackages)
72+
.map(({ name, version }: { name: string; version: string}) => {
73+
const match = parameters.install!.find((p) => {
74+
if (typeof p === 'string') {
75+
return p === name;
76+
}
77+
78+
return p.name === name;
79+
})
80+
81+
if (!match) {
82+
return { name, version };
83+
}
84+
85+
if (typeof match === 'string') {
86+
return name;
87+
}
88+
89+
if (!match.version) {
90+
return { name };
91+
}
92+
93+
return { name, version };
94+
});
95+
96+
console.log(parsedInstalledPackages);
97+
98+
return {
99+
...parameters,
100+
install: parsedInstalledPackages,
101+
}
102+
}
103+
104+
async create(plan: CreatePlan<PipResourceConfig>): Promise<void> {
105+
const { install, virtualEnv } = plan.desiredConfig;
106+
107+
await this.pipInstall(install, virtualEnv);
108+
}
109+
110+
async modify(pc: ParameterChange<PipResourceConfig>, plan: ModifyPlan<PipResourceConfig>): Promise<void> {
111+
const { install: desiredInstall, virtualEnv } = plan.desiredConfig;
112+
const { install: currentInstall } = plan.currentConfig;
113+
114+
const toInstall = desiredInstall.filter((d) => !this.findMatchingForModify(d, currentInstall));
115+
const toUninstall = currentInstall.filter((c) => !this.findMatchingForModify(c, desiredInstall));
116+
117+
if (toUninstall.length > 0) {
118+
await this.pipUninstall(toUninstall, virtualEnv);
119+
}
120+
121+
if (toInstall.length > 0) {
122+
await this.pipInstall(toInstall, virtualEnv)
123+
}
124+
}
125+
126+
async destroy(plan: DestroyPlan<PipResourceConfig>): Promise<void> {
127+
const { install, virtualEnv } = plan.currentConfig;
128+
129+
await this.pipUninstall(install, virtualEnv);
130+
}
131+
132+
private async pipInstall(packages: Array<PipListResult | string>, virtualEnv?: string): Promise<void> {
133+
const packagesToInstall = packages.map((p) => {
134+
if (typeof p === 'string') {
135+
return p;
136+
}
137+
138+
if (!p.version) {
139+
return p.name
140+
}
141+
142+
return `${p.name}===${p.version}`;
143+
});
144+
145+
await codifySpawn(
146+
(virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '')
147+
+ `pip install ${packagesToInstall.join(' ')}`
148+
)
149+
}
150+
151+
private async pipUninstall(packages: Array<PipListResult | string>, virtualEnv?: string): Promise<void> {
152+
const packagesToUninstall = packages.map((p) => {
153+
if (typeof p === 'string') {
154+
return p;
155+
}
156+
157+
return p.name;
158+
});
159+
160+
await codifySpawn(
161+
(virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '')
162+
+ `pip install ${packagesToUninstall.join(' ')}`
163+
)
164+
}
165+
166+
findMatchingForModify(d: PipListResult | string, cList: Array<PipListResult | string>): PipListResult | string | undefined {
167+
return cList.find((c) => {
168+
if (typeof d === 'string' && typeof c === 'string') {
169+
return d === c;
170+
}
171+
172+
if (!(typeof d === 'object' && typeof c === 'object')) {
173+
return false;
174+
}
175+
176+
if (d.name !== c.name) {
177+
return false;
178+
}
179+
180+
if (d.version && d.version !== c.version) {
181+
return false
182+
}
183+
184+
return true;
185+
})
186+
}
187+
}

test/python/pip.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2+
import { PluginTester } from 'codify-plugin-test';
3+
import * as path from 'node:path';
4+
import { execSync } from 'child_process';
5+
import fs from 'node:fs';
6+
import os from 'node:os';
7+
8+
describe('Pyenv resource integration tests', () => {
9+
const pluginPath = path.resolve('./src/index.ts');
10+
11+
it('Installs pyenv and python (this installs on a clean system without readline, openSSL, etc.)', { timeout: 500000 }, async () => {
12+
await PluginTester.fullTest(pluginPath, [
13+
{
14+
type: 'pyenv',
15+
pythonVersions: ['3.11']
16+
}
17+
], {
18+
skipUninstall: true,
19+
validateApply: () => {
20+
expect(() => execSync('source ~/.zshrc; which pyenv', { shell: 'zsh' })).to.not.throw();
21+
}
22+
});
23+
});
24+
25+
it ('Can install additional python versions. (this installs after openSSL and readline have been installed)', { timeout: 700000 }, async () => {
26+
await PluginTester.fullTest(pluginPath, [
27+
{
28+
type: 'homebrew',
29+
formulae: ['readline', 'openssl@3']
30+
},
31+
{
32+
type: 'pyenv',
33+
pythonVersions: ['3.11', '3.12'],
34+
global: '3.12',
35+
}
36+
], {
37+
validateApply: () => {
38+
expect(execSync('source ~/.zshrc; python --version', { shell: 'zsh' }).toString('utf-8')).to.include('3.12');
39+
40+
const versions = execSync('source ~/.zshrc; pyenv versions', { shell: 'zsh' }).toString('utf-8')
41+
expect(versions).to.include('3.12')
42+
expect(versions).to.include('3.11')
43+
},
44+
validateDestroy: () => {
45+
expect(fs.existsSync(path.resolve(os.homedir(), '.pyenv'))).to.be.false;
46+
expect(fs.readFileSync(path.resolve(os.homedir(), '.zshrc'), 'utf-8')).to.not.contain('pyenv');
47+
expect(() => execSync('source ~/.zshrc; which pyenv', { shell: 'zsh' })).to.throw();
48+
}
49+
})
50+
})
51+
})

0 commit comments

Comments
 (0)